diff --git a/AllTests-mainnet.md b/AllTests-mainnet.md index 360b04b23..ba4708a5e 100644 --- a/AllTests-mainnet.md +++ b/AllTests-mainnet.md @@ -46,6 +46,14 @@ OK: 2/2 Fail: 0/2 Skip: 0/2 + Multiaddress to ENode OK ``` OK: 2/2 Fail: 0/2 Skip: 0/2 +## Fork Choice + Finality [Preset: mainnet] +```diff ++ fork_choice - testing finality #01 OK ++ fork_choice - testing finality #02 OK ++ fork_choice - testing no votes OK ++ fork_choice - testing with votes OK +``` +OK: 4/4 Fail: 0/4 Skip: 0/4 ## Honest validator ```diff + Attestation topics OK @@ -234,4 +242,4 @@ OK: 4/4 Fail: 0/4 Skip: 0/4 OK: 8/8 Fail: 0/8 Skip: 0/8 ---TOTAL--- -OK: 145/148 Fail: 3/148 Skip: 0/148 +OK: 149/152 Fail: 3/152 Skip: 0/152 diff --git a/AllTests-minimal.md b/AllTests-minimal.md index 8eb159921..77498f7cf 100644 --- a/AllTests-minimal.md +++ b/AllTests-minimal.md @@ -73,6 +73,14 @@ OK: 2/2 Fail: 0/2 Skip: 0/2 + Multiaddress to ENode OK ``` OK: 2/2 Fail: 0/2 Skip: 0/2 +## Fork Choice + Finality [Preset: minimal] +```diff ++ fork_choice - testing finality #01 OK ++ fork_choice - testing finality #02 OK ++ fork_choice - testing no votes OK ++ fork_choice - testing with votes OK +``` +OK: 4/4 Fail: 0/4 Skip: 0/4 ## Honest validator ```diff + Attestation topics OK @@ -261,4 +269,4 @@ OK: 4/4 Fail: 0/4 Skip: 0/4 OK: 8/8 Fail: 0/8 Skip: 0/8 ---TOTAL--- -OK: 160/163 Fail: 3/163 Skip: 0/163 +OK: 164/167 Fail: 3/167 Skip: 0/167 diff --git a/beacon_chain.nimble b/beacon_chain.nimble index af3435134..fd545c511 100644 --- a/beacon_chain.nimble +++ b/beacon_chain.nimble @@ -52,8 +52,12 @@ task test, "Run all tests": # price we pay for that. # Minimal config + buildBinary "proto_array", "beacon_chain/fork_choice/", "-d:const_preset=minimal" + buildBinary "fork_choice", "beacon_chain/fork_choice/", "-d:const_preset=minimal" buildBinary "all_tests", "tests/", "-d:chronicles_log_level=TRACE -d:const_preset=minimal" # Mainnet config + buildBinary "proto_array", "beacon_chain/fork_choice/", "-d:const_preset=mainnet" + buildBinary "fork_choice", "beacon_chain/fork_choice/", "-d:const_preset=mainnet" buildBinary "all_tests", "tests/", "-d:const_preset=mainnet" # Generic SSZ test, doesn't use consensus objects minimal/mainnet presets @@ -69,4 +73,3 @@ task test, "Run all tests": # State sim; getting into 4th epoch useful to trigger consensus checks buildBinary "state_sim", "research/", "", "--validators=1024 --slots=32" buildBinary "state_sim", "research/", "-d:const_preset=mainnet", "--validators=1024 --slots=128" - diff --git a/beacon_chain/fork_choice/README.md b/beacon_chain/fork_choice/README.md new file mode 100644 index 000000000..1f8f742d1 --- /dev/null +++ b/beacon_chain/fork_choice/README.md @@ -0,0 +1,5 @@ +# Fork choice implementations + +References: +- https://github.com/ethereum/eth2.0-specs/blob/v0.10.1/specs/phase0/fork-choice.md +- https://github.com/protolambda/lmd-ghost diff --git a/beacon_chain/fork_choice/fork_choice.nim b/beacon_chain/fork_choice/fork_choice.nim new file mode 100644 index 000000000..7dcbb87bd --- /dev/null +++ b/beacon_chain/fork_choice/fork_choice.nim @@ -0,0 +1,582 @@ +# 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. + +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] + ): ForkChoiceError {.raises: [Defect].} +# 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 + ): Result[ForkChoice, string] {.raises: [Defect].} = + ## 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)) + +func extend[T](s: var seq[T], minLen: int) {.raises: [Defect].} = + ## 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 + ) {.raises: [Defect].} = + ## 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 + ): Result[void, string] {.raises: [Defect].} = + ## 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] + ): Result[Eth2Digest, string] {.raises: [Defect].} = + ## 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 + ): Result[void, string] {.raises: [Defect].} = + ## 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] + ): ForkChoiceError {.raises: [Defect].} = + ## 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() diff --git a/beacon_chain/fork_choice/fork_choice_types.nim b/beacon_chain/fork_choice/fork_choice_types.nim new file mode 100644 index 000000000..437536312 --- /dev/null +++ b/beacon_chain/fork_choice/fork_choice_types.nim @@ -0,0 +1,127 @@ + +# 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. + +import + # Standard library + std/tables, std/options, + # Internal + ../spec/[datatypes, digest] + +# 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 + +# ProtoArray low-level types +# ---------------------------------------------------------------------- + +type + FcErrKind* = enum + ## Fork Choice Error Kinds + fcSuccess + fcErrFinalizedNodeUnknown + fcErrJustifiedNodeUnknown + fcErrInvalidFinalizedRootCHange + fcErrInvalidNodeIndex + fcErrInvalidParentIndex + fcErrInvalidBestChildIndex + fcErrInvalidJustifiedIndex + fcErrInvalidBestDescendant + fcErrInvalidParentDelta + fcErrInvalidNodeDelta + fcErrDeltaUnderflow + fcErrIndexUnderflow + fcErrInvalidDeltaLen + fcErrRevertedFinalizedEpoch + fcErrInvalidBestNode + + FcUnderflowKind* = enum + ## Fork Choice Overflow Kinds + fcUnderflowIndices = "Indices Overflow" + fcUnderflowBestChild = "Best Child Overflow" + fcUnderflowBestDescendant = "Best Descendant Overflow" + + Index* = int + Delta* = int + ## Delta indices + + ForkChoiceError* = object + case kind*: FcErrKind + of fcSuccess: + discard + of fcErrFinalizedNodeUnknown, + fcErrJustifiedNodeUnknown: + block_root*: Eth2Digest + of fcErrInvalidFinalizedRootChange: + discard + of fcErrInvalidNodeIndex, + fcErrInvalidParentIndex, + fcErrInvalidBestChildIndex, + fcErrInvalidJustifiedIndex, + fcErrInvalidBestDescendant, + fcErrInvalidParentDelta, + fcErrInvalidNodeDelta, + fcErrDeltaUnderflow: + index*: Index + of fcErrIndexUnderflow: + underflowKind*: FcUnderflowKind + of fcErrInvalidDeltaLen: + deltasLen*: int + indicesLen*: int + of fcErrRevertedFinalizedEpoch: + current_finalized_epoch*: Epoch + new_finalized_epoch*: Epoch + of fcErrInvalidBestNode: + start_root*: Eth2Digest + justified_epoch*: Epoch + finalized_epoch*: Epoch + head_root*: Eth2Digest + head_justified_epoch*: Epoch + head_finalized_epoch*: Epoch + + ProtoArray* = object + prune_threshold*: int + justified_epoch*: Epoch + finalized_epoch*: Epoch + nodes*: seq[ProtoNode] + indices*: Table[Eth2Digest, Index] + + ProtoNode* = object + # TODO: generic "Metadata" field for slot/state_root + slot*: Slot # This is unnecessary for fork choice but helps external components + state_root*: Eth2Digest # This is unnecessary for fork choice but helps external components + # Fields used in fork choice + root*: Eth2Digest + parent*: Option[Index] + justified_epoch*: Epoch + finalized_epoch*: Epoch + weight*: int64 + best_child*: Option[Index] + best_descendant*: Option[Index] + +const ForkChoiceSuccess* = ForkChoiceError(kind: fcSuccess) + +# Fork choice high-level types +# ---------------------------------------------------------------------- + +type + VoteTracker* = object + current_root*: Eth2Digest + next_root*: Eth2Digest + next_epoch*: Epoch + + ForkChoice* = object + # Note: Lighthouse is protecting all fields with Reader-Writer locks. + # However, given the nature of the fields, I suspect sharing those fields + # will lead to thread contention. For now, stay single-threaded. - Mamy + proto_array*: ProtoArray + votes*: seq[VoteTracker] + balances*: seq[Gwei] diff --git a/beacon_chain/fork_choice/proto_array.nim b/beacon_chain/fork_choice/proto_array.nim new file mode 100644 index 000000000..8548a641d --- /dev/null +++ b/beacon_chain/fork_choice/proto_array.nim @@ -0,0 +1,507 @@ +# 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. + +import + # Standard library + std/tables, std/options, std/typetraits, + # Internal + ../spec/[datatypes, digest], + # Fork choice + ./fork_choice_types + +# 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 + +# Helper +# ---------------------------------------------------------------------- + +func tiebreak(a, b: Eth2Digest): bool = + ## Fork-Choice tie-break between 2 digests + ## Currently implemented as `>=` (greater or equal) + ## on the binary representation + for i in 0 ..< a.data.len: + if a.data[i] < b.data[i]: + return false + elif a.data[i] > b.data[i]: + return true + # else we have equality so far + return true + +template getOrFailcase*[K, V](table: Table[K, V], key: K, failcase: untyped): V = + ## Get a value from a Nim Table, turning KeyError into + ## the "failcase" + block: + # TODO: try/except expression with Nim v1.2.0: + # https://github.com/status-im/nim-beacon-chain/pull/865#discussion_r404856551 + var value: V + try: + value = table[key] + except KeyError: + failcase + value + +template unsafeGet*[K, V](table: Table[K, V], key: K): V = + ## Get a value from a Nim Table, turning KeyError into + ## an AssertionError defect + getOrFailcase(table, key): + doAssert false, "The " & astToStr(table) & " table shouldn't miss a key" + +# Forward declarations +# ---------------------------------------------------------------------- + +func maybe_update_best_child_and_descendant(self: var ProtoArray, parent_index: Index, child_index: Index): ForkChoiceError {.raises: [Defect].} +func node_is_viable_for_head(self: ProtoArray, node: ProtoNode): bool {.raises: [Defect].} +func node_leads_to_viable_head(self: ProtoArray, node: ProtoNode): tuple[viable: bool, err: ForkChoiceError] {.raises: [Defect].} + +# ProtoArray routines +# ---------------------------------------------------------------------- + +func apply_score_changes*( + self: var ProtoArray, + deltas: var openarray[Delta], + justified_epoch: Epoch, + finalized_epoch: Epoch + ): ForkChoiceError {.raises: [Defect].}= + ## Iterate backwards through the array, touching all nodes and their parents + ## and potentially the best-child of each parent. + ## + ## The structure of `self.nodes` array ensures that the child of each node + ## is always touched before it's aprent. + ## + ## For each node the following is done: + ## + ## 1. Update the node's weight with the corresponding delta. + ## 2. Backpropagate each node's delta to its parent's delta. + ## 3. Compare the current node with the parent's best-child, + ## updating if the current node should become the best-child + ## 4. If required, update the parent's best-descendant with the current node or its best-descendant + if deltas.len != self.indices.len: + return ForkChoiceError( + kind: fcErrInvalidDeltaLen, + deltasLen: deltas.len, + indicesLen: self.indices.len + ) + + self.justified_epoch = justified_epoch + self.finalized_epoch = finalized_epoch + + # Iterate backwards through all the indices in `self.nodes` + for node_index in countdown(self.nodes.len - 1, 0): + template node: untyped {.dirty.}= self.nodes[node_index] + ## Alias + # This cannot raise the IndexError exception, how to tell compiler? + + if node.root == default(Eth2Digest): + continue + + if node_index notin {0..deltas.len-1}: + # TODO: Here `deltas.len == self.indices.len` from the previous check + # and we can probably assume that + # `self.indices.len == self.nodes.len` by construction + # and avoid this check in a loop or altogether + return ForkChoiceError( + kind: fcErrInvalidNodeDelta, + index: node_index + ) + let node_delta = deltas[node_index] + + # Apply the delta to the node + # We fail fast if underflow, which shouldn't happen. + # Note that delta can be negative but weight cannot + let weight = node.weight + node_delta + if weight < 0: + return ForkChoiceError( + kind: fcErrDeltaUnderflow, + index: node_index + ) + node.weight = weight + + # If the node has a parent, try to update its best-child and best-descendant + if node.parent.isSome(): + # TODO: Nim `options` module could use some {.inline.} + # and a mutable overload for unsafeGet + # and a "no exceptions" (only panics) implementation. + let parent_index = node.parent.unsafeGet() + if parent_index notin {0..deltas.len-1}: + return ForkChoiceError( + kind: fcErrInvalidParentDelta, + index: parent_index + ) + + # Back-propagate the nodes delta to its parent. + deltas[parent_index] += node_delta + + let err = self.maybe_update_best_child_and_descendant(parent_index, node_index) + if err.kind != fcSuccess: + return err + + return ForkChoiceSuccess + + +func on_block*( + self: var ProtoArray, + slot: Slot, + root: Eth2Digest, + parent: Option[Eth2Digest], + state_root: Eth2Digest, + justified_epoch: Epoch, + finalized_epoch: Epoch + ): ForkChoiceError {.raises: [Defect].} = + ## Register a block with the fork choice + ## A `none` parent is only valid for Genesis + + # If the block is already known, ignore it + if root in self.indices: + return ForkChoiceSuccess + + let node_index = self.nodes.len + + let parent_index = block: + if parent.isNone: + none(int) + elif parent.unsafeGet() notin self.indices: + # Is this possible? + none(int) + else: + some(self.indices.unsafeGet(parent.unsafeGet())) + + let node = ProtoNode( + slot: slot, + state_root: state_root, + root: root, + parent: parent_index, + justified_epoch: justified_epoch, + finalized_epoch: finalized_epoch, + weight: 0, + best_child: none(int), + best_descendant: none(int) + ) + + self.indices[node.root] = node_index + self.nodes.add node # TODO: if this is costly, we can setLen + construct the node in-place + + if parent_index.isSome(): + let err = self.maybe_update_best_child_and_descendant(parent_index.unsafeGet(), node_index) + if err.kind != fcSuccess: + return err + + return ForkChoiceSuccess + +func find_head*( + self: var ProtoArray, + head: var Eth2Digest, + justified_root: Eth2Digest + ): ForkChoiceError {.raises: [Defect].} = + ## Follows the best-descendant links to find the best-block (i.e. head-block) + ## + ## ⚠️ Warning + ## The result may not be accurate if `on_new_block` + ## is not followed by `apply_score_changes` as `on_new_block` does not + ## update the whole tree. + + let justified_index = self.indices.getOrFailcase(justified_root): + return ForkChoiceError( + kind: fcErrJustifiedNodeUnknown, + block_root: justified_root + ) + + if justified_index notin {0..self.nodes.len-1}: + return ForkChoiceError( + kind: fcErrInvalidJustifiedIndex, + index: justified_index + ) + + template justified_node: untyped {.dirty.} = self.nodes[justified_index] + # Alias, IndexError are defects + + let best_descendant_index = block: + if justified_node.best_descendant.isSome(): + justified_node.best_descendant.unsafeGet() + else: + justified_index + + if best_descendant_index notin {0..self.nodes.len-1}: + return ForkChoiceError( + kind: fcErrInvalidBestDescendant, + index: best_descendant_index + ) + template best_node: untyped {.dirty.} = self.nodes[best_descendant_index] + # Alias, IndexError are defects + + # Perform a sanity check to ensure the node can be head + if not self.node_is_viable_for_head(best_node): + return ForkChoiceError( + kind: fcErrInvalidBestNode, + start_root: justified_root, + justified_epoch: self.justified_epoch, + finalized_epoch: self.finalized_epoch, + head_root: justified_node.root, + head_justified_epoch: justified_node.justified_epoch, + head_finalized_epoch: justified_node.finalized_epoch + ) + + head = best_node.root + return ForkChoiceSuccess + +# TODO: pruning can be made cheaper by keeping the new offset as a field +# in proto_array instead of scanning the table to substract the offset. +# In that case pruning can always be done and does not need a threshold for efficiency. +# https://github.com/protolambda/eth2-py-hacks/blob/ae286567/proto_array.py +func maybe_prune*( + self: var ProtoArray, + finalized_root: Eth2Digest + ): ForkChoiceError {.raises: [Defect].} = + ## Update the tree with new finalization information. + ## The tree is pruned if and only if: + ## - The `finalized_root` and finalized epoch are different from current + ## - The number of nodes in `self` is at least `self.prune_threshold` + ## + ## Returns error if: + ## - The finalized epoch is less than the current one + ## - The finalized epoch matches the current one but the finalized root is different + ## - Internal error due to invalid indices in `self` + let finalized_index = self.indices.getOrFailcase(finalized_root): + return ForkChoiceError( + kind: fcErrFinalizedNodeUnknown, + block_root: finalized_root + ) + + if finalized_index < self.prune_threshold: + # Pruning small numbers of nodes incurs more overhead than leaving them as is + return ForkChoiceSuccess + + # Remove the `self.indices` key/values for the nodes slated for deletion + if finalized_index notin {0..self.nodes.len-1}: + return ForkChoiceError( + kind: fcErrInvalidNodeIndex, + index: finalized_index + ) + for node_index in 0 ..< finalized_index: + self.indices.del(self.nodes[node_index].root) + + # Drop all nodes prior to finalization. + # This is done in-place with `moveMem` to avoid costly reallocations. + static: doAssert ProtoNode.supportsCopyMem(), "ProtoNode must be a trivial type" + let tail = self.nodes.len - finalized_index + # TODO: can we have an unallocated `self.nodes`? i.e. self.nodes[0] is nil + moveMem(self.nodes[0].addr, self.nodes[finalized_index].addr, tail * sizeof(ProtoNode)) + self.nodes.setLen(tail) + + # Adjust the indices map + for index in self.indices.mvalues(): + index -= finalized_index + if index < 0: + return ForkChoiceError( + kind: fcErrIndexUnderflow, + underflowKind: fcUnderflowIndices + ) + + # Iterate through all the existing nodes and adjust their indices to match + # the new layout of `self.nodes` + for node in self.nodes.mitems(): + # If `node.parent` is less than `finalized_index`, set it to None + if node.parent.isSome(): + let new_parent = node.parent.unsafeGet() - finalized_index + if new_parent < 0: + node.parent = none(Index) + else: + node.parent = some(new_parent) + + if node.best_child.isSome(): + let new_best_child = node.best_child.unsafeGet() - finalized_index + if new_best_child < 0: + return ForkChoiceError( + kind: fcErrIndexUnderflow, + underflowKind: fcUnderflowBestChild + ) + node.best_child = some(new_best_child) + + if node.best_descendant.isSome(): + let new_best_descendant = node.best_descendant.unsafeGet() - finalized_index + if new_best_descendant < 0: + return ForkChoiceError( + kind: fcErrIndexUnderflow, + underflowKind: fcUnderflowBestDescendant + ) + node.best_descendant = some(new_best_descendant) + + return ForkChoiceSuccess + + +func maybe_update_best_child_and_descendant( + self: var ProtoArray, + parent_index: Index, + child_index: Index): ForkChoiceError {.raises: [Defect].} = + ## Observe the parent at `parent_index` with respect to the child at `child_index` and + ## potentiatlly modify the `parent.best_child` and `parent.best_descendant` values + ## + ## There are four scenarios: + ## + ## 1. The child is already the best child + ## but it's now invalid due to a FFG change and should be removed. + ## 2. The child is already the best child + ## and the parent is updated with the new best descendant + ## 3. The child is not the best child but becomes the best child + ## 4. The child is not the best child and does not become the best child + + if child_index notin {0..self.nodes.len-1}: + return ForkChoiceError( + kind: fcErrInvalidNodeIndex, + index: child_index + ) + if parent_index notin {0..self.nodes.len-1}: + return ForkChoiceError( + kind: fcErrInvalidNodeIndex, + index: parent_index + ) + + # Aliases + template child: untyped {.dirty.} = self.nodes[child_index] + template parent: untyped {.dirty.} = self.nodes[parent_index] + + let (child_leads_to_viable_head, err) = self.node_leads_to_viable_head(child) + if err.kind != fcSuccess: + return err + + let # Aliases to the 3 possible (best_child, best_descendant) tuples + change_to_none = (none(Index), none(Index)) + change_to_child = ( + some(child_index), + # Nim `options` module doesn't implement option `or` + if child.best_descendant.isSome(): child.best_descendant + else: some(child_index) + ) + no_change = (parent.best_child, parent.best_descendant) + + # TODO: state-machine? The control-flow is messy + let (new_best_child, new_best_descendant) = block: + if parent.best_child.isSome: + let best_child_index = parent.best_child.unsafeGet() + if best_child_index == child_index and not child_leads_to_viable_head: + # The child is already the best-child of the parent + # but it's not viable to be the head block => remove it + change_to_none + elif best_child_index == child_index: + # If the child is the best-child already, set it again to ensure + # that the best-descendant of the parent is up-to-date. + change_to_child + else: + if best_child_index notin {0..self.nodes.len-1}: + return ForkChoiceError( + kind: fcErrInvalidBestDescendant, + index: best_child_index + ) + let best_child = self.nodes[best_child_index] + + let (best_child_leads_to_viable_head, err) = self.node_leads_to_viable_head(best_child) + if err.kind != fcSuccess: + return err + + if child_leads_to_viable_head and not best_child_leads_to_viable_head: + # The child leads to a viable head, but the current best-child doesn't + change_to_child + elif not child_leads_to_viable_head and best_child_leads_to_viable_head: + # The best child leads to a viable head, but the child doesn't + no_change + elif child.weight == best_child.weight: + # Tie-breaker of equal weights by root + if child.root.tiebreak(best_child.root): + change_to_child + else: + no_change + else: # Choose winner by weight + if child.weight >= best_child.weight: + change_to_child + else: + no_change + else: + if child_leads_to_viable_head: + # There is no current best-child and the child is viable + change_to_child + else: + # There is no current best-child but the child is not viable + no_change + + self.nodes[parent_index].best_child = new_best_child + self.nodes[parent_index].best_descendant = new_best_descendant + + return ForkChoiceSuccess + +func node_leads_to_viable_head( + self: ProtoArray, node: ProtoNode + ): tuple[viable: bool, err: ForkChoiceError] {.raises: [Defect].} = + ## Indicates if the node itself or its best-descendant are viable + ## for blockchain head + let best_descendant_is_viable_for_head = block: + if node.best_descendant.isSome(): + let best_descendant_index = node.best_descendant.unsafeGet() + if best_descendant_index notin {0..self.nodes.len-1}: + return ( + false, + ForkChoiceError( + kind: fcErrInvalidBestDescendant, + index: best_descendant_index + ) + ) + let best_descendant = self.nodes[best_descendant_index] + self.node_is_viable_for_head(best_descendant) + else: + false + + return ( + best_descendant_is_viable_for_head or + self.node_is_viable_for_head(node), + ForkChoiceSuccess + ) + +func node_is_viable_for_head(self: ProtoArray, node: ProtoNode): bool {.raises: [Defect].} = + ## This is the equivalent of `filter_block_tree` function in eth2 spec + ## https://github.com/ethereum/eth2.0-specs/blob/v0.10.0/specs/phase0/fork-choice.md#filter_block_tree + ## + ## Any node that has a different finalized or justified epoch + ## should not be viable for the head. + ( + (node.justified_epoch == self.justified_epoch) or + (self.justified_epoch == Epoch(0)) + ) and ( + (node.finalized_epoch == self.finalized_epoch) or + (self.finalized_epoch == Epoch(0)) + ) + +# Sanity checks +# ---------------------------------------------------------------------- +# Sanity checks on internal private procedures + +when isMainModule: + import nimcrypto/[hash, utils] + + echo "Sanity checks on fork choice tiebreaks" + + block: + let a = Eth2Digest.fromHex("0x0000000000000001000000000000000000000000000000000000000000000000") + let b = Eth2Digest.fromHex("0x0000000000000000000000000000000000000000000000000000000000000000") # sha256(1) + + doAssert tiebreak(a, b) + + + block: + let a = Eth2Digest.fromHex("0x0000000000000002000000000000000000000000000000000000000000000000") + let b = Eth2Digest.fromHex("0x0000000000000001000000000000000000000000000000000000000000000000") # sha256(1) + + doAssert tiebreak(a, b) + + + block: + let a = Eth2Digest.fromHex("0xD86E8112F3C4C4442126F8E9F44F16867DA487F29052BF91B810457DB34209A4") # sha256(2) + let b = Eth2Digest.fromHex("0x7C9FA136D4413FA6173637E883B6998D32E1D675F88CDDFF9DCBCF331820F4B8") # sha256(1) + + doAssert tiebreak(a, b) diff --git a/tests/all_tests.nim b/tests/all_tests.nim index 396b02f77..7bc1442b3 100644 --- a/tests/all_tests.nim +++ b/tests/all_tests.nim @@ -29,7 +29,8 @@ import # Unit test ./test_peer_pool, ./test_sync_manager, ./test_honest_validator, - ./test_interop + ./test_interop, + ./fork_choice/tests_fork_choice import # Refactor state transition unit tests # TODO re-enable when useful diff --git a/tests/fork_choice/interpreter.nim b/tests/fork_choice/interpreter.nim new file mode 100644 index 000000000..987ab36c9 --- /dev/null +++ b/tests/fork_choice/interpreter.nim @@ -0,0 +1,112 @@ +# beacon_chain +# Copyright (c) 2018 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. + +import + # Standard library + std/strformat, std/tables, std/options, + # Status libraries + stew/[result, endians2], + # Internals + ../../beacon_chain/spec/[datatypes, digest], + ../../beacon_chain/fork_choice/[fork_choice, fork_choice_types] + +export result, datatypes, digest, fork_choice, fork_choice_types, tables, options + +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() + +# The fork choice tests are quite complex. +# For flexibility in block arrival, timers, operations sequencing, ... +# we create a small interpreter that will trigger events in proper order +# before fork choice. + +type + OpKind* = enum + FindHead + InvalidFindHead + ProcessBlock + ProcessAttestation + Prune + + Operation* = object + # variant specific fields + case kind*: OpKind + of FindHead, InvalidFindHead: + justified_epoch*: Epoch + justified_root*: Eth2Digest + finalized_epoch*: Epoch + justified_state_balances*: seq[Gwei] + expected_head*: Eth2Digest + of ProcessBlock: + root*: Eth2Digest + parent_root*: Eth2Digest + blk_justified_epoch*: Epoch + blk_finalized_epoch*: Epoch + of ProcessAttestation: + validator_index*: ValidatorIndex + block_root*: Eth2Digest + target_epoch*: Epoch + of Prune: # ProtoArray specific + finalized_root*: Eth2Digest + prune_threshold*: int + expected_len*: int + +func apply(ctx: var ForkChoice, id: int, op: Operation) = + ## Apply the specified operation to a ForkChoice context + ## ``id`` is additional debugging info. It is the + ## operation index. + # debugEcho " =========================================================================================" + case op.kind + of FindHead, InvalidFindHead: + let r = ctx.find_head( + op.justified_epoch, + op.justified_root, + op.finalized_epoch, + op.justified_state_balances + ) + if op.kind == FindHead: + doAssert r.isOk(), &"find_head (op #{id}) returned an error: {r.error}" + doAssert r.get() == op.expected_head, &"find_head (op #{id}) returned an incorrect result: {r.get()} (expected: {op.expected_head})" + debugEcho " Found expected head: 0x", op.expected_head, " from justified checkpoint(epoch: ", op.justified_epoch, ", root: 0x", op.justified_root, ")" + else: + doAssert r.isErr(), "find_head was unexpectedly successful" + debugEcho " Detected an expected invalid head" + of ProcessBlock: + let r = ctx.process_block( + slot = default(Slot), # unused in fork choice, only helpful for external components + block_root = op.root, + parent_root = op.parent_root, + state_root = default(Eth2Digest), # unused in fork choice, only helpful for external components + justified_epoch = op.blk_justified_epoch, + finalized_epoch = op.blk_finalized_epoch + ) + doAssert r.isOk(), &"process_block (op #{id}) returned an error: {r.error}" + debugEcho " Processed block 0x", op.root, " with parent 0x", op.parent_root, " and justified epoch ", op.blk_justified_epoch + of ProcessAttestation: + ctx.process_attestation( + validator_index = op.validator_index, + block_root = op.block_root, + target_epoch = op.target_epoch + ) + debugEcho " Processed att target 0x", op.block_root, " from validator ", op.validator_index, " for epoch ", op.target_epoch + of Prune: + ctx.proto_array.prune_threshold = op.prune_threshold + let r = ctx.maybe_prune(op.finalized_root) + doAssert r.isOk(), &"prune (op #{id}) returned an error: {r.error}" + doAssert ctx.proto_array.nodes.len == op.expected_len, + &"prune (op #{id}): the resulting length ({ctx.proto_array.nodes.len}) was not expected ({op.expected_len})" + debugEcho " Maybe_pruned block preceding finalized block 0x", op.finalized_root + +func run*(ctx: var ForkChoice, ops: seq[Operation]) = + ## Apply a sequence of fork-choice operations on a store + for i, op in ops: + ctx.apply(i, op) diff --git a/tests/fork_choice/scenarios/ffg_01.nim b/tests/fork_choice/scenarios/ffg_01.nim new file mode 100644 index 000000000..1ad98dd66 --- /dev/null +++ b/tests/fork_choice/scenarios/ffg_01.nim @@ -0,0 +1,129 @@ +# beacon_chain +# Copyright (c) 2018 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. + +# import ../interpreter # included to be able to use "suiteReport" + +proc setup_finality_01(): tuple[fork_choice: ForkChoice, ops: seq[Operation]] = + var balances = @[Gwei(1), Gwei(1)] + let GenesisRoot = fakeHash(0) + + # Initialize the fork choice context + result.fork_choice = initForkChoice( + finalized_block_slot = Slot(0), # Metadata unused in fork choice + finalized_block_state_root = default(Eth2Digest), # Metadata unused in fork choice + justified_epoch = Epoch(1), + finalized_epoch = Epoch(1), + finalized_root = GenesisRoot + ).get() + + # ---------------------------------- + + # Head should be genesis + result.ops.add Operation( + kind: FindHead, + justified_epoch: Epoch(1), + justified_root: GenesisRoot, + finalized_epoch: Epoch(1), + justified_state_balances: balances, + expected_head: GenesisRoot + ) + + # Build the following chain + # + # 0 <- just: 0, fin: 0 + # | + # 1 <- just: 0, fin: 0 + # | + # 2 <- just: 1, fin: 0 + # | + # 3 <- just: 2, fin: 1 + result.ops.add Operation( + kind: ProcessBlock, + root: fakeHash(1), + parent_root: GenesisRoot, + blk_justified_epoch: Epoch(0), + blk_finalized_epoch: Epoch(0) + ) + result.ops.add Operation( + kind: ProcessBlock, + root: fakeHash(2), + parent_root: fakeHash(1), + blk_justified_epoch: Epoch(1), + blk_finalized_epoch: Epoch(0) + ) + result.ops.add Operation( + kind: ProcessBlock, + root: fakeHash(3), + parent_root: fakeHash(2), + blk_justified_epoch: Epoch(2), + blk_finalized_epoch: Epoch(1) + ) + + # Ensure that with justified epoch 0 we find 3 + # + # 0 <- start + # | + # 1 + # | + # 2 + # | + # 3 <- head + result.ops.add Operation( + kind: FindHead, + justified_epoch: Epoch(0), + justified_root: GenesisRoot, + finalized_epoch: Epoch(0), + justified_state_balances: balances, + expected_head: fakeHash(3) + ) + + # Ensure that with justified epoch 1 we find 2 + # + # 0 + # | + # 1 + # | + # 2 <- start + # | + # 3 <- head + result.ops.add Operation( + kind: FindHead, + justified_epoch: Epoch(1), + justified_root: fakeHash(2), + finalized_epoch: Epoch(0), + justified_state_balances: balances, + expected_head: fakeHash(2) + ) + + # Ensure that with justified epoch 2 we find 3 + # + # 0 + # | + # 1 + # | + # 2 + # | + # 3 <- start + head + result.ops.add Operation( + kind: FindHead, + justified_epoch: Epoch(2), + justified_root: fakeHash(3), + finalized_epoch: Epoch(1), + justified_state_balances: balances, + expected_head: fakeHash(3) + ) + +proc test_ffg01() = + timedTest "fork_choice - testing finality #01": + # for i in 0 ..< 4: + # echo " block (", i, ") hash: ", fakeHash(i) + # echo " ------------------------------------------------------" + + var (ctx, ops) = setup_finality_01() + ctx.run(ops) + +test_ffg01() diff --git a/tests/fork_choice/scenarios/ffg_02.nim b/tests/fork_choice/scenarios/ffg_02.nim new file mode 100644 index 000000000..7d77e7dbf --- /dev/null +++ b/tests/fork_choice/scenarios/ffg_02.nim @@ -0,0 +1,391 @@ +# beacon_chain +# Copyright (c) 2018 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. + +# import ../interpreter # included to be able to use "suiteReport" + +proc setup_finality_02(): tuple[fork_choice: ForkChoice, ops: seq[Operation]] = + var balances = @[Gwei(1), Gwei(1)] + let GenesisRoot = fakeHash(0) + + # Initialize the fork choice context + result.fork_choice = initForkChoice( + finalized_block_slot = Slot(0), # Metadata unused in fork choice + finalized_block_state_root = default(Eth2Digest), # Metadata unused in fork choice + justified_epoch = Epoch(1), + finalized_epoch = Epoch(1), + finalized_root = GenesisRoot + ).get() + + # ---------------------------------- + + # Head should be genesis + result.ops.add Operation( + kind: FindHead, + justified_epoch: Epoch(1), + justified_root: GenesisRoot, + finalized_epoch: Epoch(1), + justified_state_balances: balances, + expected_head: GenesisRoot + ) + + # Build the following tree. + # + # 0 + # / \ + # just: 0, fin: 0 -> 1 2 <- just: 0, fin: 0 + # | | + # just: 1, fin: 0 -> 3 4 <- just: 0, fin: 0 + # | | + # just: 1, fin: 0 -> 5 6 <- just: 0, fin: 0 + # | | + # just: 1, fin: 0 -> 7 8 <- just: 1, fin: 0 + # | | + # just: 2, fin: 0 -> 9 10 <- just: 2, fin: 0 + + # Left branch + result.ops.add Operation( + kind: ProcessBlock, + root: fakeHash(1), + parent_root: GenesisRoot, + blk_justified_epoch: Epoch(0), + blk_finalized_epoch: Epoch(0) + ) + result.ops.add Operation( + kind: ProcessBlock, + root: fakeHash(3), + parent_root: fakeHash(1), + blk_justified_epoch: Epoch(1), + blk_finalized_epoch: Epoch(0) + ) + result.ops.add Operation( + kind: ProcessBlock, + root: fakeHash(5), + parent_root: fakeHash(3), + blk_justified_epoch: Epoch(1), + blk_finalized_epoch: Epoch(0) + ) + result.ops.add Operation( + kind: ProcessBlock, + root: fakeHash(7), + parent_root: fakeHash(5), + blk_justified_epoch: Epoch(1), + blk_finalized_epoch: Epoch(0) + ) + result.ops.add Operation( + kind: ProcessBlock, + root: fakeHash(9), + parent_root: fakeHash(7), + blk_justified_epoch: Epoch(2), + blk_finalized_epoch: Epoch(0) + ) + + # Build the following tree. + # + # 0 + # / \ + # just: 0, fin: 0 -> 1 2 <- just: 0, fin: 0 + # | | + # just: 1, fin: 0 -> 3 4 <- just: 0, fin: 0 + # | | + # just: 1, fin: 0 -> 5 6 <- just: 0, fin: 0 + # | | + # just: 1, fin: 0 -> 7 8 <- just: 1, fin: 0 + # | | + # just: 2, fin: 0 -> 9 10 <- just: 2, fin: 0 + + # Right branch + result.ops.add Operation( + kind: ProcessBlock, + root: fakeHash(2), + parent_root: GenesisRoot, + blk_justified_epoch: Epoch(0), + blk_finalized_epoch: Epoch(0) + ) + result.ops.add Operation( + kind: ProcessBlock, + root: fakeHash(4), + parent_root: fakeHash(2), + blk_justified_epoch: Epoch(0), + blk_finalized_epoch: Epoch(0) + ) + result.ops.add Operation( + kind: ProcessBlock, + root: fakeHash(6), + parent_root: fakeHash(4), + blk_justified_epoch: Epoch(0), + blk_finalized_epoch: Epoch(0) + ) + result.ops.add Operation( + kind: ProcessBlock, + root: fakeHash(8), + parent_root: fakeHash(6), + blk_justified_epoch: Epoch(1), + blk_finalized_epoch: Epoch(0) + ) + result.ops.add Operation( + kind: ProcessBlock, + root: fakeHash(10), + parent_root: fakeHash(8), + blk_justified_epoch: Epoch(2), + blk_finalized_epoch: Epoch(0) + ) + + # Ensure that if we start at 0 we find 10 (just: 0, fin: 0). + # + # 0 <-- start + # / \ + # 1 2 + # | | + # 3 4 + # | | + # 5 6 + # | | + # 7 8 + # | | + # 9 10 <-- head + result.ops.add Operation( + kind: FindHead, + justified_epoch: Epoch(0), + justified_root: GenesisRoot, + finalized_epoch: Epoch(0), + justified_state_balances: balances, + expected_head: fakeHash(10) + ) + + # Same with justified_epoch 2 + result.ops.add Operation( + kind: FindHead, + justified_epoch: Epoch(2), + justified_root: GenesisRoot, + finalized_epoch: Epoch(0), + justified_state_balances: balances, + expected_head: fakeHash(10) + ) + + # Justified epoch 3 is invalid + result.ops.add Operation( + kind: InvalidFindHead, + justified_epoch: Epoch(3), # <--- Wrong epoch + justified_root: GenesisRoot, + finalized_epoch: Epoch(0), + justified_state_balances: balances + ) + + # Add a vote to 1. + # + # 0 + # / \ + # +1 vote -> 1 2 + # | | + # 3 4 + # | | + # 5 6 + # | | + # 7 8 + # | | + # 9 10 + result.ops.add Operation( + kind: ProcessAttestation, + validator_index: ValidatorIndex(0), + block_root: fake_hash(1), + target_epoch: Epoch(0) + ) + + # Ensure that if we start at 0 we find 9 (just: 0, fin: 0). + # + # 0 <-- start + # / \ + # 1 2 + # | | + # 3 4 + # | | + # 5 6 + # | | + # 7 8 + # | | + # head -> 9 10 + result.ops.add Operation( + kind: FindHead, + justified_epoch: Epoch(0), + justified_root: GenesisRoot, + finalized_epoch: Epoch(0), + justified_state_balances: balances, + expected_head: fakeHash(9) + ) + + # Same with justified_epoch 2 + result.ops.add Operation( + kind: FindHead, + justified_epoch: Epoch(2), + justified_root: GenesisRoot, + finalized_epoch: Epoch(0), + justified_state_balances: balances, + expected_head: fakeHash(9) + ) + + # Justified epoch 3 is invalid + result.ops.add Operation( + kind: InvalidFindHead, + justified_epoch: Epoch(3), # <--- Wrong epoch + justified_root: GenesisRoot, + finalized_epoch: Epoch(0), + justified_state_balances: balances + ) + + # Add a vote to 2. + # + # 0 + # / \ + # 1 2 <- +1 vote + # | | + # 3 4 + # | | + # 5 6 + # | | + # 7 8 + # | | + # 9 10 + result.ops.add Operation( + kind: ProcessAttestation, + validator_index: ValidatorIndex(1), + block_root: fake_hash(2), + target_epoch: Epoch(0) + ) + + # Ensure that if we start at 0 we find 10 again (just: 0, fin: 0). + # + # 0 <-- start + # / \ + # 1 2 + # | | + # 3 4 + # | | + # 5 6 + # | | + # 7 8 + # | | + # 9 10 <-- head + result.ops.add Operation( + kind: FindHead, + justified_epoch: Epoch(0), + justified_root: GenesisRoot, + finalized_epoch: Epoch(0), + justified_state_balances: balances, + expected_head: fakeHash(10) + ) + + # Same with justified_epoch 2 + result.ops.add Operation( + kind: FindHead, + justified_epoch: Epoch(2), + justified_root: GenesisRoot, + finalized_epoch: Epoch(0), + justified_state_balances: balances, + expected_head: fakeHash(10) + ) + + # Justified epoch 3 is invalid + result.ops.add Operation( + kind: InvalidFindHead, + justified_epoch: Epoch(3), # <--- Wrong epoch + justified_root: GenesisRoot, + finalized_epoch: Epoch(0), + justified_state_balances: balances + ) + + # Ensure that if we start at 1 (instead of 0) we find 9 (just: 0, fin: 0). + # + # 0 + # / \ + # start-> 1 2 + # | | + # 3 4 + # | | + # 5 6 + # | | + # 7 8 + # | | + # head -> 9 10 + result.ops.add Operation( + kind: FindHead, + justified_epoch: Epoch(0), + justified_root: fakeHash(1), + finalized_epoch: Epoch(0), + justified_state_balances: balances, + expected_head: fakeHash(9) + ) + + # Same with justified_epoch 2 + result.ops.add Operation( + kind: FindHead, + justified_epoch: Epoch(2), + justified_root: fakeHash(1), + finalized_epoch: Epoch(0), + justified_state_balances: balances, + expected_head: fakeHash(9) + ) + + # Justified epoch 3 is invalid + result.ops.add Operation( + kind: InvalidFindHead, + justified_epoch: Epoch(3), # <--- Wrong epoch + justified_root: fakeHash(1), + finalized_epoch: Epoch(0), + justified_state_balances: balances + ) + + # Ensure that if we start at 2 (instead of 0) we find 10 (just: 0, fin: 0). + # + # 0 + # / \ + # 1 2 <- start + # | | + # 3 4 + # | | + # 5 6 + # | | + # 7 8 + # | | + # 9 10 <- head + result.ops.add Operation( + kind: FindHead, + justified_epoch: Epoch(0), + justified_root: fakeHash(2), + finalized_epoch: Epoch(0), + justified_state_balances: balances, + expected_head: fakeHash(10) + ) + + # Same with justified_epoch 2 + result.ops.add Operation( + kind: FindHead, + justified_epoch: Epoch(2), + justified_root: fakeHash(2), + finalized_epoch: Epoch(0), + justified_state_balances: balances, + expected_head: fakeHash(10) + ) + + # Justified epoch 3 is invalid + result.ops.add Operation( + kind: InvalidFindHead, + justified_epoch: Epoch(3), # <--- Wrong epoch + justified_root: fakeHash(2), + finalized_epoch: Epoch(0), + justified_state_balances: balances + ) + +proc test_ffg02() = + timedTest "fork_choice - testing finality #02": + # for i in 0 ..< 12: + # echo " block (", i, ") hash: ", fakeHash(i) + # echo " ------------------------------------------------------" + + var (ctx, ops) = setup_finality_02() + ctx.run(ops) + +test_ffg02() diff --git a/tests/fork_choice/scenarios/no_votes.nim b/tests/fork_choice/scenarios/no_votes.nim new file mode 100644 index 000000000..4ad23de7d --- /dev/null +++ b/tests/fork_choice/scenarios/no_votes.nim @@ -0,0 +1,266 @@ +# beacon_chain +# Copyright (c) 2018 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. + +# import ../interpreter # included to be able to use "suiteReport" + +proc setup_no_votes(): tuple[fork_choice: ForkChoice, ops: seq[Operation]] = + let balances = newSeq[Gwei](16) + let GenesisRoot = fakeHash(0) + + # Initialize the fork choice context + result.fork_choice = initForkChoice( + finalized_block_slot = Slot(0), # Metadata unused in fork choice + finalized_block_state_root = default(Eth2Digest), # Metadata unused in fork choice + justified_epoch = Epoch(1), + finalized_epoch = Epoch(1), + finalized_root = GenesisRoot + ).get() + + # ---------------------------------- + + # Head should be genesis + result.ops.add Operation( + kind: FindHead, + justified_epoch: Epoch(1), + justified_root: GenesisRoot, + finalized_epoch: Epoch(1), + justified_state_balances: balances, + expected_head: GenesisRoot + ) + + # Add block 2 + # + # 0 + # / + # 2 + result.ops.add Operation( + kind: ProcessBlock, + root: fakeHash(2), + parent_root: GenesisRoot, + blk_justified_epoch: Epoch(1), + blk_finalized_epoch: Epoch(1) + ) + + # Head should be 2 + # + # 0 + # / + # 2 <- head + result.ops.add Operation( + kind: FindHead, + justified_epoch: Epoch(1), + justified_root: GenesisRoot, + finalized_epoch: Epoch(1), + justified_state_balances: balances, + expected_head: fakeHash(2) + ) + + # Add block 1 as a fork + # + # 0 + # / \ + # 2 1 + result.ops.add Operation( + kind: ProcessBlock, + root: fakeHash(1), + parent_root: GenesisRoot, + blk_justified_epoch: Epoch(1), + blk_finalized_epoch: Epoch(1) + ) + + # Head is still 2 due to tiebreaker as fakeHash(2) (0xD8...) > fakeHash(1) (0x7C...) + # + # 0 + # / \ + # head-> 2 1 + result.ops.add Operation( + kind: FindHead, + justified_epoch: Epoch(1), + justified_root: GenesisRoot, + finalized_epoch: Epoch(1), + justified_state_balances: balances, + expected_head: fakeHash(2) + ) + + # Add block 3 + # + # 0 + # / \ + # 2 1 + # | + # 3 + result.ops.add Operation( + kind: ProcessBlock, + root: fakeHash(3), + parent_root: fakeHash(1), + blk_justified_epoch: Epoch(1), + blk_finalized_epoch: Epoch(1) + ) + + # Head is still 2 + # + # 0 + # / \ + # head-> 2 1 + # | + # 3 + result.ops.add Operation( + kind: FindHead, + justified_epoch: Epoch(1), + justified_root: GenesisRoot, + finalized_epoch: Epoch(1), + justified_state_balances: balances, + expected_head: fakeHash(2) + ) + + # Add block 4 + # + # 0 + # / \ + # 2 1 + # | | + # 4 3 + result.ops.add Operation( + kind: ProcessBlock, + root: fakeHash(4), + parent_root: fakeHash(2), + blk_justified_epoch: Epoch(1), + blk_finalized_epoch: Epoch(1) + ) + + # Check that head is 4 + # + # 0 + # / \ + # 2 1 + # | | + # head-> 4 3 + result.ops.add Operation( + kind: FindHead, + justified_epoch: Epoch(1), + justified_root: GenesisRoot, + finalized_epoch: Epoch(1), + justified_state_balances: balances, + expected_head: fakeHash(4) + ) + + # Add block 5 with justified epoch of 2 + # + # 0 + # / \ + # 2 1 + # | | + # 4 3 + # | + # 5 <- justified epoch = 2 + result.ops.add Operation( + kind: ProcessBlock, + root: fakeHash(5), + parent_root: fakeHash(4), + blk_justified_epoch: Epoch(2), + blk_finalized_epoch: Epoch(1) + ) + + # Ensure the head is still 4 whilst the justified epoch is 0. + # + # 0 + # / \ + # 2 1 + # | | + # head-> 4 3 + # | + # 5 + result.ops.add Operation( + kind: FindHead, + justified_epoch: Epoch(1), + justified_root: GenesisRoot, + finalized_epoch: Epoch(1), + justified_state_balances: balances, + expected_head: fakeHash(4) + ) + + # Ensure that there is an error when starting from a block with the wrong justified epoch + # 0 + # / \ + # 2 1 + # | | + # 4 3 + # | + # 5 <- starting from 5 with justified epoch 1 should error. + result.ops.add Operation( + kind: InvalidFindHead, + justified_epoch: Epoch(1), # <--- Wrong epoch + justified_root: fakeHash(5), + finalized_epoch: Epoch(1), + justified_state_balances: balances + ) + + # Set the justified epoch to 2 and the start block to 5 and ensure 5 is the head. + # 0 + # / \ + # 2 1 + # | | + # 4 3 + # | + # 5 <- head + justified + result.ops.add Operation( + kind: FindHead, + justified_epoch: Epoch(2), + justified_root: fakeHash(5), + finalized_epoch: Epoch(1), + justified_state_balances: balances, + expected_head: fakeHash(5) + ) + + # Add block 6 + # + # 0 + # / \ + # 2 1 + # | | + # 4 3 + # | + # 5 <- justified root + # | + # 6 + result.ops.add Operation( + kind: ProcessBlock, + root: fakeHash(6), + parent_root: fakeHash(5), + blk_justified_epoch: Epoch(2), + blk_finalized_epoch: Epoch(1) + ) + + # Ensure 6 is the head + # 0 + # / \ + # 2 1 + # | | + # 4 3 + # | + # 5 <- justified root + # | + # 6 <- head + result.ops.add Operation( + kind: FindHead, + justified_epoch: Epoch(2), + justified_root: fakeHash(5), + finalized_epoch: Epoch(1), + justified_state_balances: balances, + expected_head: fakeHash(6) + ) + +proc test_no_votes() = + timedTest "fork_choice - testing no votes": + # for i in 0 ..< 6: + # echo " block (", i, ") hash: ", fakeHash(i) + # echo " ------------------------------------------------------" + + var (ctx, ops) = setup_no_votes() + ctx.run(ops) + +test_no_votes() diff --git a/tests/fork_choice/scenarios/votes.nim b/tests/fork_choice/scenarios/votes.nim new file mode 100644 index 000000000..176e7794a --- /dev/null +++ b/tests/fork_choice/scenarios/votes.nim @@ -0,0 +1,718 @@ +# beacon_chain +# Copyright (c) 2018 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. + +# import ../interpreter # included to be able to use "suiteReport" + +proc setup_votes(): tuple[fork_choice: ForkChoice, ops: seq[Operation]] = + var balances = @[Gwei(1), Gwei(1)] + let GenesisRoot = fakeHash(0) + + # Initialize the fork choice context + result.fork_choice = initForkChoice( + finalized_block_slot = Slot(0), # Metadata unused in fork choice + finalized_block_state_root = default(Eth2Digest), # Metadata unused in fork choice + justified_epoch = Epoch(1), + finalized_epoch = Epoch(1), + finalized_root = GenesisRoot + ).get() + + # ---------------------------------- + + # Head should be genesis + result.ops.add Operation( + kind: FindHead, + justified_epoch: Epoch(1), + justified_root: GenesisRoot, + finalized_epoch: Epoch(1), + justified_state_balances: balances, + expected_head: GenesisRoot + ) + + # Add block 2 + # + # 0 + # / + # 2 + result.ops.add Operation( + kind: ProcessBlock, + root: fakeHash(2), + parent_root: GenesisRoot, + blk_justified_epoch: Epoch(1), + blk_finalized_epoch: Epoch(1) + ) + + # Head should be 2 + # + # 0 + # / + # 2 <- head + result.ops.add Operation( + kind: FindHead, + justified_epoch: Epoch(1), + justified_root: GenesisRoot, + finalized_epoch: Epoch(1), + justified_state_balances: balances, + expected_head: fakeHash(2) + ) + + # Add block 1 as a fork + # + # 0 + # / \ + # 2 1 + result.ops.add Operation( + kind: ProcessBlock, + root: fakeHash(1), + parent_root: GenesisRoot, + blk_justified_epoch: Epoch(1), + blk_finalized_epoch: Epoch(1) + ) + + # Head is still 2 due to tiebreaker as fakeHash(2) (0xD8...) > fakeHash(1) (0x7C...) + # + # 0 + # / \ + # head-> 2 1 + result.ops.add Operation( + kind: FindHead, + justified_epoch: Epoch(1), + justified_root: GenesisRoot, + finalized_epoch: Epoch(1), + justified_state_balances: balances, + expected_head: fakeHash(2) + ) + + # Add a vote to block 1 + # + # 0 + # / \ + # 2 1 <- +vote + result.ops.add Operation( + kind: ProcessAttestation, + validator_index: ValidatorIndex(0), + block_root: fakeHash(1), + target_epoch: Epoch(2) + ) + + # Head is now 1 as 1 has an extra vote + # + # 0 + # / \ + # 2 1 <- head + result.ops.add Operation( + kind: FindHead, + justified_epoch: Epoch(1), + justified_root: GenesisRoot, + finalized_epoch: Epoch(1), + justified_state_balances: balances, + expected_head: fakeHash(1) + ) + + # Add a vote to block 2 + # + # 0 + # / \ + # +vote-> 2 1 + result.ops.add Operation( + kind: ProcessAttestation, + validator_index: ValidatorIndex(1), + block_root: fakeHash(2), + target_epoch: Epoch(2) + ) + + # Head is back to 2 due to tiebreaker as fakeHash(2) (0xD8...) > fakeHash(1) (0x7C...) + # + # 0 + # / \ + # head-> 2 1 + result.ops.add Operation( + kind: FindHead, + justified_epoch: Epoch(1), + justified_root: GenesisRoot, + finalized_epoch: Epoch(1), + justified_state_balances: balances, + expected_head: fakeHash(2) + ) + + # Add block 3 as on chain 1 + # + # 0 + # / \ + # 2 1 + # | + # 3 + result.ops.add Operation( + kind: ProcessBlock, + root: fakeHash(3), + parent_root: fakeHash(1), + blk_justified_epoch: Epoch(1), + blk_finalized_epoch: Epoch(1) + ) + + # Head is still 2 + # + # 0 + # / \ + # head-> 2 1 + result.ops.add Operation( + kind: FindHead, + justified_epoch: Epoch(1), + justified_root: GenesisRoot, + finalized_epoch: Epoch(1), + justified_state_balances: balances, + expected_head: fakeHash(2) + ) + + # Move validator #0 vote from 1 to 3 + # + # 0 + # / \ + # 2 1 <- -vote + # | + # 3 <- +vote + result.ops.add Operation( + kind: ProcessAttestation, + validator_index: ValidatorIndex(0), + block_root: fakeHash(3), + target_epoch: Epoch(3) + ) + + # Head is still 2 + # + # 0 + # / \ + # head-> 2 1 + result.ops.add Operation( + kind: FindHead, + justified_epoch: Epoch(1), + justified_root: GenesisRoot, + finalized_epoch: Epoch(1), + justified_state_balances: balances, + expected_head: fakeHash(2) + ) + + # Move validator #1 vote from 2 to 1 (this is an equivocation, but fork choice doesn't + # care) + # + # 0 + # / \ + # -vote-> 2 1 <- +vote + # | + # 3 + result.ops.add Operation( + kind: ProcessAttestation, + validator_index: ValidatorIndex(1), + block_root: fakeHash(1), + target_epoch: Epoch(3) + ) + + # Head is now 3 + # + # 0 + # / \ + # 2 1 + # | + # 3 <- head + result.ops.add Operation( + kind: FindHead, + justified_epoch: Epoch(1), + justified_root: GenesisRoot, + finalized_epoch: Epoch(1), + justified_state_balances: balances, + expected_head: fakeHash(3) + ) + + # Add block 4 on chain 1-3 + # + # 0 + # / \ + # 2 1 + # | + # 3 + # | + # 4 + result.ops.add Operation( + kind: ProcessBlock, + root: fakeHash(4), + parent_root: fakeHash(3), + blk_justified_epoch: Epoch(1), + blk_finalized_epoch: Epoch(1) + ) + + # Head is now 4 + # + # 0 + # / \ + # 2 1 + # | + # 3 + # | + # 4 <- head + result.ops.add Operation( + kind: FindHead, + justified_epoch: Epoch(1), + justified_root: GenesisRoot, + finalized_epoch: Epoch(1), + justified_state_balances: balances, + expected_head: fakeHash(4) + ) + + # Add block 5, which has a justified epoch of 2. + # + # 0 + # / \ + # 2 1 + # | + # 3 + # | + # 4 + # / + # 5 <- justified epoch = 2 + result.ops.add Operation( + kind: ProcessBlock, + root: fakeHash(5), + parent_root: fakeHash(4), + blk_justified_epoch: Epoch(2), + blk_finalized_epoch: Epoch(2) + ) + + # Ensure that 5 is filtered out and the head stays at 4. + # + # 0 + # / \ + # 2 1 + # | + # 3 + # | + # 4 <- head + # / + # 5 + result.ops.add Operation( + kind: FindHead, + justified_epoch: Epoch(1), + justified_root: GenesisRoot, + finalized_epoch: Epoch(1), + justified_state_balances: balances, + expected_head: fakeHash(4) + ) + + # Add block 6, which has a justified epoch of 0. + # + # 0 + # / \ + # 2 1 + # | + # 3 + # | + # 4 + # / \ + # 5 6 <- justified epoch = 0 + result.ops.add Operation( + kind: ProcessBlock, + root: fakeHash(6), + parent_root: fakeHash(4), + blk_justified_epoch: Epoch(1), + blk_finalized_epoch: Epoch(1) + ) + + # Move both votes to 5. + # + # 0 + # / \ + # 2 1 + # | + # 3 + # | + # 4 + # / \ + # +2 vote-> 5 6 + result.ops.add Operation( + kind: ProcessAttestation, + validator_index: ValidatorIndex(0), + block_root: fakeHash(5), + target_epoch: Epoch(4) + ) + result.ops.add Operation( + kind: ProcessAttestation, + validator_index: ValidatorIndex(1), + block_root: fakeHash(5), + target_epoch: Epoch(4) + ) + + # Add blocks 7, 8 and 9. Adding these blocks helps test the `best_descendant` + # functionality. + # + # 0 + # / \ + # 2 1 + # | + # 3 + # | + # 4 + # / \ + # 5 6 + # | + # 7 + # | + # 8 + # / + # 9 + result.ops.add Operation( + kind: ProcessBlock, + root: fakeHash(7), + parent_root: fakeHash(5), + blk_justified_epoch: Epoch(2), + blk_finalized_epoch: Epoch(2) + ) + result.ops.add Operation( + kind: ProcessBlock, + root: fakeHash(8), + parent_root: fakeHash(7), + blk_justified_epoch: Epoch(2), + blk_finalized_epoch: Epoch(2) + ) + result.ops.add Operation( + kind: ProcessBlock, + root: fakeHash(9), + parent_root: fakeHash(8), + blk_justified_epoch: Epoch(2), + blk_finalized_epoch: Epoch(2) + ) + + # Ensure that 6 is the head, even though 5 has all the votes. This is testing to ensure + # that 5 is filtered out due to a differing justified epoch. + # + # 0 + # / \ + # 2 1 + # | + # 3 + # | + # 4 + # / \ + # 5 6 <- head + # | + # 7 + # | + # 8 + # / + # 9 + result.ops.add Operation( + kind: FindHead, + justified_epoch: Epoch(1), + justified_root: GenesisRoot, + finalized_epoch: Epoch(1), + justified_state_balances: balances, + expected_head: fakeHash(6) + ) + + # Change fork-choice justified epoch to 1, and the start block to 5 and ensure that 9 is + # the head. + # + # << Change justified epoch to 1 >> + # + # 0 + # / \ + # 2 1 + # | + # 3 + # | + # 4 + # / \ + # 5 6 + # | + # 7 + # | + # 8 + # / + # head-> 9 + result.ops.add Operation( + kind: FindHead, + justified_epoch: Epoch(2), + justified_root: fakeHash(5), + finalized_epoch: Epoch(2), + justified_state_balances: balances, + expected_head: fakeHash(9) + ) + + # Update votes to block 9 + # 0 + # / \ + # 2 1 + # | + # 3 + # | + # 4 + # / \ + # 5 6 + # | + # 7 + # | + # 8 + # / + # 9 <- +2 votes + result.ops.add Operation( + kind: ProcessAttestation, + validator_index: ValidatorIndex(0), + block_root: fakeHash(9), + target_epoch: Epoch(5) + ) + result.ops.add Operation( + kind: ProcessAttestation, + validator_index: ValidatorIndex(1), + block_root: fakeHash(9), + target_epoch: Epoch(5) + ) + + # Head should still be 9 + result.ops.add Operation( + kind: FindHead, + justified_epoch: Epoch(2), + justified_root: fakeHash(5), + finalized_epoch: Epoch(2), + justified_state_balances: balances, + expected_head: fakeHash(9) + ) + + # Add block 10 + # 0 + # / \ + # 2 1 + # | + # 3 + # | + # 4 + # / \ + # 5 6 + # | + # 7 + # | + # 8 + # / \ + # 9 10 + result.ops.add Operation( + kind: ProcessBlock, + root: fakeHash(10), + parent_root: fakeHash(8), + blk_justified_epoch: Epoch(2), + blk_finalized_epoch: Epoch(2) + ) + + # Head should still be 9 + result.ops.add Operation( + kind: FindHead, + justified_epoch: Epoch(2), + justified_root: fakeHash(5), + finalized_epoch: Epoch(2), + justified_state_balances: balances, + expected_head: fakeHash(9) + ) + + # Introduce 2 new validators + balances = @[Gwei(1), Gwei(1), Gwei(1), Gwei(1)] + + # Have them vote for block 10 + # 0 + # / \ + # 2 1 + # | + # 3 + # | + # 4 + # / \ + # 5 6 + # | + # 7 + # | + # 8 + # / \ + # 9 10 <- +2 votes + result.ops.add Operation( + kind: ProcessAttestation, + validator_index: ValidatorIndex(2), + block_root: fakeHash(10), + target_epoch: Epoch(5) + ) + result.ops.add Operation( + kind: ProcessAttestation, + validator_index: ValidatorIndex(3), + block_root: fakeHash(10), + target_epoch: Epoch(5) + ) + + # Check that the head is now 10. + # + # 0 + # / \ + # 2 1 + # | + # 3 + # | + # 4 + # / \ + # 5 6 + # | + # 7 + # | + # 8 + # / \ + # 9 10 <- head + result.ops.add Operation( + kind: FindHead, + justified_epoch: Epoch(2), + justified_root: fakeHash(5), + finalized_epoch: Epoch(2), + justified_state_balances: balances, + expected_head: fakeHash(10) + ) + + # Set the last 2 validators balances to 0 + balances = @[Gwei(1), Gwei(1), Gwei(0), Gwei(0)] + + # head should be 9 again + # . + # | + # 8 + # / \ + # head -> 9 10 + result.ops.add Operation( + kind: FindHead, + justified_epoch: Epoch(2), + justified_root: fakeHash(5), + finalized_epoch: Epoch(2), + justified_state_balances: balances, + expected_head: fakeHash(9) + ) + + # Set the last 2 validators balances back to 1 + balances = @[Gwei(1), Gwei(1), Gwei(1), Gwei(1)] + + # head should be 10 again + # . + # | + # 8 + # / \ + # 9 10 <- head + result.ops.add Operation( + kind: FindHead, + justified_epoch: Epoch(2), + justified_root: fakeHash(5), + finalized_epoch: Epoch(2), + justified_state_balances: balances, + expected_head: fakeHash(10) + ) + + # Remove the validators + balances = @[Gwei(1), Gwei(1)] + + # head should be 9 again + # . + # | + # 8 + # / \ + # head -> 9 10 + result.ops.add Operation( + kind: FindHead, + justified_epoch: Epoch(2), + justified_root: fakeHash(5), + finalized_epoch: Epoch(2), + justified_state_balances: balances, + expected_head: fakeHash(9) + ) + + # Pruning below the prune threshold doesn't prune + result.ops.add Operation( + kind: Prune, + finalized_root: fakeHash(5), + prune_threshold: high(int), + expected_len: 11 + ) + + # Prune shouldn't have changed the head + result.ops.add Operation( + kind: FindHead, + justified_epoch: Epoch(2), + justified_root: fakeHash(5), + finalized_epoch: Epoch(2), + justified_state_balances: balances, + expected_head: fakeHash(9) + ) + + # Ensure that pruning above the prune threshold does prune. + # + # + # 0 + # / \ + # 2 1 + # | + # 3 + # | + # 4 + # -------pruned here ------ + # 5 6 + # | + # 7 + # | + # 8 + # / \ + # 9 10 + result.ops.add Operation( + kind: Prune, + finalized_root: fakeHash(5), + prune_threshold: 1, + expected_len: 6 + ) + + # Prune shouldn't have changed the head + result.ops.add Operation( + kind: FindHead, + justified_epoch: Epoch(2), + justified_root: fakeHash(5), + finalized_epoch: Epoch(2), + justified_state_balances: balances, + expected_head: fakeHash(9) + ) + + # Add block 11 + # + # 5 6 + # | + # 7 + # | + # 8 + # / \ + # 9 10 + # | + # 11 + result.ops.add Operation( + kind: ProcessBlock, + root: fakeHash(11), + parent_root: fakeHash(9), + blk_justified_epoch: Epoch(2), + blk_finalized_epoch: Epoch(2) + ) + + # Head is now 11 + result.ops.add Operation( + kind: FindHead, + justified_epoch: Epoch(2), + justified_root: fakeHash(5), + finalized_epoch: Epoch(2), + justified_state_balances: balances, + expected_head: fakeHash(11) + ) + +proc test_votes() = + timedTest "fork_choice - testing with votes": + # for i in 0 ..< 12: + # echo " block (", i, ") hash: ", fakeHash(i) + # echo " ------------------------------------------------------" + + var (ctx, ops) = setup_votes() + ctx.run(ops) + +test_votes() diff --git a/tests/fork_choice/tests_fork_choice.nim b/tests/fork_choice/tests_fork_choice.nim new file mode 100644 index 000000000..e9b15a41e --- /dev/null +++ b/tests/fork_choice/tests_fork_choice.nim @@ -0,0 +1,10 @@ +# Don't forgot to run the following files as main modules: +# - beacon_chain/fork_choice/proto_array.nim (sanity checks for tiebreak) +# - beacon_chain/fork_choice/fork_choice.nim (sanity checks for compute_deltas) + +import ../testutil, std/unittest + +# include to be able to use "suiteReport" +import ./interpreter +suiteReport "Fork Choice + Finality " & preset(): + include scenarios/[no_votes, votes, ffg_01, ffg_02]