Note: All code used in this notebook is contained in the notebooks/beaconrunner folder of the Beacon runner repo, and does not use current PoS specs. Most of the content here remains applicable.
Our goal in this document is to get the specs running in a cadCAD simulation environment, using the BeaconState
object defined in the Ethereum consensus layer specs. cadCAD is a cool new framework to simulate complex dynamics, in particular in stochastic environments. This makes it ideal to simulate the models of token economics and more generally any system that is controlled by state updates and user interactions. You can learn more about cadCAD by following Markus Koch's tutorials, especially part 5 on using class objects as a state variable. More specifically, we'll use radCAD, a cadCAD implementation which introduces neat features and performance improvements.
In this notebook, we present the dynamics of block proposers on the beacon chain first, to get a feel of how the chain evolves over time. Then, we introduce validation proper, where validators cast votes to finalise the chain. If we think in game-theoretic terms, here we really just want to understand the basic space of decision-making of the several players in the protocol, as well as the information available to them when such decisions are made. We simplify the presentation by assuming there is no latency and all validators can always access the latest state. A more detailed model will relax these assumptions.
We'll introduce basic building blocks of Proof-of-Stake (PoS) in Ethereum as we go along, but there is a ton of resources out there to learn more (Ben Edgington's portal is an excellent place to start for instance).
Let's start! We first need to load a bunch of stuff.
from constants import SECONDS_PER_DAY, GENESIS_EPOCH, SLOTS_PER_HISTORICAL_ROOT, MAX_VALIDATORS_PER_COMMITTEE
from specs import (
BeaconState, BeaconBlock, BeaconBlockHeader, BeaconBlockBody, SignedBeaconBlock,
Deposit, DepositData, Checkpoint, AttestationData, Attestation,
initialize_beacon_state_from_eth1, get_block_root, get_block_root_at_slot,
process_slots, process_block,
get_current_epoch, get_previous_epoch, compute_start_slot_at_epoch,
get_total_active_balance, get_committee_assignment, get_active_validator_indices
)
from ssz_impl import (hash_tree_root, signing_root)
from ssz_typing import Bitlist
from hash_function import hash
from eth2 import eth_to_gwei
import secrets
from radcad import Model, Simulation, Experiment
from radcad.engine import Engine, Backend
import pandas as pd
Dynamical systems are usually rather compact to define. We need at the very least two things:
In this part we'll focus on the initial state. We create a dummy genesis state with a bunch of validators (10 of them) who deposit 32 ETH in the contract, the minimum required to start validating.
# Create an array of `Deposit` objects
def get_initial_deposits(n):
return [Deposit(
data=DepositData(
amount=eth_to_gwei(32),
pubkey=secrets.token_bytes(48))
) for i in range(n)]
hey = "hello"
block_hash = hash(hey.encode("utf-8"))
eth1_timestamp = 1578009600
genesis_state = initialize_beacon_state_from_eth1(block_hash, eth1_timestamp, get_initial_deposits(10))
print(genesis_state.slot)
0
Let's check the current active balance! (returned here in ETH). We have 10 validators so this should tally up to 320.
get_total_active_balance(genesis_state) / 1000000000
320.0
In the specs, the first block is not proposed by a validator but obtained from the genesis state. We define a function to process the genesis block from the genesis state.
def process_genesis_block(genesis_state):
genesis_block = SignedBeaconBlock(
message=BeaconBlock(
state_root=hash_tree_root(genesis_state),
parent_root=hash_tree_root(genesis_state.latest_block_header)
)
)
process_block(genesis_state, genesis_block.message)
process_genesis_block(genesis_state)
Finally, set the genesis state as the initial condition to our cadCAD execution. We'll also record the latest block root, more on this later.
initial_conditions = {
'beacon_state': genesis_state,
'latest_block_root': None,
}
We move on to defining the dynamics. Simple dynamical systems follow some rules to update their state, e.g., the orbits of planets are given by laws derived from attraction. More complex systems are controlled: decisions made by agents in the system influence the state evolution.
In our setting, these agents are validators. State evolution is governed by the rules of the virtual machine, while validators get to decide on blocks and transactions. The resulting state is a combination of agent decisions and state updates. How validators make these decisions is embodied by policy functions: given a state, make a decision.
In eth2, time is subdivided in epochs, themselves divided in slots. At most one block should be proposed at each slot, while accounting for votes and doling out rewards and penalties is done at the end of each epoch. Note that to speed things up in this document, we set the SLOTS_PER_EPOCH
constant to 4 in the constants.py
file. This is 8x faster than the value in the current specs. (ICO when???)
We now define policies and state updates to run the simulation. Let's take a moment to understand how state transitions work in the PoS specs, with notation we introduce here to clarify the different states.
process_block
obtains state $\omega^+[s]$ from $\omega^-[s]$ and $b_s$.process_slots
caches the block root of $b_s$ and the state root. If $s+1$ is the first slot of a new epoch, process_epoch
is called which checks for justification and finalisation of checkpoints as well as attribute rewards and punishments to active validators and process exits and new entries.Let's talk about the start of this process. The genesis_state
object defined above really is $\omega^-[0]$. When we call process_genesis_block
, we move to $\omega^+[0]$. This is the initial condition of our simulations.
In our simulation, we move the state ahead given a block $b_s$ for slot $s$ from $\omega^+[s-1]$ to $\omega^+[s]$. We will break it down in three steps:
s-1
, moving from $\omega^+[s-1]$ to $\omega^-[s]$. If a block was proposed during slot $s-1$, the root of the block is cached in the block_roots
attribute of $\omega^-[s]$, at index s-1
. Otherwise, the root of the most recent block is cached. Additionally, if s-1
is the last slot of an epoch, process_epoch
is called.Let's do this step by step:
The arguments of this function are given to us by the cadCAD execution environment. s
holds the current state of the simulation, where s['beacon_state']
returns the current beacon state. Remember that we had set the initial state $\omega^+[0]$ as the beacon_state
attribute of the initial_conditions
dictionary.
def state_update_slot(params, step, sL, s, _input):
# Given state w+[s], transition to w-[s+1]
# state is w+[s]
state = s['beacon_state']
process_slots(state, state.slot + 1)
return ('beacon_state', state)
We have more latitude to define what happens at this step. We can for instance make the difference between an honest validator who returns a block when they are the block proposer, given the most current state, and an offline validator who just does not produce anything. Given a state, each will return a different item.
def honest_block_proposal(state):
# State is w-[s], block will be proposed for slot s
# Later on, we populate the `BeaconBlockBody`. For now, our validators merely produce empty blocks.
beacon_block_body = BeaconBlockBody()
beacon_block = BeaconBlock(
slot=state.slot,
# the parent root is accessed from the state
parent_root=get_block_root_at_slot(state, state.slot-1),
body=beacon_block_body
)
signed_beacon_block = SignedBeaconBlock(message=beacon_block)
print("honest propose a block for slot", state.slot)
return signed_beacon_block
def offline_block_proposal(state):
print("offline propose nothing for slot", state.slot)
return None
We now define the policy function of step 2, again using the arguments that the cadCAD environment gives us. In this simulation, we'll assume that an honest and an offline validator propose in turn, so we only have blocks on even slots.
def propose_block(params, step, sL, s):
# Given state w-[s], propose a block for slot s
# `state` is w-[s]
state = s['beacon_state']
if state.slot % 2 == 0:
block = honest_block_proposal(state)
else:
block = offline_block_proposal(state)
return ({ 'block': block })
Given a block, we can run the state transition by calling process_block
. We get the block proposed in step 2 as the block
attribute of _input
.
def state_update_block(params, step, sL, s, _input):
state = s['beacon_state']
block = _input['block']
if block is None:
# No change to the state
return ('beacon_state', state)
# Otherwise we process the block first and return the state
process_block(state, block.message)
return ('beacon_state', state)
We also add a step to record the latest block root in our state object.
def record_latest_block_root(params, step, sL, s, _input):
state = s['beacon_state']
return ('latest_block_root', hash_tree_root(state.latest_block_header).hex()[0:6])
The previous three steps are recorded in the block_proposal_psub
array. psub
stands for partial state updates blocks, the building blocks of a cadCAD simulation.
block_proposal_psub = [
{
'policies': {
},
'variables': {
'beacon_state': state_update_slot, # step 1
'latest_block_root': record_latest_block_root,
}
},
{
'policies': {
'action': propose_block # step 2
},
'variables': {
'beacon_state': state_update_block, # step 3
'latest_block_root': record_latest_block_root,
}
}
]
We set up a radCAD experiment simulating 15 slots
model = Model(
initial_state=initial_conditions,
state_update_blocks=block_proposal_psub,
params={},
)
simulation = Simulation(model=model, timesteps=15, runs=1)
experiment = Experiment([simulation])
experiment.engine = Engine(deepcopy=False, backend=Backend.SINGLE_PROCESS)
result = experiment.run()
df = pd.DataFrame(result)
offline propose nothing for slot 1 honest propose a block for slot 2 offline propose nothing for slot 3 START PROCESS EPOCH 0 not processing justification and finalization END PROCESS EPOCH honest propose a block for slot 4 offline propose nothing for slot 5 honest propose a block for slot 6 offline propose nothing for slot 7 START PROCESS EPOCH 1 not processing justification and finalization END PROCESS EPOCH honest propose a block for slot 8 offline propose nothing for slot 9 honest propose a block for slot 10 offline propose nothing for slot 11 START PROCESS EPOCH 2 old_previous 0 old_current 0 justification bits Bitvector[boolean, 4](0, 0, 0, 0) finalised checkpoint 0 END PROCESS EPOCH honest propose a block for slot 12 offline propose nothing for slot 13 honest propose a block for slot 14 offline propose nothing for slot 15
The historical sequence of states is held in the df
dataframe, let's take a look.
df.iloc[:, [1, -1, -2]]
latest_block_root | timestep | substep | |
---|---|---|---|
0 | None | 0 | 0 |
1 | 9ce1e0 | 1 | 1 |
2 | 9ce1e0 | 1 | 2 |
3 | 9ce1e0 | 2 | 1 |
4 | 54b82e | 2 | 2 |
5 | 5827e1 | 3 | 1 |
6 | 5827e1 | 3 | 2 |
7 | 5827e1 | 4 | 1 |
8 | 02da23 | 4 | 2 |
9 | 7ec27d | 5 | 1 |
10 | 7ec27d | 5 | 2 |
11 | 7ec27d | 6 | 1 |
12 | 6e7e30 | 6 | 2 |
13 | 76646e | 7 | 1 |
14 | 76646e | 7 | 2 |
15 | 76646e | 8 | 1 |
16 | 143021 | 8 | 2 |
17 | 4f58bc | 9 | 1 |
18 | 4f58bc | 9 | 2 |
19 | 4f58bc | 10 | 1 |
20 | 8a7130 | 10 | 2 |
21 | 9b5654 | 11 | 1 |
22 | 9b5654 | 11 | 2 |
23 | 9b5654 | 12 | 1 |
24 | e023d2 | 12 | 2 |
25 | a624cd | 13 | 1 |
26 | a624cd | 13 | 2 |
27 | a624cd | 14 | 1 |
28 | 19ac31 | 14 | 2 |
29 | bf01be | 15 | 1 |
30 | bf01be | 15 | 2 |
We give the last few bytes of the latest block header root in the state. Timesteps virtually represent slots of the beacon chain. For each timestep, we have two substeps since we have two partial state update blocks.
s
and substep 1, the state is $\omega^-[s]$.s
and substep 2, the state is $\omega^+[s]$.Note: The block root does not change during odd timesteps. This is because we have offline validators who do not propose blocks then.
Also note: The latest block root in substep 2 of even timesteps is unique. Before we process the slot, the state_root
attribute of the latest block header saved in the state is not set. When we process the slot, this state root is set and this changes the latest_block_root
hash.
Finally, we can check whether checkpoints (i.e., blocks between epochs) are being finalised, which is why we care about the beacon chain in the first place.
df.iloc[30]['beacon_state'].finalized_checkpoint
{'epoch': 0, 'root': b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'}
Nope! The last finalised checkpoint is still the genesis block (epoch == 0
). The blocks are empty since we restricted validators to their block proposer roles. Time to have them vote on the canonical chain and start finalising!
We create new policies to make validators attest on the blocks of the beacon chain. Attestations are "votes" in the FFG gadget, committing to checkpoints and beacon chain blocks. All active validators are called on to produce one attestation during each epoch. Each validator is assigned to one slot to make the attestation at, preferably right after they have received the block proposed for that slot.
So attestations should contain:
Admittedly, we are cheating a bit here. Since we assume our validators are honest and there is no latency, they are instantly up-to-date with the latest chain state, so we can use the BeaconState
attributes to form validator attestations. In reality, validators should run the fork choice to decide which is the head of the beacon chain.
We start at some state $\omega^+[s-1]$, i.e., the post-state of slot $s-1$.
current_slot_attestations
.current_slot_attestations
.We already have a state transition function for that, our previously defined state_update_slot
.
Let's focus on the new step here, step #2. We first need to create honest attestations from validators.
def honest_attest(state, validator_index):
# Given state w-[s], validators in committees of slot `s-1` form their attestations
# In several places here, we need to check whether `s` is the first slot of a new epoch.
current_epoch = get_current_epoch(state)
previous_epoch = get_previous_epoch(state)
# Since everyone is honest, we can assume that validators attesting during some epoch e
# choose the first block of e as their target, and the first block of e-1 as their source
# checkpoint.
#
# So let's assume the validator here is making an attestation at slot s in epoch e:
#
# - If the `state` variable is at epoch e, then the first block of epoch e-1 is
# a checkpoint held in `state.current_justified_checkpoint`.
# The target checkpoint root is obtained by calling
# `get_block_root(state, current_epoch)` (since current_epoch = e).
#
# - If the `state` variable is at epoch e+1, then the first block of epoch e-1
# is a checkpoint held in `state.previous_justified_checkpoint`,
# since in the meantime the first block of e was justified.
# This is the case when s is the last slot of epoch e.
# The target checkpoint root is obtained by calling
# `get_block_root(state, previous_epoch)` (since current_epoch = e+1).
#
# ... still here?
# If `state` is already at the start of a new epoch e+1
if state.slot == compute_start_slot_at_epoch(current_epoch):
# `committee_slot` is equal to s-1
(committee, committee_index, committee_slot) = get_committee_assignment(
state, previous_epoch, validator_index
)
# Since we are at state w-[s], we can get the block root of the block at slot s-1.
block_root = get_block_root_at_slot(state, committee_slot)
src_checkpoint = Checkpoint(
epoch=state.previous_justified_checkpoint.epoch,
root=state.previous_justified_checkpoint.root
)
tgt_checkpoint = Checkpoint(
epoch=previous_epoch,
root=get_block_root(state, previous_epoch)
)
# Otherwise, if `state` is at epoch e
else:
# `committee_slot` is equal to s-1
(committee, committee_index, committee_slot) = get_committee_assignment(
state, current_epoch, validator_index
)
# Since we are at state w-[s], we can get the block root of the block at slot s-1.
block_root = get_block_root_at_slot(state, committee_slot)
src_checkpoint = Checkpoint(
epoch=state.current_justified_checkpoint.epoch,
root=state.current_justified_checkpoint.root
)
tgt_checkpoint = Checkpoint(
epoch=current_epoch,
root=get_block_root(state, current_epoch)
)
att_data = AttestationData(
index = committee_index,
slot = committee_slot,
beacon_block_root = block_root,
source = src_checkpoint,
target = tgt_checkpoint
)
print("attestation for source", src_checkpoint.epoch, "and target", tgt_checkpoint.epoch)
# For now we disregard aggregation of attestations.
# Some validators are chosen as aggregators: they take a bunch of identical attestations
# and join them together in one object,
# with `aggregation_bits` identifying which validators are part of the aggregation.
committee_size = len(committee)
index_in_committee = committee.index(validator_index)
aggregation_bits = Bitlist[MAX_VALIDATORS_PER_COMMITTEE](*([0] * committee_size))
aggregation_bits[index_in_committee] = True # set the aggregation bits of the validator to True
attestation = Attestation(
aggregation_bits=aggregation_bits,
data=att_data
)
return attestation
We can use this honest_attest
function in our policy function for step #2, honest_attest_policy
.
def honest_attest_policy(params, step, sL, s):
# Collect all attestations formed for slot s-1.
# `state` is at w-[s]
state = s['beacon_state']
current_epoch = get_current_epoch(state)
previous_epoch = get_previous_epoch(state)
# `validator_epoch` is the epoch of slot s-1.
# - If the state is already ahead by one epoch, this is given by `previous_epoch`
# - Otherwise it is `current_epoch`
if state.slot == compute_start_slot_at_epoch(current_epoch):
validator_epoch = previous_epoch
else:
validator_epoch = current_epoch
active_validator_indices = get_active_validator_indices(state, validator_epoch)
slot_attestations = []
for validator_index in active_validator_indices:
# For each validator, check which committee they belong to
(committee, committee_index, committee_slot) = get_committee_assignment(
state, validator_epoch, validator_index
)
# If they belong to a committee attesting for slot s-1, we ask them to form an attestation
# using `honest_attest` defined above.
if committee_slot+1 == state.slot:
print("validator attesting", validator_index, "for slot", committee_slot)
attestation = honest_attest(state, validator_index)
slot_attestations.append(attestation)
return({ 'slot_attestations': slot_attestations })
Step #3 is easily handled with one single state update.
def update_current_slot_attestations(params, step, sL, s, _input):
# Take the output of `honest_attest_policy` and set it as `current_slot_attestations`
return('current_slot_attestations', _input['slot_attestations'])
We must update our block proposal policy to include latest attestations. This time, we do not include offline block proposers.
def honest_block_proposal(state, attestations):
# State is w-[s], block will be proposed for slot s
beacon_block_body = BeaconBlockBody(
attestations=attestations
)
beacon_block = BeaconBlock(
slot=state.slot,
# the parent root is accessed from the state
parent_root=get_block_root_at_slot(state, state.slot-1),
body=beacon_block_body
)
signed_beacon_block = SignedBeaconBlock(message=beacon_block)
print("honest propose a block for slot", state.slot)
return signed_beacon_block
def propose_block(params, step, sL, s):
# Given state w-[s], propose a block for slot s
# `state` is w-[s]
state = s['beacon_state']
# We get the output of our honest attestation policy
attestations = s['current_slot_attestations']
block = honest_block_proposal(state, attestations)
return ({ 'block': block })
We also have a state update for this step, state_update_block
, which we do not need to change.
We'll record the total balance received by validators during the simulation.
def total_balance(params, step, sL, s, _input):
state = s["beacon_state"]
return "total_balance", sum(state.balances) - eth_to_gwei(32 * len(state.validators))
Our PSUB array becomes:
block_attestation_psub = [
# Step 1
{
'policies':{
},
'variables': {
'beacon_state': state_update_slot,
'latest_block_root': record_latest_block_root,
}
},
# Step 2+3
{
'policies': {
'action': honest_attest_policy
},
'variables': {
'current_slot_attestations': update_current_slot_attestations,
}
},
# Step 4+5
{
'policies': {
'action': propose_block
},
'variables': {
'beacon_state': state_update_block,
'latest_block_root': record_latest_block_root,
'total_balance': total_balance,
}
}
]
We need to change our initial conditions to add the current_slot_attestations
state attribute, which starts out empty. To keep the overhead low, we set a low number of validators and run the simulation for 20 slots.
%%capture
num_validators = 10
genesis_state = initialize_beacon_state_from_eth1(
block_hash, eth1_timestamp, get_initial_deposits(num_validators)
)
initial_conditions = {
'beacon_state': genesis_state,
'current_slot_attestations': [],
'latest_block_root': None,
'total_balance': 0,
}
Here we leave the execution trace visible. Use a %%capture
directive at the top of the following cell to hide it.
model = Model(
initial_state=initial_conditions,
state_update_blocks=block_attestation_psub,
params={},
)
simulation = Simulation(model=model, timesteps=20, runs=1)
experiment = Experiment([simulation])
experiment.engine = Engine(deepcopy=False, backend=Backend.SINGLE_PROCESS)
result = experiment.run()
df = pd.DataFrame(result)
validator attesting 3 for slot 0 attestation for source 0 and target 0 validator attesting 5 for slot 0 attestation for source 0 and target 0 honest propose a block for slot 1 validator attesting 0 for slot 1 attestation for source 0 and target 0 validator attesting 6 for slot 1 attestation for source 0 and target 0 validator attesting 7 for slot 1 attestation for source 0 and target 0 honest propose a block for slot 2 validator attesting 4 for slot 2 attestation for source 0 and target 0 validator attesting 8 for slot 2 attestation for source 0 and target 0 honest propose a block for slot 3 START PROCESS EPOCH 0 not processing justification and finalization END PROCESS EPOCH validator attesting 1 for slot 3 attestation for source 0 and target 0 validator attesting 2 for slot 3 attestation for source 0 and target 0 validator attesting 9 for slot 3 attestation for source 0 and target 0 honest propose a block for slot 4 validator attesting 0 for slot 4 attestation for source 0 and target 1 validator attesting 3 for slot 4 attestation for source 0 and target 1 honest propose a block for slot 5 validator attesting 2 for slot 5 attestation for source 0 and target 1 validator attesting 4 for slot 5 attestation for source 0 and target 1 validator attesting 8 for slot 5 attestation for source 0 and target 1 honest propose a block for slot 6 validator attesting 6 for slot 6 attestation for source 0 and target 1 validator attesting 7 for slot 6 attestation for source 0 and target 1 honest propose a block for slot 7 START PROCESS EPOCH 1 not processing justification and finalization END PROCESS EPOCH validator attesting 1 for slot 7 attestation for source 0 and target 1 validator attesting 5 for slot 7 attestation for source 0 and target 1 validator attesting 9 for slot 7 attestation for source 0 and target 1 honest propose a block for slot 8 validator attesting 4 for slot 8 attestation for source 0 and target 2 validator attesting 8 for slot 8 attestation for source 0 and target 2 honest propose a block for slot 9 validator attesting 0 for slot 9 attestation for source 0 and target 2 validator attesting 2 for slot 9 attestation for source 0 and target 2 validator attesting 9 for slot 9 attestation for source 0 and target 2 honest propose a block for slot 10 validator attesting 1 for slot 10 attestation for source 0 and target 2 validator attesting 6 for slot 10 attestation for source 0 and target 2 honest propose a block for slot 11 START PROCESS EPOCH 2 old_previous 0 old_current 0 new current justified checkpoint 1 new current justified checkpoint 2 justification bits Bitvector[boolean, 4](1, 1, 0, 0) finalised checkpoint 0 END PROCESS EPOCH validator attesting 3 for slot 11 attestation for source 0 and target 2 validator attesting 5 for slot 11 attestation for source 0 and target 2 validator attesting 7 for slot 11 attestation for source 0 and target 2 honest propose a block for slot 12 validator attesting 2 for slot 12 attestation for source 2 and target 3 validator attesting 3 for slot 12 attestation for source 2 and target 3 honest propose a block for slot 13 validator attesting 0 for slot 13 attestation for source 2 and target 3 validator attesting 6 for slot 13 attestation for source 2 and target 3 validator attesting 7 for slot 13 attestation for source 2 and target 3 honest propose a block for slot 14 validator attesting 8 for slot 14 attestation for source 2 and target 3 validator attesting 9 for slot 14 attestation for source 2 and target 3 honest propose a block for slot 15 START PROCESS EPOCH 3 old_previous 0 old_current 2 new current justified checkpoint 2 new current justified checkpoint 3 justification bits Bitvector[boolean, 4](1, 1, 1, 0) finalised checkpoint 2 END PROCESS EPOCH validator attesting 1 for slot 15 attestation for source 2 and target 3 validator attesting 4 for slot 15 attestation for source 2 and target 3 validator attesting 5 for slot 15 attestation for source 2 and target 3 honest propose a block for slot 16 validator attesting 0 for slot 16 attestation for source 3 and target 4 validator attesting 5 for slot 16 attestation for source 3 and target 4 honest propose a block for slot 17 validator attesting 4 for slot 17 attestation for source 3 and target 4 validator attesting 6 for slot 17 attestation for source 3 and target 4 validator attesting 8 for slot 17 attestation for source 3 and target 4 honest propose a block for slot 18 validator attesting 1 for slot 18 attestation for source 3 and target 4 validator attesting 3 for slot 18 attestation for source 3 and target 4 honest propose a block for slot 19 START PROCESS EPOCH 4 old_previous 2 old_current 3 new current justified checkpoint 3 new current justified checkpoint 4 justification bits Bitvector[boolean, 4](1, 1, 1, 1) finalised checkpoint 3 END PROCESS EPOCH validator attesting 2 for slot 19 attestation for source 3 and target 4 validator attesting 7 for slot 19 attestation for source 3 and target 4 validator attesting 9 for slot 19 attestation for source 3 and target 4 honest propose a block for slot 20
Let's check the dataframe to make sure our state updates are correct. Since we have several partial updates blocks, we will have several substeps at each timestep. We give the length of the number of attestations in column num_attestations
. They add up to 10 over each epoch, as we have 10 validators.
df['num_attestations'] = df.current_slot_attestations.apply(len)
df['epoch'] = df.timestep.apply(lambda ts: (ts-1) // 4)
df[df.substep == 1].head(20).iloc[:, [-3, -1, -2, 2, 3]]
timestep | epoch | num_attestations | latest_block_root | total_balance | |
---|---|---|---|---|---|
1 | 1 | 0 | 0 | 65ccaf | 0 |
4 | 2 | 0 | 2 | c33a18 | 0 |
7 | 3 | 0 | 3 | 6f7f5c | 0 |
10 | 4 | 0 | 2 | f21715 | 0 |
13 | 5 | 1 | 3 | 7399cc | 0 |
16 | 6 | 1 | 2 | 39eb3c | 0 |
19 | 7 | 1 | 3 | ed2a7b | 0 |
22 | 8 | 1 | 2 | 3fb1d1 | 0 |
25 | 9 | 2 | 3 | ab6c45 | 36203880 |
28 | 10 | 2 | 2 | 100552 | 36203880 |
31 | 11 | 2 | 3 | 54be85 | 36203880 |
34 | 12 | 2 | 2 | a57a9a | 36203880 |
37 | 13 | 3 | 3 | cdb5f5 | 72407760 |
40 | 14 | 3 | 2 | 7d454f | 72407760 |
43 | 15 | 3 | 3 | d2aa6e | 72407760 |
46 | 16 | 3 | 2 | 52ea61 | 72407760 |
49 | 17 | 4 | 3 | f458e9 | 108611640 |
52 | 18 | 4 | 2 | 1f5d55 | 108611640 |
55 | 19 | 4 | 3 | abbcc6 | 108611640 |
58 | 20 | 4 | 2 | aeae3c | 108611640 |
We can check the balances of our validators.
df.iloc[60]['beacon_state'].balances
List[Gwei, 1099511627776](32014368415, 32014820963, 32014594689, 32014255278, 32014707826, 32014594689, 32014481552, 32014368415, 32014368415, 32014255278)
Note that they are getting rewarded for attesting correctly, with balances greater than the initial 32 ETH they started with, but some are more rewarded than others: this is the randomness of the block proposal. If you picked a low num_validators
, you will notice that rewards are quite high. This is due to the variable rate of reward, which increases when the supply of validators is low. In practice, for 1,000,000 ETH at stake, we ought to have about 30,000 validators.
We can also check that we are finalising checkpoints.
df.iloc[60]['beacon_state'].finalized_checkpoint
{'epoch': 3, 'root': b'\xcd\xb5\xf5\x94Ork,\x90\x04\x87\n,\x80\xa94\xe4\xef\xa8\x17\x07\x01\xa1\xf5V\x13\x1d>q\xe4G$'}
Indeed, the latest finalised checkpoint is at epoch 3!
Let's plot the total rewards given out to validators (the "issuance"). First we add a column to our dataframe holding the net profits from state balances.
df[df.substep == 1].plot('timestep', 'total_balance')
<AxesSubplot:xlabel='timestep'>
It's kind of nice to see a small economy grow before your very eyes no? Like gardening.
Now how can we extend this model?
Some efforts should also be spent making the simulation lighter to execute, so that larger models can be tested, including stochastic events using Monte-Carlo methods which require repeating executions a significant amount of times.
Some other cool resources: