2020-04-09 16:15:00 +00:00
|
|
|
|
# beacon_chain
|
2022-02-04 11:59:40 +00:00
|
|
|
|
# Copyright (c) 2018-2022 Status Research & Development GmbH
|
2020-04-09 16:15:00 +00:00
|
|
|
|
# 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.
|
|
|
|
|
|
2022-07-29 10:53:42 +00:00
|
|
|
|
when (NimMajor, NimMinor) < (1, 4):
|
|
|
|
|
{.push raises: [Defect].}
|
|
|
|
|
else:
|
|
|
|
|
{.push raises: [].}
|
2020-04-15 09:21:22 +00:00
|
|
|
|
|
2020-04-09 16:15:00 +00:00
|
|
|
|
import
|
|
|
|
|
# Standard library
|
2022-08-31 11:29:34 +00:00
|
|
|
|
std/[tables, typetraits],
|
2020-07-09 09:29:32 +00:00
|
|
|
|
# Status libraries
|
|
|
|
|
chronicles,
|
2022-02-14 05:26:19 +00:00
|
|
|
|
stew/results,
|
2020-04-09 16:15:00 +00:00
|
|
|
|
# Internal
|
2021-06-21 08:35:24 +00:00
|
|
|
|
../spec/datatypes/base,
|
2022-07-10 15:26:29 +00:00
|
|
|
|
../spec/helpers,
|
2020-04-09 16:15:00 +00:00
|
|
|
|
# Fork choice
|
|
|
|
|
./fork_choice_types
|
|
|
|
|
|
2020-07-09 09:29:32 +00:00
|
|
|
|
logScope:
|
|
|
|
|
topics = "fork_choice"
|
|
|
|
|
|
2020-07-30 15:48:25 +00:00
|
|
|
|
export results
|
|
|
|
|
|
2021-08-20 23:37:45 +00:00
|
|
|
|
# https://github.com/ethereum/consensus-specs/blob/v0.11.1/specs/phase0/fork-choice.md
|
2020-04-09 16:15:00 +00:00
|
|
|
|
# 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
|
|
|
|
|
|
2020-08-26 15:23:34 +00:00
|
|
|
|
# Helpers
|
2020-04-09 16:15:00 +00:00
|
|
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
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 unsafeGet*[K, V](table: Table[K, V], key: K): V =
|
|
|
|
|
## Get a value from a Nim Table, turning KeyError into
|
|
|
|
|
## an AssertionError defect
|
2020-08-18 14:56:32 +00:00
|
|
|
|
# Pointer is used to work around the lack of a `var` withValue
|
|
|
|
|
try:
|
|
|
|
|
table[key]
|
|
|
|
|
except KeyError as exc:
|
|
|
|
|
raiseAssert(exc.msg)
|
2020-04-09 16:15:00 +00:00
|
|
|
|
|
2020-11-20 10:00:22 +00:00
|
|
|
|
func `[]`(nodes: ProtoNodes, idx: Index): Option[ProtoNode] =
|
2020-08-26 15:23:34 +00:00
|
|
|
|
## Retrieve a ProtoNode at "Index"
|
|
|
|
|
if idx < nodes.offset:
|
|
|
|
|
return none(ProtoNode)
|
|
|
|
|
let i = idx - nodes.offset
|
|
|
|
|
if i >= nodes.buf.len:
|
|
|
|
|
return none(ProtoNode)
|
2022-02-21 11:55:56 +00:00
|
|
|
|
some(nodes.buf[i])
|
2020-08-26 15:23:34 +00:00
|
|
|
|
|
2020-11-20 10:00:22 +00:00
|
|
|
|
func len*(nodes: ProtoNodes): int =
|
2020-08-26 15:23:34 +00:00
|
|
|
|
nodes.buf.len
|
|
|
|
|
|
2020-11-20 10:00:22 +00:00
|
|
|
|
func add(nodes: var ProtoNodes, node: ProtoNode) =
|
2020-08-26 15:23:34 +00:00
|
|
|
|
nodes.buf.add node
|
|
|
|
|
|
2022-08-29 07:26:01 +00:00
|
|
|
|
func isPreviousEpochJustified(self: ProtoArray): bool =
|
|
|
|
|
self.checkpoints.justified.epoch + 1 == self.currentEpoch
|
|
|
|
|
|
2020-04-09 16:15:00 +00:00
|
|
|
|
# Forward declarations
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
2021-02-16 18:53:07 +00:00
|
|
|
|
func maybeUpdateBestChildAndDescendant(self: var ProtoArray,
|
|
|
|
|
parentIdx: Index,
|
|
|
|
|
childIdx: Index): FcResult[void]
|
|
|
|
|
|
|
|
|
|
func nodeIsViableForHead(self: ProtoArray, node: ProtoNode): bool
|
|
|
|
|
func nodeLeadsToViableHead(self: ProtoArray, node: ProtoNode): FcResult[bool]
|
2020-04-09 16:15:00 +00:00
|
|
|
|
|
|
|
|
|
# ProtoArray routines
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
2022-08-29 07:26:01 +00:00
|
|
|
|
func init*(
|
|
|
|
|
T: type ProtoArray, checkpoints: FinalityCheckpoints,
|
|
|
|
|
hasLowParticipation: bool): T =
|
2020-08-18 14:56:32 +00:00
|
|
|
|
let node = ProtoNode(
|
2022-07-06 10:33:02 +00:00
|
|
|
|
root: checkpoints.finalized.root,
|
2020-08-18 14:56:32 +00:00
|
|
|
|
parent: none(int),
|
2022-07-06 10:33:02 +00:00
|
|
|
|
checkpoints: checkpoints,
|
2020-08-18 14:56:32 +00:00
|
|
|
|
weight: 0,
|
2022-09-06 16:58:54 +00:00
|
|
|
|
invalid: false,
|
2021-02-16 18:53:07 +00:00
|
|
|
|
bestChild: none(int),
|
2022-07-06 10:33:02 +00:00
|
|
|
|
bestDescendant: none(int))
|
2020-08-18 14:56:32 +00:00
|
|
|
|
|
2022-08-29 07:26:01 +00:00
|
|
|
|
T(hasLowParticipation: hasLowParticipation,
|
|
|
|
|
checkpoints: checkpoints,
|
2020-08-26 15:23:34 +00:00
|
|
|
|
nodes: ProtoNodes(buf: @[node], offset: 0),
|
2022-07-06 10:33:02 +00:00
|
|
|
|
indices: {node.root: 0}.toTable())
|
2020-08-18 14:56:32 +00:00
|
|
|
|
|
2022-08-29 07:26:01 +00:00
|
|
|
|
iterator realizePendingCheckpoints*(
|
|
|
|
|
self: var ProtoArray, resetTipTracking = true): FinalityCheckpoints =
|
|
|
|
|
# Pull-up chain tips from previous epoch
|
|
|
|
|
for idx, unrealized in self.currentEpochTips.pairs():
|
|
|
|
|
let physicalIdx = idx - self.nodes.offset
|
|
|
|
|
if unrealized != self.nodes.buf[physicalIdx].checkpoints:
|
|
|
|
|
trace "Pulling up chain tip",
|
|
|
|
|
blck = self.nodes.buf[physicalIdx].root,
|
|
|
|
|
checkpoints = self.nodes.buf[physicalIdx].checkpoints,
|
|
|
|
|
unrealized
|
|
|
|
|
self.nodes.buf[physicalIdx].checkpoints = unrealized
|
|
|
|
|
|
|
|
|
|
yield unrealized
|
|
|
|
|
|
|
|
|
|
# Reset tip tracking for new epoch
|
|
|
|
|
if resetTipTracking:
|
|
|
|
|
self.currentEpochTips.clear()
|
|
|
|
|
|
2022-03-02 10:00:21 +00:00
|
|
|
|
# https://github.com/ethereum/consensus-specs/blob/v1.1.10/specs/phase0/fork-choice.md#get_latest_attesting_balance
|
2022-09-19 09:07:46 +00:00
|
|
|
|
func calculateProposerBoost(validatorBalances: openArray[Gwei]): uint64 =
|
2022-02-04 11:59:40 +00:00
|
|
|
|
var
|
|
|
|
|
total_balance: uint64
|
2022-09-19 09:07:46 +00:00
|
|
|
|
num_validators: uint64
|
2022-02-04 11:59:40 +00:00
|
|
|
|
for balance in validatorBalances:
|
|
|
|
|
# We need to filter zero balances here to get an accurate active validator
|
|
|
|
|
# count. This is because we default inactive validator balances to zero
|
|
|
|
|
# when creating this balances array.
|
|
|
|
|
if balance != 0:
|
|
|
|
|
total_balance += balance
|
|
|
|
|
num_validators += 1
|
|
|
|
|
if num_validators == 0:
|
|
|
|
|
return 0
|
|
|
|
|
let
|
2022-09-19 09:07:46 +00:00
|
|
|
|
average_balance = total_balance div num_validators.uint64
|
|
|
|
|
committee_size = num_validators div SLOTS_PER_EPOCH
|
2022-02-04 11:59:40 +00:00
|
|
|
|
committee_weight = committee_size * average_balance
|
|
|
|
|
(committee_weight * PROPOSER_SCORE_BOOST) div 100
|
|
|
|
|
|
2021-02-16 18:53:07 +00:00
|
|
|
|
func applyScoreChanges*(self: var ProtoArray,
|
|
|
|
|
deltas: var openArray[Delta],
|
2022-08-29 07:26:01 +00:00
|
|
|
|
currentEpoch: Epoch,
|
2022-07-06 10:33:02 +00:00
|
|
|
|
checkpoints: FinalityCheckpoints,
|
2022-02-04 11:59:40 +00:00
|
|
|
|
newBalances: openArray[Gwei],
|
2022-04-12 10:06:30 +00:00
|
|
|
|
proposerBoostRoot: Eth2Digest): FcResult[void] =
|
2020-04-09 16:15:00 +00:00
|
|
|
|
## Iterate backwards through the array, touching all nodes and their parents
|
|
|
|
|
## and potentially the best-child of each parent.
|
2022-02-04 11:59:40 +00:00
|
|
|
|
#
|
|
|
|
|
# The structure of `self.nodes` array ensures that the child of each node
|
|
|
|
|
# is always touched before its parent.
|
|
|
|
|
#
|
|
|
|
|
# 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
|
2020-08-26 15:23:34 +00:00
|
|
|
|
doAssert self.indices.len == self.nodes.len # By construction
|
2020-04-09 16:15:00 +00:00
|
|
|
|
if deltas.len != self.indices.len:
|
2020-07-30 15:48:25 +00:00
|
|
|
|
return err ForkChoiceError(
|
2021-02-16 18:53:07 +00:00
|
|
|
|
kind: fcInvalidDeltaLen,
|
|
|
|
|
deltasLen: deltas.len,
|
|
|
|
|
indicesLen: self.indices.len)
|
2020-04-09 16:15:00 +00:00
|
|
|
|
|
2022-08-29 07:26:01 +00:00
|
|
|
|
self.currentEpoch = currentEpoch
|
2022-07-06 10:33:02 +00:00
|
|
|
|
self.checkpoints = checkpoints
|
2020-04-09 16:15:00 +00:00
|
|
|
|
|
2022-08-29 07:26:01 +00:00
|
|
|
|
# If previous epoch is justified, pull up all current tips to previous epoch
|
|
|
|
|
if self.hasLowParticipation and self.isPreviousEpochJustified:
|
|
|
|
|
for realized in self.realizePendingCheckpoints(resetTipTracking = false):
|
|
|
|
|
discard
|
|
|
|
|
|
2021-02-16 18:53:07 +00:00
|
|
|
|
## Alias
|
|
|
|
|
# This cannot raise the IndexError exception, how to tell compiler?
|
|
|
|
|
template node: untyped {.dirty.} =
|
|
|
|
|
self.nodes.buf[nodePhysicalIdx]
|
2020-04-09 16:15:00 +00:00
|
|
|
|
|
2022-02-04 11:59:40 +00:00
|
|
|
|
# Default value, if not otherwise set in first node loop
|
2022-09-19 09:07:46 +00:00
|
|
|
|
var proposerBoostScore: uint64
|
2022-02-04 11:59:40 +00:00
|
|
|
|
|
2021-02-16 18:53:07 +00:00
|
|
|
|
# Iterate backwards through all the indices in `self.nodes`
|
|
|
|
|
for nodePhysicalIdx in countdown(self.nodes.len - 1, 0):
|
2022-02-14 05:26:19 +00:00
|
|
|
|
if node.root.isZero:
|
2020-04-09 16:15:00 +00:00
|
|
|
|
continue
|
|
|
|
|
|
2022-02-04 11:59:40 +00:00
|
|
|
|
var nodeDelta = deltas[nodePhysicalIdx]
|
|
|
|
|
|
|
|
|
|
# If we find the node for which the proposer boost was previously applied,
|
|
|
|
|
# decrease the delta by the previous score amount.
|
2022-04-12 10:06:30 +00:00
|
|
|
|
if (not self.previousProposerBoostRoot.isZero) and
|
2022-02-04 11:59:40 +00:00
|
|
|
|
self.previousProposerBoostRoot == node.root:
|
|
|
|
|
if nodeDelta < 0 and
|
2022-09-19 09:07:46 +00:00
|
|
|
|
nodeDelta - low(Delta) < self.previousProposerBoostScore.int64:
|
2022-02-04 11:59:40 +00:00
|
|
|
|
return err ForkChoiceError(
|
|
|
|
|
kind: fcDeltaUnderflow,
|
|
|
|
|
index: nodePhysicalIdx)
|
2022-09-19 09:07:46 +00:00
|
|
|
|
nodeDelta -= self.previousProposerBoostScore.int64
|
2022-02-04 11:59:40 +00:00
|
|
|
|
|
|
|
|
|
# If we find the node matching the current proposer boost root, increase
|
|
|
|
|
# the delta by the new score amount.
|
|
|
|
|
#
|
2022-03-02 10:00:21 +00:00
|
|
|
|
# https://github.com/ethereum/consensus-specs/blob/v1.1.10/specs/phase0/fork-choice.md#get_latest_attesting_balance
|
2022-04-12 10:06:30 +00:00
|
|
|
|
if (not proposerBoostRoot.isZero) and proposerBoostRoot == node.root:
|
2022-02-04 11:59:40 +00:00
|
|
|
|
proposerBoostScore = calculateProposerBoost(newBalances)
|
|
|
|
|
if nodeDelta >= 0 and
|
2022-09-19 09:07:46 +00:00
|
|
|
|
high(Delta) - nodeDelta < proposerBoostScore.int64:
|
2022-02-04 11:59:40 +00:00
|
|
|
|
return err ForkChoiceError(
|
|
|
|
|
kind: fcDeltaOverflow,
|
|
|
|
|
index: nodePhysicalIdx)
|
|
|
|
|
nodeDelta += proposerBoostScore.int64
|
2020-04-09 16:15:00 +00:00
|
|
|
|
|
|
|
|
|
# Apply the delta to the node
|
|
|
|
|
# We fail fast if underflow, which shouldn't happen.
|
|
|
|
|
# Note that delta can be negative but weight cannot
|
2021-02-16 18:53:07 +00:00
|
|
|
|
let weight = node.weight + nodeDelta
|
2020-04-09 16:15:00 +00:00
|
|
|
|
if weight < 0:
|
2020-07-30 15:48:25 +00:00
|
|
|
|
return err ForkChoiceError(
|
|
|
|
|
kind: fcDeltaUnderflow,
|
2021-02-16 18:53:07 +00:00
|
|
|
|
index: nodePhysicalIdx)
|
2020-04-09 16:15:00 +00:00
|
|
|
|
node.weight = weight
|
|
|
|
|
|
2022-02-04 11:59:40 +00:00
|
|
|
|
# If the node has a parent, try to update its best-child and best-descendent
|
2020-04-09 16:15:00 +00:00
|
|
|
|
if node.parent.isSome():
|
2021-02-16 18:53:07 +00:00
|
|
|
|
let parentLogicalIdx = node.parent.unsafeGet()
|
|
|
|
|
let parentPhysicalIdx = parentLogicalIdx - self.nodes.offset
|
|
|
|
|
if parentPhysicalIdx < 0:
|
2020-08-26 15:23:34 +00:00
|
|
|
|
# Orphan, for example
|
|
|
|
|
# 0
|
|
|
|
|
# / \
|
|
|
|
|
# 2 1
|
|
|
|
|
# |
|
|
|
|
|
# 3
|
|
|
|
|
# |
|
|
|
|
|
# 4
|
|
|
|
|
# -------pruned here ------
|
|
|
|
|
# 5 6
|
|
|
|
|
# |
|
|
|
|
|
# 7
|
|
|
|
|
# |
|
|
|
|
|
# 8
|
|
|
|
|
# / \
|
|
|
|
|
# 9 10
|
|
|
|
|
#
|
|
|
|
|
# with 5 the canonical chain and 6 a discarded fork
|
|
|
|
|
# that will be pruned next.
|
2020-11-19 14:11:08 +00:00
|
|
|
|
continue
|
2020-08-26 15:23:34 +00:00
|
|
|
|
|
2021-02-16 18:53:07 +00:00
|
|
|
|
if parentPhysicalIdx >= deltas.len:
|
2020-07-30 15:48:25 +00:00
|
|
|
|
return err ForkChoiceError(
|
|
|
|
|
kind: fcInvalidParentDelta,
|
2021-02-16 18:53:07 +00:00
|
|
|
|
index: parentPhysicalIdx)
|
2020-04-09 16:15:00 +00:00
|
|
|
|
|
|
|
|
|
# Back-propagate the nodes delta to its parent.
|
2021-02-16 18:53:07 +00:00
|
|
|
|
deltas[parentPhysicalIdx] += nodeDelta
|
|
|
|
|
|
2022-02-04 11:59:40 +00:00
|
|
|
|
# After applying all deltas, update the `previous_proposer_boost`.
|
2022-04-12 10:06:30 +00:00
|
|
|
|
self.previousProposerBoostRoot = proposerBoostRoot
|
|
|
|
|
self.previousProposerBoostScore = proposerBoostScore
|
2022-02-04 11:59:40 +00:00
|
|
|
|
|
2021-02-16 18:53:07 +00:00
|
|
|
|
for nodePhysicalIdx in countdown(self.nodes.len - 1, 0):
|
2022-02-14 05:26:19 +00:00
|
|
|
|
if node.root.isZero:
|
2021-02-16 18:53:07 +00:00
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
if node.parent.isSome():
|
|
|
|
|
let parentLogicalIdx = node.parent.unsafeGet()
|
|
|
|
|
let parentPhysicalIdx = parentLogicalIdx - self.nodes.offset
|
|
|
|
|
if parentPhysicalIdx < 0:
|
|
|
|
|
# Orphan
|
|
|
|
|
continue
|
2020-04-09 16:15:00 +00:00
|
|
|
|
|
2021-02-16 18:53:07 +00:00
|
|
|
|
let nodeLogicalIdx = nodePhysicalIdx + self.nodes.offset
|
|
|
|
|
? self.maybeUpdateBestChildAndDescendant(parentLogicalIdx, nodeLogicalIdx)
|
2020-04-09 16:15:00 +00:00
|
|
|
|
|
2021-02-16 18:53:07 +00:00
|
|
|
|
ok()
|
2020-04-09 16:15:00 +00:00
|
|
|
|
|
2021-02-16 18:53:07 +00:00
|
|
|
|
func onBlock*(self: var ProtoArray,
|
|
|
|
|
root: Eth2Digest,
|
|
|
|
|
parent: Eth2Digest,
|
2022-07-06 10:33:02 +00:00
|
|
|
|
checkpoints: FinalityCheckpoints,
|
|
|
|
|
unrealized = none(FinalityCheckpoints)): FcResult[void] =
|
2020-04-09 16:15:00 +00:00
|
|
|
|
## Register a block with the fork choice
|
2020-06-10 06:58:12 +00:00
|
|
|
|
## A block `hasParentInForkChoice` may be false
|
|
|
|
|
## on fork choice initialization:
|
|
|
|
|
## - either from Genesis
|
|
|
|
|
## - or from a finalized state loaded from database
|
|
|
|
|
|
|
|
|
|
# Note: if parent is an "Option" type, we can run out of stack space.
|
2020-04-09 16:15:00 +00:00
|
|
|
|
|
|
|
|
|
# If the block is already known, ignore it
|
|
|
|
|
if root in self.indices:
|
2020-07-30 15:48:25 +00:00
|
|
|
|
return ok()
|
2020-04-09 16:15:00 +00:00
|
|
|
|
|
2021-02-16 18:53:07 +00:00
|
|
|
|
var parentIdx: Index
|
2020-08-18 14:56:32 +00:00
|
|
|
|
self.indices.withValue(parent, index) do:
|
2021-02-16 18:53:07 +00:00
|
|
|
|
parentIdx = index[]
|
2020-08-18 14:56:32 +00:00
|
|
|
|
do:
|
2020-07-30 15:48:25 +00:00
|
|
|
|
return err ForkChoiceError(
|
|
|
|
|
kind: fcUnknownParent,
|
2021-02-16 18:53:07 +00:00
|
|
|
|
childRoot: root,
|
|
|
|
|
parentRoot: parent)
|
2020-04-09 16:15:00 +00:00
|
|
|
|
|
2021-02-16 18:53:07 +00:00
|
|
|
|
let nodeLogicalIdx = self.nodes.offset + self.nodes.buf.len
|
2020-04-09 16:15:00 +00:00
|
|
|
|
|
|
|
|
|
let node = ProtoNode(
|
|
|
|
|
root: root,
|
2021-02-16 18:53:07 +00:00
|
|
|
|
parent: some(parentIdx),
|
2022-07-06 10:33:02 +00:00
|
|
|
|
checkpoints: checkpoints,
|
2020-04-09 16:15:00 +00:00
|
|
|
|
weight: 0,
|
2022-09-06 16:58:54 +00:00
|
|
|
|
invalid: false,
|
2021-02-16 18:53:07 +00:00
|
|
|
|
bestChild: none(int),
|
2022-07-06 10:33:02 +00:00
|
|
|
|
bestDescendant: none(int))
|
2020-04-09 16:15:00 +00:00
|
|
|
|
|
2021-02-16 18:53:07 +00:00
|
|
|
|
self.indices[node.root] = nodeLogicalIdx
|
2020-08-18 14:56:32 +00:00
|
|
|
|
self.nodes.add node
|
2020-04-09 16:15:00 +00:00
|
|
|
|
|
2022-07-06 10:33:02 +00:00
|
|
|
|
if unrealized.isSome:
|
|
|
|
|
self.currentEpochTips.del parentIdx
|
|
|
|
|
self.currentEpochTips[nodeLogicalIdx] = unrealized.get
|
|
|
|
|
|
2021-02-16 18:53:07 +00:00
|
|
|
|
? self.maybeUpdateBestChildAndDescendant(parentIdx, nodeLogicalIdx)
|
2020-04-09 16:15:00 +00:00
|
|
|
|
|
2021-02-16 18:53:07 +00:00
|
|
|
|
ok()
|
2020-04-09 16:15:00 +00:00
|
|
|
|
|
2021-02-16 18:53:07 +00:00
|
|
|
|
func findHead*(self: var ProtoArray,
|
|
|
|
|
head: var Eth2Digest,
|
|
|
|
|
justifiedRoot: Eth2Digest): FcResult[void] =
|
2020-04-09 16:15:00 +00:00
|
|
|
|
## Follows the best-descendant links to find the best-block (i.e. head-block)
|
|
|
|
|
##
|
2021-08-20 23:37:45 +00:00
|
|
|
|
## ️ Warning
|
2021-02-16 18:53:07 +00:00
|
|
|
|
## The result may not be accurate if `onBlock` is not followed by
|
|
|
|
|
## `applyScoreChanges` as `onBlock` does not update the whole tree.
|
2020-04-09 16:15:00 +00:00
|
|
|
|
|
2021-02-16 18:53:07 +00:00
|
|
|
|
var justifiedIdx: Index
|
|
|
|
|
self.indices.withValue(justifiedRoot, value) do:
|
|
|
|
|
justifiedIdx = value[]
|
2020-08-18 14:56:32 +00:00
|
|
|
|
do:
|
2020-07-30 15:48:25 +00:00
|
|
|
|
return err ForkChoiceError(
|
|
|
|
|
kind: fcJustifiedNodeUnknown,
|
2021-02-16 18:53:07 +00:00
|
|
|
|
blockRoot: justifiedRoot)
|
2020-04-09 16:15:00 +00:00
|
|
|
|
|
2021-02-16 18:53:07 +00:00
|
|
|
|
let justifiedNode = self.nodes[justifiedIdx]
|
|
|
|
|
if justifiedNode.isNone():
|
2020-07-30 15:48:25 +00:00
|
|
|
|
return err ForkChoiceError(
|
|
|
|
|
kind: fcInvalidJustifiedIndex,
|
2021-02-16 18:53:07 +00:00
|
|
|
|
index: justifiedIdx)
|
2020-04-09 16:15:00 +00:00
|
|
|
|
|
2021-02-16 18:53:07 +00:00
|
|
|
|
let bestDescendantIdx = justifiedNode.get().bestDescendant.get(justifiedIdx)
|
|
|
|
|
let bestNode = self.nodes[bestDescendantIdx]
|
|
|
|
|
if bestNode.isNone():
|
2020-07-30 15:48:25 +00:00
|
|
|
|
return err ForkChoiceError(
|
|
|
|
|
kind: fcInvalidBestDescendant,
|
2021-02-16 18:53:07 +00:00
|
|
|
|
index: bestDescendantIdx)
|
2020-04-09 16:15:00 +00:00
|
|
|
|
|
|
|
|
|
# Perform a sanity check to ensure the node can be head
|
2021-02-16 18:53:07 +00:00
|
|
|
|
if not self.nodeIsViableForHead(bestNode.get()):
|
2020-07-30 15:48:25 +00:00
|
|
|
|
return err ForkChoiceError(
|
|
|
|
|
kind: fcInvalidBestNode,
|
2021-02-16 18:53:07 +00:00
|
|
|
|
startRoot: justifiedRoot,
|
2022-07-06 10:33:02 +00:00
|
|
|
|
fkChoiceCheckpoints: self.checkpoints,
|
2021-02-16 18:53:07 +00:00
|
|
|
|
headRoot: justifiedNode.get().root,
|
2022-07-06 10:33:02 +00:00
|
|
|
|
headCheckpoints: justifiedNode.get().checkpoints)
|
2021-02-16 18:53:07 +00:00
|
|
|
|
|
|
|
|
|
head = bestNode.get().root
|
|
|
|
|
ok()
|
|
|
|
|
|
|
|
|
|
func prune*(self: var ProtoArray, finalizedRoot: Eth2Digest): FcResult[void] =
|
2020-04-09 16:15:00 +00:00
|
|
|
|
## Update the tree with new finalization information.
|
|
|
|
|
## The tree is pruned if and only if:
|
2021-02-16 18:53:07 +00:00
|
|
|
|
## - The `finalizedRoot` and finalized epoch are different from current
|
2020-04-09 16:15:00 +00:00
|
|
|
|
##
|
|
|
|
|
## 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`
|
2020-08-18 14:56:32 +00:00
|
|
|
|
|
2021-02-16 18:53:07 +00:00
|
|
|
|
var finalizedIdx: int
|
|
|
|
|
self.indices.withValue(finalizedRoot, value) do:
|
|
|
|
|
finalizedIdx = value[]
|
2020-08-18 14:56:32 +00:00
|
|
|
|
do:
|
2020-07-30 15:48:25 +00:00
|
|
|
|
return err ForkChoiceError(
|
|
|
|
|
kind: fcFinalizedNodeUnknown,
|
2021-02-16 18:53:07 +00:00
|
|
|
|
blockRoot: finalizedRoot)
|
2020-04-09 16:15:00 +00:00
|
|
|
|
|
2021-02-16 18:53:07 +00:00
|
|
|
|
if finalizedIdx == self.nodes.offset:
|
2020-08-26 15:23:34 +00:00
|
|
|
|
# Nothing to do
|
2020-07-30 15:48:25 +00:00
|
|
|
|
return ok()
|
2020-04-09 16:15:00 +00:00
|
|
|
|
|
2021-02-16 18:53:07 +00:00
|
|
|
|
if finalizedIdx < self.nodes.offset:
|
2020-07-30 15:48:25 +00:00
|
|
|
|
return err ForkChoiceError(
|
2020-08-26 15:23:34 +00:00
|
|
|
|
kind: fcPruningFromOutdatedFinalizedRoot,
|
2021-02-16 18:53:07 +00:00
|
|
|
|
finalizedRoot: finalizedRoot)
|
2020-07-09 09:29:32 +00:00
|
|
|
|
|
2020-07-30 15:48:25 +00:00
|
|
|
|
trace "Pruning blocks from fork choice",
|
2022-04-14 10:47:14 +00:00
|
|
|
|
finalizedRoot = shortLog(finalizedRoot)
|
2020-07-09 09:29:32 +00:00
|
|
|
|
|
2021-02-16 18:53:07 +00:00
|
|
|
|
let finalPhysicalIdx = finalizedIdx - self.nodes.offset
|
|
|
|
|
for nodeIdx in 0 ..< finalPhysicalIdx:
|
2022-07-06 10:33:02 +00:00
|
|
|
|
self.currentEpochTips.del nodeIdx
|
2021-02-16 18:53:07 +00:00
|
|
|
|
self.indices.del(self.nodes.buf[nodeIdx].root)
|
2020-04-09 16:15:00 +00:00
|
|
|
|
|
|
|
|
|
# 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"
|
2021-02-16 18:53:07 +00:00
|
|
|
|
let tail = self.nodes.len - finalPhysicalIdx
|
2020-04-09 16:15:00 +00:00
|
|
|
|
# TODO: can we have an unallocated `self.nodes`? i.e. self.nodes[0] is nil
|
2021-02-16 18:53:07 +00:00
|
|
|
|
moveMem(self.nodes.buf[0].addr, self.nodes.buf[finalPhysicalIdx].addr, tail * sizeof(ProtoNode))
|
2020-08-26 15:23:34 +00:00
|
|
|
|
self.nodes.buf.setLen(tail)
|
2020-04-09 16:15:00 +00:00
|
|
|
|
|
2020-08-26 15:23:34 +00:00
|
|
|
|
# update offset
|
2021-02-16 18:53:07 +00:00
|
|
|
|
self.nodes.offset = finalizedIdx
|
2020-04-09 16:15:00 +00:00
|
|
|
|
|
2021-02-16 18:53:07 +00:00
|
|
|
|
ok()
|
2020-04-09 16:15:00 +00:00
|
|
|
|
|
2021-02-16 18:53:07 +00:00
|
|
|
|
func maybeUpdateBestChildAndDescendant(self: var ProtoArray,
|
|
|
|
|
parentIdx: Index,
|
|
|
|
|
childIdx: Index): FcResult[void] =
|
|
|
|
|
## Observe the parent at `parentIdx` with respect to the child at `childIdx` and
|
|
|
|
|
## potentially modify the `parent.bestChild` and `parent.bestDescendant` values
|
2020-04-09 16:15:00 +00:00
|
|
|
|
##
|
|
|
|
|
## 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
|
|
|
|
|
|
2021-02-16 18:53:07 +00:00
|
|
|
|
let child = self.nodes[childIdx]
|
2020-08-26 15:23:34 +00:00
|
|
|
|
if child.isNone():
|
2020-07-30 15:48:25 +00:00
|
|
|
|
return err ForkChoiceError(
|
|
|
|
|
kind: fcInvalidNodeIndex,
|
2021-02-16 18:53:07 +00:00
|
|
|
|
index: childIdx)
|
2020-08-26 15:23:34 +00:00
|
|
|
|
|
2021-02-16 18:53:07 +00:00
|
|
|
|
let parent = self.nodes[parentIdx]
|
2020-08-26 15:23:34 +00:00
|
|
|
|
if parent.isNone():
|
2020-07-30 15:48:25 +00:00
|
|
|
|
return err ForkChoiceError(
|
|
|
|
|
kind: fcInvalidNodeIndex,
|
2021-02-16 18:53:07 +00:00
|
|
|
|
index: parentIdx)
|
2020-04-09 16:15:00 +00:00
|
|
|
|
|
2021-02-16 18:53:07 +00:00
|
|
|
|
let childLeadsToViableHead = ? self.nodeLeadsToViableHead(child.get())
|
2020-04-09 16:15:00 +00:00
|
|
|
|
|
2021-02-16 18:53:07 +00:00
|
|
|
|
let # Aliases to the 3 possible (bestChild, bestDescendant) tuples
|
|
|
|
|
changeToNone = (none(Index), none(Index))
|
|
|
|
|
changeToChild = (
|
|
|
|
|
some(childIdx),
|
2020-04-09 16:15:00 +00:00
|
|
|
|
# Nim `options` module doesn't implement option `or`
|
2021-02-16 18:53:07 +00:00
|
|
|
|
if child.get().bestDescendant.isSome(): child.get().bestDescendant
|
|
|
|
|
else: some(childIdx)
|
2020-04-09 16:15:00 +00:00
|
|
|
|
)
|
2021-02-16 18:53:07 +00:00
|
|
|
|
noChange = (parent.get().bestChild, parent.get().bestDescendant)
|
2020-04-09 16:15:00 +00:00
|
|
|
|
|
|
|
|
|
# TODO: state-machine? The control-flow is messy
|
2021-02-16 18:53:07 +00:00
|
|
|
|
let (newBestChild, newBestDescendant) = block:
|
|
|
|
|
if parent.get().bestChild.isSome:
|
|
|
|
|
let bestChildIdx = parent.get().bestChild.unsafeGet()
|
|
|
|
|
if bestChildIdx == childIdx and not childLeadsToViableHead:
|
2020-04-09 16:15:00 +00:00
|
|
|
|
# The child is already the best-child of the parent
|
|
|
|
|
# but it's not viable to be the head block => remove it
|
2021-02-16 18:53:07 +00:00
|
|
|
|
changeToNone
|
|
|
|
|
elif bestChildIdx == childIdx:
|
2020-04-09 16:15:00 +00:00
|
|
|
|
# If the child is the best-child already, set it again to ensure
|
|
|
|
|
# that the best-descendant of the parent is up-to-date.
|
2021-02-16 18:53:07 +00:00
|
|
|
|
changeToChild
|
2020-04-09 16:15:00 +00:00
|
|
|
|
else:
|
2021-02-16 18:53:07 +00:00
|
|
|
|
let bestChild = self.nodes[bestChildIdx]
|
|
|
|
|
if bestChild.isNone():
|
2020-07-30 15:48:25 +00:00
|
|
|
|
return err ForkChoiceError(
|
|
|
|
|
kind: fcInvalidBestDescendant,
|
2021-02-16 18:53:07 +00:00
|
|
|
|
index: bestChildIdx)
|
2020-04-09 16:15:00 +00:00
|
|
|
|
|
2021-02-16 18:53:07 +00:00
|
|
|
|
let bestChildLeadsToViableHead =
|
|
|
|
|
? self.nodeLeadsToViableHead(bestChild.get())
|
2020-04-09 16:15:00 +00:00
|
|
|
|
|
2021-02-16 18:53:07 +00:00
|
|
|
|
if childLeadsToViableHead and not bestChildLeadsToViableHead:
|
2020-04-09 16:15:00 +00:00
|
|
|
|
# The child leads to a viable head, but the current best-child doesn't
|
2021-02-16 18:53:07 +00:00
|
|
|
|
changeToChild
|
|
|
|
|
elif not childLeadsToViableHead and bestChildLeadsToViableHead:
|
2020-04-09 16:15:00 +00:00
|
|
|
|
# The best child leads to a viable head, but the child doesn't
|
2021-02-16 18:53:07 +00:00
|
|
|
|
noChange
|
|
|
|
|
elif child.get().weight == bestChild.get().weight:
|
2020-04-09 16:15:00 +00:00
|
|
|
|
# Tie-breaker of equal weights by root
|
2021-02-16 18:53:07 +00:00
|
|
|
|
if child.get().root.tiebreak(bestChild.get().root):
|
|
|
|
|
changeToChild
|
2020-04-09 16:15:00 +00:00
|
|
|
|
else:
|
2021-02-16 18:53:07 +00:00
|
|
|
|
noChange
|
2020-04-09 16:15:00 +00:00
|
|
|
|
else: # Choose winner by weight
|
2020-08-26 15:23:34 +00:00
|
|
|
|
let cw = child.get().weight
|
2021-02-16 18:53:07 +00:00
|
|
|
|
let bw = bestChild.get().weight
|
2020-08-26 15:23:34 +00:00
|
|
|
|
if cw >= bw:
|
2021-02-16 18:53:07 +00:00
|
|
|
|
changeToChild
|
2020-04-09 16:15:00 +00:00
|
|
|
|
else:
|
2021-02-16 18:53:07 +00:00
|
|
|
|
noChange
|
2020-04-09 16:15:00 +00:00
|
|
|
|
else:
|
2021-02-16 18:53:07 +00:00
|
|
|
|
if childLeadsToViableHead:
|
2020-04-09 16:15:00 +00:00
|
|
|
|
# There is no current best-child and the child is viable
|
2021-02-16 18:53:07 +00:00
|
|
|
|
changeToChild
|
2020-04-09 16:15:00 +00:00
|
|
|
|
else:
|
|
|
|
|
# There is no current best-child but the child is not viable
|
2021-02-16 18:53:07 +00:00
|
|
|
|
noChange
|
2020-04-09 16:15:00 +00:00
|
|
|
|
|
2021-02-16 18:53:07 +00:00
|
|
|
|
self.nodes.buf[parentIdx - self.nodes.offset].bestChild = newBestChild
|
|
|
|
|
self.nodes.buf[parentIdx - self.nodes.offset].bestDescendant = newBestDescendant
|
2020-04-09 16:15:00 +00:00
|
|
|
|
|
2021-02-16 18:53:07 +00:00
|
|
|
|
ok()
|
2020-04-09 16:15:00 +00:00
|
|
|
|
|
2021-02-16 18:53:07 +00:00
|
|
|
|
func nodeLeadsToViableHead(self: ProtoArray, node: ProtoNode): FcResult[bool] =
|
2020-04-09 16:15:00 +00:00
|
|
|
|
## Indicates if the node itself or its best-descendant are viable
|
|
|
|
|
## for blockchain head
|
2021-02-16 18:53:07 +00:00
|
|
|
|
let bestDescendantIsViableForHead = block:
|
|
|
|
|
if node.bestDescendant.isSome():
|
|
|
|
|
let bestDescendantIdx = node.bestDescendant.unsafeGet()
|
|
|
|
|
let bestDescendant = self.nodes[bestDescendantIdx]
|
|
|
|
|
if bestDescendant.isNone:
|
2020-07-30 15:48:25 +00:00
|
|
|
|
return err ForkChoiceError(
|
2021-02-16 18:53:07 +00:00
|
|
|
|
kind: fcInvalidBestDescendant,
|
|
|
|
|
index: bestDescendantIdx)
|
|
|
|
|
self.nodeIsViableForHead(bestDescendant.get())
|
2020-04-09 16:15:00 +00:00
|
|
|
|
else:
|
|
|
|
|
false
|
|
|
|
|
|
2021-02-16 18:53:07 +00:00
|
|
|
|
ok(bestDescendantIsViableForHead or self.nodeIsViableForHead(node))
|
2020-04-09 16:15:00 +00:00
|
|
|
|
|
2021-02-16 18:53:07 +00:00
|
|
|
|
func nodeIsViableForHead(self: ProtoArray, node: ProtoNode): bool =
|
2020-04-09 16:15:00 +00:00
|
|
|
|
## This is the equivalent of `filter_block_tree` function in eth2 spec
|
2022-08-20 16:03:32 +00:00
|
|
|
|
## https://github.com/ethereum/consensus-specs/blob/v1.2.0-rc.3/specs/phase0/fork-choice.md#filter_block_tree
|
2022-09-06 16:58:54 +00:00
|
|
|
|
|
|
|
|
|
if node.invalid:
|
|
|
|
|
return false
|
|
|
|
|
|
2022-08-29 07:26:01 +00:00
|
|
|
|
if self.hasLowParticipation:
|
2022-09-09 00:31:33 +00:00
|
|
|
|
return
|
|
|
|
|
if node.checkpoints.justified.epoch < self.checkpoints.justified.epoch:
|
|
|
|
|
false
|
|
|
|
|
elif self.isPreviousEpochJustified:
|
|
|
|
|
node.checkpoints.finalized.epoch >= self.checkpoints.finalized.epoch
|
|
|
|
|
else:
|
|
|
|
|
node.checkpoints.finalized == self.checkpoints.finalized or
|
|
|
|
|
self.checkpoints.finalized.epoch == GENESIS_EPOCH
|
2022-08-29 07:26:01 +00:00
|
|
|
|
|
2020-04-09 16:15:00 +00:00
|
|
|
|
## Any node that has a different finalized or justified epoch
|
|
|
|
|
## should not be viable for the head.
|
|
|
|
|
(
|
2022-07-06 10:33:02 +00:00
|
|
|
|
(node.checkpoints.justified == self.checkpoints.justified) or
|
|
|
|
|
(self.checkpoints.justified.epoch == GENESIS_EPOCH)
|
2020-04-09 16:15:00 +00:00
|
|
|
|
) and (
|
2022-07-06 10:33:02 +00:00
|
|
|
|
(node.checkpoints.finalized == self.checkpoints.finalized) or
|
|
|
|
|
(self.checkpoints.finalized.epoch == GENESIS_EPOCH)
|
2020-04-09 16:15:00 +00:00
|
|
|
|
)
|
|
|
|
|
|
2022-08-29 22:02:29 +00:00
|
|
|
|
# Diagnostics
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
# Helpers to dump internal state
|
|
|
|
|
|
|
|
|
|
type ProtoArrayItem* = object
|
|
|
|
|
root*: Eth2Digest
|
|
|
|
|
parent*: Eth2Digest
|
|
|
|
|
checkpoints*: FinalityCheckpoints
|
|
|
|
|
unrealized*: Option[FinalityCheckpoints]
|
|
|
|
|
weight*: int64
|
2022-09-06 16:58:54 +00:00
|
|
|
|
invalid*: bool
|
2022-08-29 22:02:29 +00:00
|
|
|
|
bestChild*: Eth2Digest
|
|
|
|
|
bestDescendant*: Eth2Digest
|
|
|
|
|
|
|
|
|
|
func root(self: ProtoNodes, logicalIdx: Option[Index]): Eth2Digest =
|
|
|
|
|
if logicalIdx.isNone:
|
|
|
|
|
return ZERO_HASH
|
|
|
|
|
let node = self[logicalIdx.unsafeGet]
|
|
|
|
|
if node.isNone:
|
|
|
|
|
return ZERO_HASH
|
|
|
|
|
node.unsafeGet.root
|
|
|
|
|
|
|
|
|
|
iterator items*(self: ProtoArray): ProtoArrayItem =
|
|
|
|
|
## Iterate over all nodes known by fork choice.
|
|
|
|
|
doAssert self.indices.len == self.nodes.len
|
|
|
|
|
for nodePhysicalIdx, node in self.nodes.buf:
|
|
|
|
|
if node.root.isZero:
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
let unrealized = block:
|
|
|
|
|
let nodeLogicalIdx = nodePhysicalIdx + self.nodes.offset
|
|
|
|
|
if self.currentEpochTips.hasKey(nodeLogicalIdx):
|
|
|
|
|
some self.currentEpochTips.unsafeGet(nodeLogicalIdx)
|
|
|
|
|
else:
|
|
|
|
|
none(FinalityCheckpoints)
|
|
|
|
|
|
|
|
|
|
yield ProtoArrayItem(
|
|
|
|
|
root: node.root,
|
|
|
|
|
parent: self.nodes.root(node.parent),
|
|
|
|
|
checkpoints: node.checkpoints,
|
|
|
|
|
unrealized: unrealized,
|
|
|
|
|
weight: node.weight,
|
2022-09-06 16:58:54 +00:00
|
|
|
|
invalid: node.invalid,
|
2022-08-29 22:02:29 +00:00
|
|
|
|
bestChild: self.nodes.root(node.bestChild),
|
|
|
|
|
bestDescendant: self.nodes.root(node.bestDescendant))
|
|
|
|
|
|
2020-04-09 16:15:00 +00:00
|
|
|
|
# Sanity checks
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
# Sanity checks on internal private procedures
|
|
|
|
|
|
|
|
|
|
when isMainModule:
|
2020-04-10 14:06:24 +00:00
|
|
|
|
import nimcrypto/hash
|
2020-04-09 16:15:00 +00:00
|
|
|
|
|
|
|
|
|
echo "Sanity checks on fork choice tiebreaks"
|
|
|
|
|
|
|
|
|
|
block:
|
2022-02-21 11:55:56 +00:00
|
|
|
|
const a = Eth2Digest.fromHex("0x0000000000000001000000000000000000000000000000000000000000000000")
|
|
|
|
|
const b = Eth2Digest.fromHex("0x0000000000000000000000000000000000000000000000000000000000000000") # sha256(1)
|
2020-04-09 16:15:00 +00:00
|
|
|
|
|
|
|
|
|
doAssert tiebreak(a, b)
|
|
|
|
|
|
|
|
|
|
block:
|
2022-02-21 11:55:56 +00:00
|
|
|
|
const a = Eth2Digest.fromHex("0x0000000000000002000000000000000000000000000000000000000000000000")
|
|
|
|
|
const b = Eth2Digest.fromHex("0x0000000000000001000000000000000000000000000000000000000000000000") # sha256(1)
|
2020-04-09 16:15:00 +00:00
|
|
|
|
|
|
|
|
|
doAssert tiebreak(a, b)
|
|
|
|
|
|
|
|
|
|
block:
|
2022-02-21 11:55:56 +00:00
|
|
|
|
const a = Eth2Digest.fromHex("0xD86E8112F3C4C4442126F8E9F44F16867DA487F29052BF91B810457DB34209A4") # sha256(2)
|
|
|
|
|
const b = Eth2Digest.fromHex("0x7C9FA136D4413FA6173637E883B6998D32E1D675F88CDDFF9DCBCF331820F4B8") # sha256(1)
|
2020-04-09 16:15:00 +00:00
|
|
|
|
|
|
|
|
|
doAssert tiebreak(a, b)
|