# ePBS Forkchoice annotated spec
This document deals with the technical specification of forkchoice for ePBS. For a more high level description of it we recommend the reader to look into [the design notes](https://hackmd.io/uWVGcvcKSoqS4P5c5NHG3g#Forkchoice-considerations).
The main problem with regards to forkchoice is that instead of using a vanilla `(block, slot)` replacement for LMD, we use a triple `(block, slot, payload_present)` system where attestations may support a consensus block for a given slot, but not the payload that was revealed for that block for example. This is to prevent situations in which a malicious collusion of a builder and a proposer, agree to reveal the payload late and thus reorg the next proposer.
In the following text I will assume that the reader has already gone through the examples linked in the design notes, and will only comment here as to the specific implementation functions of the `forkchoice.md` document in [this PR](https://github.com/potuz/consensus-specs/pull/2).
## Modified constants
| Name | Value |
| -------------------- | ----------- |
| `PAYLOAD_TIMELY_THRESHOLD` | `PTC_SIZE/2` (=`uint64(256)`) |
| `PROPOSER_SCORE_BOOST` | `20` (modified in ePBS]) |
| `PAYLOAD_WITHHOLD_BOOST` | `40` |
| `PAYLOAD_REVEAL_BOOST` | `40` |
These are the values described for `PB`, `WB` and `RB` in the design notes and in [this article](https://ethresear.ch/t/payload-boosts-in-epbs/18769). The constant `PAYLOAD_TIMELY_THRESHOLD` is the minimum number of votes from the PTC that a payload status has to receive in order for the PTC to achieve quorum
## `ChildNode`
This is an auxiliary class to consider `(block, slot, bool)` LMD voting. A forkchoice node is determined by its beacon block root `root`, whether the block was full or empty (`is_payload_present`) and the `slot` in which this node context is being considered: for example when answering the question, is the full block with root `root` viable for head at slot `slot`?
```python
class ChildNode(Container):
root: Root
slot: Slot
is_payload_present: bool
```
## Latest Messages
### Modified `LatestMessage`
**Note:** The class is modified to keep track of the slot instead of the epoch
```python
@dataclass(eq=True, frozen=True)
class LatestMessage(object):
slot: Slot
root: Root
```
The reason for this modification is so that when receiving an attestation for slot `N` that points to the block root of `N-1` (because `N` arrived late), then we can assert that this attestation does not support the block `N` for head at `N`, but rather supports `N-1`, advanced to `N` instead. That, is to allow an attestation to vote for the red node as head:
```graphviz
digraph G {
rankdir=RL;
node [style=filled, fillcolor=lightblue];
A [label="N-1"];
B [label="N-1", style="dashed,bold", color=red];
C [label="N"];
C -> A;
B -> A [style=dashed, color=red];
}
```
### Modified `update_latest_messages`
The function `update_latest_messages` is updated to use the attestation slot instead of target. Since this function is only called on attestations that have been previously validated then the epoch that corresponds to the slot has to indeed be a new epoch for the latest message of this validator unless in the case where the validator has committed a slashable offense.
```python
def update_latest_messages(store: Store, attesting_indices: Sequence[ValidatorIndex], attestation: Attestation) -> None:
slot = attestation.data.slot
beacon_block_root = attestation.data.beacon_block_root
non_equivocating_attesting_indices = [i for i in attesting_indices if i not in store.equivocating_indices]
for i in non_equivocating_attesting_indices:
if i not in store.latest_messages or slot > store.latest_messages[i].slot:
store.latest_messages[i] = LatestMessage(slot=slot, root=beacon_block_root)
```
## Modified `Store`
Store adds the following entries:
```python
@dataclass
class Store(object):
...
execution_payload_states: Dict[Root, BeaconState] = field(default_factory=dict) # [New in ePBS]
inclusion_list_available: Dict[Root, bool] = field(default_factory=dict) # [New in ePBS]
ptc_vote: Dict[Root, Vector[uint8, PTC_SIZE]] = field(default_factory=dict) # [New in ePBS]
payload_withhold_boost_root: Root # [New in ePBS]
payload_withhold_boost_full: bool # [New in ePBS]
payload_reveal_boost_root: Root # [New in ePBS]
```
- `execution_payload_states` are the post-states obtained after performing the state transition function of importing an execution payload, that is `process_execution_payload`. This is a map keyed by the beacon block root that committed to that payload, rather than by the payload hash.
- `inclusion_list_available` keeps track if the inclusion list for the given beacon block root has been seen and validated. We could remove this entry if we make this availability a validity condition for the corresponding beacon block.
- `ptc_vote` keeps track of all the votes from the PTC committee
:::warning
Clients may chose to only keep track of this full vote for the current slot and instead keep a list of boolean for the payload presence of each block before that.
:::
- `payload_withhold_boost_root` is the parent block root of a payload that has achieved consensus on `PAYLOAD_WITHHELD`.
- `payload_withhold_boost_full` whether the parent block of the withheld payload was full or empty.
- `payload_reveal_boost_root` is the block root of a payload that has achieved consensus on `PAYLOAD_PRESENT`.
## Inclusion Lists
### `verify_inclusion_list`
This function simply checks the signature of the inclusion list, checks that the proposer index agrees with the corresponding block proposer and then sends the IL to the EL for validation.
```python
def verify_inclusion_list(state: BeaconState, block: BeaconBlock, inclusion_list: InclusionList, execution_engine: ExecutionEngine) -> bool:
"""
returns true if the inclusion list is valid.
"""
# Check that the inclusion list corresponds to the block proposer
signed_summary = inclusion_list.summary
proposer_index = signed_summary.message.proposer_index
assert block.proposer_index == proposer_index
# Check that the signature is correct
assert verify_inclusion_list_summary_signature(state, signed_summary)
# TODO: These checks will also be performed by the EL surely so we can probably remove them from here.
# Check the summary and transaction list lengths
summary = signed_summary.message.summary
assert len(summary) <= MAX_TRANSACTIONS_PER_INCLUSION_LIST
assert len(inclusion_list.transactions) == len(summary)
# Check that the inclusion list is valid
return execution_engine.notify_new_inclusion_list(NewInclusionListRequest(
inclusion_list=inclusion_list.transactions,
summary=summary,
parent_block_hash = state.latest_execution_payload_header.block_hash))
```
### `blocks_for_slot`
The function `blocks_for_slot` returns all the beacon blocks found in the store for the given slot. This implementation here is only for specification purposes and clients may choose to optimize this by using an internal map or similar caching structures.
```python
def blocks_for_slot(store: Store, slot: Slot) -> Set[BeaconBlock]:
return [block for root, block in store.blocks.items() if block.slot == slot]
```
### `block_for_inclusion_list`
The function `block_for_inclusion_list` returns a known beacon block in store that is compatible with the given inclusion list. Because of equivocations, we may have different beacon blocks that have compatible data with an inclusion list. This is not really a problem as we make sure that the parent hash is the same, validity of the IL should hold for any block even if equivocating.
```python
def block_for_inclusion_list(store: Store, inclusion_list: InclusionList) -> Optional[BeaconBlock]:
summary = inclusion_list.signed_summary.message
parent_hash = inclusion_list.parent_block_hash
blocks = blocks_for_slot(store, summary.slot)
for block in blocks:
if block.slot == summary.slot and block.proposer_index == summary.proposer_index and block.signed_execution_payload_header.message.parent_block_hash == parent_hash:
return block
return None
```
### `on_inclusion_list`
This is the main handler for inclusion lists, it is called every time an `InclusionList` is seen by the node that passes pubsub validation. This specification requires that there is already a beacon block in the store that is compatible with this inclusion list. Client developers may (and should) instead validate the inclusion list against the head state in case it arrives earlier than the beacon block and cache this result. That is, client nodes are encouraged to validate immediately the inclusion list even if they do not have the block, assuming it is for the current slot and the proposer is correct, the client can take the parent block hash and send the IL for validation to the EL immediately. Then they can cache the result in `store.inclusion_list_available` for usage when the beacon block is validated.
```python
def on_inclusion_list(store: Store, inclusion_list: InclusionList) -> None:
"""
Validates an incoming inclusion list and records the result in the corresponding forkchoice node.
"""
# Require we have one block compatible with the inclusion list
block = block_for_inclusion_list(store, inclusion_list)
assert block is not None
root = hash_tree_root(block)
assert root in store.block_states
state = store.block_states[root]
assert block.parent_root in store.blocks
parent_block = store.blocks[block.parent_root]
# Ignore the list if the parent consensus block did not contain a payload
header = block.body.signed_execution_payload_header.message
parent_header = parent_block.body.signed_execution_payload_header.message
if header.parent_block_hash != parent_header.block_hash
assert header.parent_block_hash == parent_header.parent_block_hash
return
# verify the inclusion list
assert verify_inclusion_list(state, block, inclusion_list, EXECUTION_ENGINE)
store.inclusion_list_available[root]=True
```
## LMD Weights
### `is_payload_present`
This function simply returns whether the given beacon block root has a payload that the PTC vote has achieved quorum for `PAYLOAD_PRESENT`. If there is no quorum then the payload is considered absent for the point of view of PTC accounting.
```python
def is_payload_present(store: Store, beacon_block_root: Root) -> bool:
"""
return whether the execution payload for the beacon block with root ``beacon_block_root`` was voted as present
by the PTC
"""
# The beacon block root must be known
assert beacon_block_root in store.ptc_vote
return store.ptc_vote[beacon_block_root].count(PAYLOAD_PRESENT) > PAYLOAD_TIMELY_THRESHOLD
```
:::danger
This function does not return the payload content of the node in the canonical chain. It may well be that the canonical node disagrees with the PTC vote for the node. This can specially happen if the PTC vote has not reached everywhere on time. Whenever possible, users should use `is_parent_node_full`
:::
### `is_parent_node_full`
This function checks if the parent block for the passed beacon block was full or empty. This exploits the fact that given a node, its parent has a unique payload content. This is in contrast with the above function `is_payload_present` which only takes into account a single node.
```python
def is_parent_node_full(store: Store, block: BeaconBlock) -> bool:
parent = store.blocks[block.parent_root]
return block.body.signed_execution_payload_header.message.parent_block_hash == parent.body.signed_execution_payload_header.message.block_hash
```
### Modified `get_ancestor`
This function is modified to account for the different possible statuses of a slot. The slot can be either:
- Full (both blocks are present)
- Empty (only the consensus block is present)
- Skipped (no block is present)
This function takes a `root` and looks up what is the block root that is the ancestor of the current block at the given slot. It returns a `ChildNode` object containing the root of the beacon block, the slot at which this block was proposed and the payload status. Notice that for skipped slots, the root will be the last consensus block before the given slot, just as today.
```python
def get_ancestor(store: Store, root: Root, slot: Slot) -> ChildNode:
"""
Returns the beacon block root, the slot and the payload status of the ancestor of the beacon block
with ``root`` at ``slot``. If the beacon block with ``root`` is already at ``slot`` it returns it's
PTC status instead of the actual payload content.
"""
block = store.blocks[root]
if block.slot == slot:
return ChildNode(root=root, slot=slot, is_payload_present= is_payload_present(store, root))
assert block.slot > slot
parent = store.blocks[block.parent_root]
if parent.slot > slot:
return get_ancestor(store, block.parent_root, slot)
return ChildNode(root=block.parent_root, slot=parent.slot, is_payload_present=is_parent_node_full(block))
```
Let us analyze a few examples for this function. In all cases we call `get_ancestor` with `root=N+1`. We abuse notation and by `root = N+1` we mean the block root of the block at slot `N+1`. The ancestor is marked in red. Recall our coloring scheme from [the design notes](https://hackmd.io/uWVGcvcKSoqS4P5c5NHG3g?view) in which lightblue nodes are full, orange ones are empty and white ones are missing.
#### Same slot
We call `get_ancestor` with `slot = N+1`
```graphviz
digraph G {
rankdir=RL;
node [style=filled, fillcolor=lightblue];
A [label="N+1", color=red, style="filled,bold"];
}
```
The first `if` statement hits:
```python=
if block.slot <= slot:
return ChildNode(root=root, slot=slot, is_payload_present= is_payload_present(store, root))
```
This is a very special case in which the ancestor for a given block root will return the payload status deduced from the PTC vote. That is, as soon as the block is imported and no PTC votes have been counted, the result would be for the absent payload:
```graphviz
digraph G {
rankdir=RL;
node [style=filled, fillcolor=lightblue];
A [label="N+1", color=red, style="filled,bold", fillcolor=orange];
}
```
The same would happen if requesting the *ancestor for a future slot*.
:::warning
This special case needs to be taken into account when analyzing any call to `get_ancestor`.
:::
#### Single chain full blocks
We call `get_ancestor` with `slot = N`. In this case all blocks are full.
```graphviz
digraph G {
rankdir=RL;
node [style=filled, fillcolor=lightblue];
A [label="N", color=red, style="filled,bold"];
B [label="N+1"];
B-> A;
}
```
The second if statement does not hit
```python
if parent.slot > slot:
return get_ancestor(store, block.parent_root, slot)
```
since `parent.slot == slot = N`. The parent's block hash is the current current block's (`N+1`) parent block hash, hence the return is for `N` and `True` indicating that this chain builds on the full node.
#### Single chain missing payload
In this case the payload for `N` was revealed late, the proposer of `N+1` built on top of the empty block for `N`. We call `get_ancestor` with `slot = N`.
```graphviz
digraph G {
rankdir=RL;
node [style=filled, fillcolor=lightblue];
D [label="N-1"]
A [label="N", color=red, style="filled,bold", fillcolor=orange];
B [label="N+1"];
C [label="N"]
B-> A;
C -> D;
B -> C [style=invis]
A -> D;
}
```
The second if statement does not hit
```python
if parent.slot > slot:
return get_ancestor(store, block.parent_root, slot)
```
since `parent.slot == slot = N`. The parent's block hash is not the current current block's (`N+1`) parent block hash. Since the parent's block hash is for the payload revealed in `N`, but the current block's parent hash is that payload in `N-1` (the last full block in this chain is `N-1`) hence the return is for `N` and `False` indicating that this chain builds on the empty node.
#### Missing slot
In this case the block for `N` was revealed late, the proposer of `N+1` built on top of the missing block for `N`, that is, it built on top of `N-1`. We call `get_ancestor` with `slot = N`.
```graphviz
digraph G {
rankdir=RL;
node [style=filled, fillcolor=lightblue];
D [label="N-1"]
A [label="N-1", color=red, style="bold,filled,dashed"];
B [label="N+1"];
C [label="N", fillcolor=orange]
B-> A;
C -> D;
B -> C [style=invis]
A -> D;
}
```
The second if statement does not hit
```python
if parent.slot > slot:
return get_ancestor(store, block.parent_root, slot)
```
since `parent.slot = N-1 < slot = N`.
The parent's block hash is the current current block's (`N+1`) parent block hash, since the parent's block hash is for the payload revealed in `N-1`, and the current block's parent hash is that payload in `N-1` (the last full block in this chain is `N-1`) hence the return is for `N-1` and `True` indicating that this chain builds on the missing node.
### Longer chain
The situation for longer chains is similar, and can be treated by induction since each block root as a single parent node, no matter if there are many nodes in forkchoice with a given block root. Consider for example the situation when the payload of `N` was revealed late, thus the builder of `N+1` built on top of the empty block `N`. We call `get_ancestor` with `slot = N-1` this time
```graphviz
digraph G {
rankdir=RL;
node [style=filled, fillcolor=lightblue];
D [label="N-1"]
A [label="N", color=red, style="filled,bold", fillcolor=orange];
B [label="N+1"];
C [label="N"]
B-> A;
C -> D;
B -> C [style=invis]
A -> D;
}
```
This time the `parent.slot = N > slot = N-1`, thus the second if statement does hit:
```python=
if parent.slot > slot:
return get_ancestor(store, block.parent_root, slot)
```
So we call again `get_ancestor` with `root=N` and `slot = N-1`. In this case it will get the parent root `N-1` regardless if `N+1` was based on empty or full and it will only return `True` in both cases, since both nodes for `N` are based on the full `N-1` block. Notice that we cannot have a situation where there are valid `True` and `False` notions to return unless there was an equivocation; that is, in the following diagram:
```graphviz
digraph G {
rankdir=RL;
node [style=filled, fillcolor=lightblue];
D [label="N-1"]
E [label="N-1" fillcolor=orange]
A [label="N", color=red, style="filled,bold", fillcolor=orange];
B [label="N+1"];
C [label="N"]
B-> A;
C -> E;
B -> C [style=invis]
A -> D;
}
```
There must have been equivocations for the consensus block of `N` since it has two different parent block hash even though its parent block is the same beacon block.
### Modified `get_checkpoint_block`
This is only modified because of the modification to `get_ancestor` that now returns a `ChildNode` object. Notice that we are disregarding the payload content of the beacon block of the checkpoint, that is we would consider both empty or full nodes as valid checkpoints. This does not alter significantly FFG since every ancestor for the two possible blocks is the same and only descendants is that they will not agree upon.
```python
def get_checkpoint_block(store: Store, root: Root, epoch: Epoch) -> Root:
"""
Compute the checkpoint block for epoch ``epoch`` in the chain of block ``root``
"""
epoch_first_slot = compute_start_slot_at_epoch(epoch)
return get_ancestor(store, root, epoch_first_slot).root
```
### `is_supporting_vote`
This function is a new helper that is used to check if an attestation supports an ancestor. Previously, the notion of ancestor was rather simple. The design landscape here is that we do not want to change the attestation type, attestations only attest to a beacon block root, at a given slot. Before, we would consider that attestation as supporting any ancestor of that beacon block root, we didn't even take into account the slot at which the attestation was cast. In addition to the slot, attestations do not have any information about payload presence, but nodes in forkchoice do! a given blockroot is based on either full or empty for its parent. This makes attestation weight counting rather complicated.
We need to account for many different cases since a vote from the committe a slot `N`, that votes for `N-1` needs to support the `N-1` as head during `N` **but need not support** `N` for head at `N`. It also needs to take into account the payload status of the node we are trying to compute the support. That is, an attestation for `N` that is based on `N-1` full, should not support `N-1` empty chains, but should support `N-1` full chains.
This function takes a `Childnode` for the node we want to support, where `node.slot` is the slot that the given node would be advanced to be computed as head, and the given attestation (sent as the `message` parameter) would support that head.
```python
def is_supporting_vote(store: Store, node: ChildNode, message: LatestMessage) -> bool:
if node.root == message.root:
return node.slot <= message.slot
message_block = store.blocks[message.root]
if node.slot >= message_block.slot:
return False
ancestor = get_ancestor(store, message.root, node.slot)
return (node.root == ancestor.root) and (node.is_payload_present == ancestor.is_payload_present)
```
Better to consider some concrete examples with the same coloring as above. In all these examples we will call `is_supporting_vote` for `root = N`
#### Direct vote
The easiest example is a vote for the given root `N` by an attester in a committee after the slot `N`. That is suppose that `message.slot = N` or higher, then the following hits
```python=
if node.root == message.root:
return node.slot <= message.slot
```
and the attestation supports the node. Notice that this is regardless of the payload content of the node. That is
```graphviz
digraph G {
rankdir=RL;
A[label="N", fillcolor=lightblue, style=filled]
B[label="N", fillcolor=orange, style=filled]
C[label="N", style="dashed,filled", fillcolor=lightblue]
D[label="N", style="dashed,filled", fillcolor=orange]
C -> A [style=dashed];
D -> B [style=dashed];
}
```
In both cases a message of the form `message.root = N`, and `message.slot = N+1`, will support both branches, that is, both `N` empty or full, for head at `N+1`.
:::warning
This special case needs to be considered when analyzing calls to `get_supporting_vote`.
:::
If `message.slot < node.slot` on the other hand, this is an attestation from an older committee, this attestation supported the block `N` for head only up to that point, but it hasn't attested for `N` being head during `node.slot`, therefore it is not a supporting vote.
### Higher committee than the block
If the vote is for a slot that is higher or equal than the beacon block root pointed by the message, then the attestation cannot be a supporting vote. This is due to the branch
```python=
if node.slot >= message_block.slot:
return False
```
The typical situation would be this
```graphviz
digraph G {
rankdir=RL;
node [style=filled, fillcolor=lightblue];
A [label="N"];
B [label="N+1"]
B->A
}
```
Here we have `message.root=N+1` and `node.slot = N+2`. That is the attestation (which must have been during at least the committee of `N+1`) is for the root `N+1` and it supports it as head, therefore it will not support `N` as head during slot `N+2` (because it supports `N+1` already). Notice that the payload status `node.is_payload_present` of `N` is completely irrelevant in this situation The fact that the attestation supported `N+1` for head means that it won't support `N` for head at the same slot. Also notice that even if the block was not in `N+1` but was earlier (an ancestor of `N`) or a contending branch from `N`, then in those cases also this attestation would not support `N` for head during `N+2`(or any slot higher than the message block)
:::info
This is a big change from the current status, in which the slot is not specified in `is_supporting_vote`.
:::
#### Equal slot as the block
We have yet to analyze the situation in which the `message.root != node.root` and `node.slot < message_block.slot`. Here the attestation for `message.root` will support it if and only if the block `message.root` (with any payload content), is a descendant of `N` with the payload content defined by `node.is_payload_present`. This has several subcases:
##### Not a consensus desendant
```graphviz
digraph G {
rankdir=RL;
node [style=filled, fillcolor=lightblue];
A [label="N-1"];
B [label="N"];
C [label="N+1"];
C-> A;
B-> A;
C-> B [style=invis];
}
```
An attestation for `N+1` does not support `N` for head at any slot. This is because `get_ancestor(N+1, slot)` will return never return `N` as root for any value of `slot`.
##### Wrong payload status
Consider the following diagram
```graphviz
digraph G {
rankdir=RL;
node [style=filled, fillcolor=lightblue];
A [label="N-1"];
B [label="N"];
E [label="N", fillcolor=orange]
C [label="N+1"];
B-> A;
C-> B;
E-> A
}
```
An attestation for `N+1` will not support `N` empty for any slot, this is because a call to `get_ancestor(N+1, slot)` will can only return `N, True` given that `N+1` is based on the full block `N`. So a call to `is_supporting_vote(N, slot, False)` will return `False` always.
Notice that when `slot = N` a call to `is_supporting_vote(N, N, True)` does return `True`, that is a vote for `N+1` above did support `N` as head during slot `N`.
##### Wrong slot
Let's consider the situation where there are some missing slots:
```graphviz
digraph G {
rankdir=RL;
node [style=filled, fillcolor=lightblue];
A [label="N-1"];
B [label="N"];
E [label="N", fillcolor=orange]
C [label="N+1"];
F [label="N", style=dashed]
B-> A;
C-> F;
F -> A;
E-> A
}
```
Here the proposer of `N+1` based his block on the skipped slot `N`, that is, the parent block is the full `N-1` block. Attestations for `N+1` do not support `N` since they are not descendant of the consensus block `N`, but they will support `N-1` full for slots `N-1` and slot `N` as head. It will not support `N-1` empty under no circumstances.
### New `compute_proposer_boost`
This is a helper to compute the proposer boost. It applies the proposer boost to any ancestor of the proposer boost root taking into account the payload presence. There is one exception: if the requested node has the same root and slot as the block with the proposer boost root, then the proposer boost is applied to both empty and full versions of the node.
```python
def compute_proposer_boost(store: Store, state: State, node: ChildNode) -> Gwei:
if store.proposer_boost_root == Root():
return Gwei(0)
ancestor = get_ancestor(store, store.proposer_boost_root, node.slot)
if ancestor.root != node.root:
return Gwei(0)
proposer_boost_slot = store.blocks[store.proposer_boost_root].slot
# Proposer boost is not applied after skipped slots
if node.slot > proposer_boost_slot:
return Gwei(0)
if (node.slot < proposer_boost_slot) and (ancestor.is_payload_present != node.is_payload_present):
return Gwei(0)
committee_weight = get_total_active_balance(state) // SLOTS_PER_EPOCH
return (committee_weight * PROPOSER_SCORE_BOOST) // 100
```
The typical application of this would be on a call to get the head of the chain right after importing an early block. In this case we would want to check if the boost applies to the current just synced node. The payload hasn't even been revealed yet so we need the boost to be applied to the consensus block no matter which payload status
Since `node.slot == store.blocks[store.proposer_boost_root].slot` (and both of them are equal to the current slot), the three `if` statements do not hit and the proposer boost is applied to the current head. If later when the payload is revealed but still when requesting for `node.slot` being the current slot, this function would not check for the payload status, and both forkchoice nodes, the current head with and without the payload, will be boosted.
All ancestors however do take into account the right payload status, this is because of the clause `...and (ancestor.is_payload_present != node.is_payload_present)`. That is, if the current early block is based on the empty parent node, the proposer boost will be applied to the empty parent, but not the full block with the same root as the parent root, as this is a contending block to it.
### New `compute_withhold_boost` `
This is a similar helper that applies for the withhold boost.
```python
def compute_withhold_boost(store: Store, state: State, node: ChildNode) -> Gwei:
if store.payload_withhold_boost_root == Root():
return Gwei(0)
ancestor = get_ancestor(store, store.payload_withold_boost_root, node.slot)
if ancestor.root != node.root:
return Gwei(0)
if node.slot >= store.blocks[store.payload_withhold_boost_root].slot:
ancestor.is_payload_present = store.payload_withhold_boost_full
if ancestor.is_payload_present != node.is_payload_present:
return Gwei(0)
committee_weight = get_total_active_balance(state) // SLOTS_PER_EPOCH
return (committee_weight * PAYLOAD_WITHHOLD_BOOST) // 100
```
In this case this always takes into account the reveal status. The reason being that the withhold boost is applied to the parent of the beacon block where the payload was withheld. And this parent has an already defined unique payload status.
:::info
This is the reason why we record `payload_withhold_boost_full` in the Store, so that we can recover this unique parent here.
:::
:::warning
There is a major caveat in this function: we want to apply the withhold boost to skipped slots, that is to consider the parent of the withheld block for head, as if the withheld block did not exist, that is a future slot after the parent's slot.
:::
This is the difference between the lines
```python
if node.slot > proposer_boost_slot:
return Gwei(0)
```
in `compute_proposer_boost` versus the lines
```python
if node.slot >= store.blocks[store.payload_withhold_boost_root].slot:
ancestor.is_payload_present = store.payload_withhold_boost_full
```
### New `compute_reveal_boost`
This is a similar helper to the last two, the only difference is that the reveal boost is only applied to the full version of the node when querying for the same slot as the revealed payload. Also, differently than the proposer boost, the reveal boost has to be applied on skipped slots while it lasts.
```python
def compute_reveal_boost(store: Store, state: State, node: ChildNode) -> Gwei:
if store.payload_reveal_boost_root == Root():
return Gwei(0)
ancestor = get_ancestor(store, store.payload_reveal_boost_root, node.slot)
if ancestor.root != node.root:
return Gwei(0)
if node.slot >= store.blocks[store.payload_reveal_boost_root].slot:
ancestor.is_payload_present = True
if is_ancestor_full != node.is_payload_present:
return Gwei(0)
committee_weight = get_total_active_balance(state) // SLOTS_PER_EPOCH
return (committee_weight * PAYLOAD_REVEAL_BOOST) // 100
```
### Modified `get_weight`
This is modified to only count votes for descending chains that support the status of a triple `Root, Slot, bool`, where the `bool` indicates if the block was full or not. `Slot` is needed for a correct implementation of `(Block, Slot)` voting.
We have seen above the minutiae of `is_supporting_vote` which is the gist of this function. It simply computes the weight of a node with consensus blockroot `root`, for consideration as head during slot `slot`, when the payload was present or not according to `is_payload_present`, but summing all the effective balances of supporting attestations for that triple. After that it adds to the root the proposer and builder's boosts.
```python
def get_weight(store: Store, node: ChildNode) -> Gwei:
state = store.checkpoint_states[store.justified_checkpoint]
unslashed_and_active_indices = [
i for i in get_active_validator_indices(state, get_current_epoch(state))
if not is_slashed_attester(state.validators[i])
]
attestation_score = Gwei(sum(
state.validators[i].effective_balance for i in unslashed_and_active_indices
if (i in store.latest_messages
and i not in store.equivocating_indices
and is_supporting_vote(store, node, store.latest_messages[i]))
))
# Compute boosts
proposer_score = compute_boost(store, state, node)
builder_reveal_score = compute_reveal_boost(store, state, node)
builder_withhold_score = compute_withhold_boost(store, state, node)
return attestation_score + proposer_score + builder_reveal_score + builder_withhold_score
```
### New `get_head_no_il`
This is a modified version of `get_head` to use the new `get_weight` function. It returns the Beacon block root of the head block and whether its payload is considered present or not. It disregards IL availability.
```python
def get_head_no_il(store: Store) -> ChildNode:
blocks = get_filtered_block_tree(store)
justified_root = store.justified_checkpoint.root
justified_block = store.blocks[justified_root]
justified_slot = justified_block.slot
justified_full = is_payload_present(store, justified_root)
best_child = ChildNode(root=head_root, slot=head_slot, is_payload_present=head_full)
```
This sets the initial variables for a while loop. We start looking for our head from the justified checkpoint's root. We consider the PTC vote for the justified checkpoint to determine the initial payload content.
:::warning
This may seem dangerous since it may well happen that the chain that justified this checkpoint and is the current canonical chain does not agree with the PTC vote, but this is not really a problem since we will probe for all descendants from the consensus block, regardless of payload status.
:::
```python
while True:
children = [
ChildNode(root=root, slot=block.slot, is_payload_present=present) for (root, block) in blocks.items()
if block.parent_root == best_child.root and
is_parent_node_full(store, block) == best_child.is_payload_present if root != store.justified_root
for present in (True, False) if root in store.execution_payload_states or present == False
]
```
We get all the triples `(root, slot, bool)` of consisting on the block roots, the slots and the payload status of all direct children of the current head candidate. Recall we start with the justified checkpoint so this guarantees that we will only consider descendants of the justified checkpoint for head. The children only care about the consensus blocks that descend from them. We add both versions with a present or an absent payload for any child CL block that we see, with the condition that we only add children with present payload if the payload was indeed imported.
:::info
There is a special casing for the justified checkpoint because we do not store the payload status of that justified checkpoint. So the first iteration of the loop will query for children of both the justified checkpoint empty and full. An alternative design is to consider the checkpoint with the payload status, but we felt that opens a can of worms in dealing with FFG considerations.
:::
```python
if len(children) == 0:
return best_child
```
If there aren't any children then we know that we have reached head, return the last computed couple of the head root and it's payload status.
```python
children += [ChildNode(root=best_child.root, slot=best_child.slot + 1, best_child.is_payload_present)]
```
If we have children we consider the current head advanced as a possible head.
:::warning
There is a pathological case in which the justified checkpoint has no descendant and we consider it for head with the PTC vote when perhaps the canonical chain will descend from a different payload status. This can happen for example for an optimistically syncing node that just found out that all his chain but the justified checkpoint is invalid.
:::
```python
new_best_child = max(children, key=lambda child: (get_weight(store, child.root, child.slot, child.is_payload_present), is_payload_present(store, child.root), child.is_payload_present, child.slot, child.root))
```
We compute the best child by getting the weight of each of them. We untie them by preferring the child that has a payload according to the PTC vote. Untie then by preferring the child that actually has a payload. We then untie by the higher slot and finally lexycographically by root
```python
if new_best_child.root == best_child.root:
return new_best_child
```
If the best child is the current head just advanced, then we know that ends the loop and we can just stop here and return the current head.
```python
best_child = new_best_child
```
Otherwise we update the head CL block and go back one step further. Let us analyze some of the examples encountered in the design notes
#### Happy case
```graphviz
digraph G {
rankdir=RL;
node [style=filled, fillcolor=lightblue];
A [label=<slot: N<Br/>weight:30<BR/>vote:10>];
B [label=<slot: N+1<br/>weight:20<br/>vote:10>];
C [label=<slot: N+2<br/>weight:10<br/>vote:10>];
C -> B;
B -> A;
}
```
In this case a vote of 10 was assigned at each slot to their corresponding block that was revealed full. At the start of the loop iteration, the first call for `best_child` will have the following weights for the three possible children
```graphviz
digraph G {
rankdir=RL;
node [style=filled, fillcolor=lightblue];
A [label="slot: N"];
B [label=<slot: N<br/>weight:0<br/>vote:0> style="dashed,filled"];
C [label=<slot: N+1<br/>weight:20<br/>vote:10>];
D [label=<slot: N+1<br/>weight:10<br/>vote:10> fillcolor=orange];
B -> A;
C -> A;
D -> A
}
```
The skipped node has no votes because we call `get_weight` with `slot=N+1` and at this stage, the votes from the committee at `N` would not count for `N` as head during `N+1`. The votes from the committee of `N+1`would count for both the empty and full `N+1` node as head during `N+1`. However the votes from the committee of `N+2` only support `N+1` full. That is why the full node has weight 20 at this step of the while loop. It is chosen as the best child.
The next step of the loop looks similar:
```graphviz
digraph G {
rankdir=RL;
node [style=filled, fillcolor=lightblue];
A [label="slot: N+1"];
B [label=<slot: N+1<br/>weight:0<br/>vote:0> style="dashed,filled"];
C [label=<slot: N+2<br/>weight:10<br/>vote:10>];
D [label=<slot: N+2<br/>weight:10<br/>vote:10> fillcolor=orange];
B -> A;
C -> A;
D -> A
}
```
The call this time is to `get_weight` with `slot=N+2`. The votes from the committee at `N` and `N+1` are completely ignored this time. This is why the skipped slot has zero weight. The votes from the committee at `N+2` will support both the full and the empty block so they are tied. We are ignoring any payload boosts at this time and also the PTC vote. They are untied by the payload being present in the full case, and thus the head will be the full node at `N+2`. In case we have payload reveal boosts or simply if the PTC committee had agreed on the payload for N+2 being present, we would chose the full block as head anyway.
#### Late block
The last block in the chain arrived late, thus the attesters for that slot voted for its parent.
```graphviz
digraph G {
rankdir=RL;
node [style=filled, fillcolor=lightblue];
A [label=<slot=N<br/>weight:30<BR/>vote:10>];
B [label=<slot=N+1<br/>weight:20<br/>vote:20>];
C [label=<slot=N+2<br/>weight:0<br/>vote:0>];
C -> B;
B -> A;
}
```
At the start of the loop the situation is almost exactly as above
```graphviz
digraph G {
rankdir=RL;
node [style=filled, fillcolor=lightblue];
A [label="slot: N"];
B [label=<slot: N<br/>weight:0<br/>vote:0> style="dashed,filled"];
C [label=<slot: N+1<br/>weight:20<br/>vote:20>];
D [label=<slot: N+1<br/>weight:20<br/>vote:20> fillcolor=orange];
B -> A;
C -> A;
D -> A
}
```
This time the weight for both the full and empty slot `N+1` are 20 because they count both the votes from the `N+1` and `N+2` committees that directly voted for the `N+1` beacon block root.
:::info
We could make this situation more robust by changing the attestation type in the CL to also mention the payload status in case the vote is for a previous slot's block. But this is a pretty invasive change with minimal benefits.
:::
The full block would be decided by the presence of the payload, regardless if the PTC had achieved consensus or not. The next iteration would look as follows:
```graphviz
digraph G {
rankdir=RL;
node [style=filled, fillcolor=lightblue];
A [label="slot: N+1"];
B [label=<slot: N+1<br/>weight:10<br/>vote:10> style="dashed,filled"];
C [label=<slot: N+2<br/>weight:0<br/>vote:0>];
D [label=<slot: N+2<br/>weight:0<br/>vote:0> fillcolor=orange];
B -> A;
C -> A;
D -> A
}
```
The skipped slot has now weight 10 because we call `get_weight` with `slot=N+2`. In this case the vote of the committee of `N+2` would count for it. And thus the head of the chain after the late block `N+2` arrives, remains being the full `N+1` block.
#### Attempted payload reorg
In this case the beacon block for `N+1` has a payload, the PTC has achieved threshold for `PAYLOAD_PRESENT` but the proposer of `N+2` has revealed a timely block reorging the payload.
```graphviz
digraph G {
rankdir=RL;
node [style=filled fillcolor=lightblue];
A [label = "N"]
B [label = "N+1"]
C [label = "N+1" fillcolor=orange]
D [label = "N+2"]
B -> A;
C -> A;
D -> C;
}
```
Let us analyze the honest behavior and full calls for `get_head_no_il` in this situation.
- During slot `N` the committee voted for `N`, the payload was present and becomes the head. We will change units in this example and by a vote of $1$ we mean the *full committee* voted for it.
- The PTC has agreed on `PAYLOAD_PRESENT`.
- During slot `N+1` the proposer reveals his block timely. Therefore at the attestation deadline, for a call to `get_head` with `slot = N+1`, assuming we start the loop at `N` honest validators will have:
```graphviz
digraph G {
rankdir = RL;
node [style=filled fillcolor=lightblue];
A [label=<slot: N>];
B [label=<slot: N<br/>weight: RB> style="dashed,filled"]
C [label=<slot: N+1<br/>weight RB + PB>, fillcolor=orange]
B -> A;
C -> A;
}
```
- The builder's reveal boost is applied to both the missed slot `N+1` and the empty slot `N+1` as they both descend from the reveal boost block `N`.
- There is no full `N+1` node yet since the payload has not been revealed. The committee for `N` voted for `N` during `N` but those votes do not count for the block as head at `N+1`. So the weight for `N` is only the `RB`.
- The proposer boost for `N+1` that was revealed early applies to `N+1` but it does not apply to `N` at slot `N+1` (recall it will apply for `N` at slot `N`)
Given the above discussion honest validators in the `N+1` committee would vote for `N+1`. If **they hadn't seen `N+1` timely** then honest validators would have attested for their only possible block which is `N`.
Let us suppose that a fraction $\beta$ of the committee of $N+1$ is malicious, a fraction $x$ of the honest validators in the committee saw `N+1` timely and therefore $1-x-\beta$ didn't.
- At the payload reveal time, the builder sees the following forkchoice diagram
```graphviz
digraph G {
rankdir = RL;
node [style=filled fillcolor=lightblue];
A [label=<slot: N>];
B [label=<slot: N<br/>weight: 1 - x - β> style="dashed,filled"]
C [label=<slot: N+1<br/>weight PB + x>, fillcolor=orange]
B -> A;
C -> A;
}
```
- Notice that the reveal boost is no longer applied after this time (see below in `on_payload_attestation_message`)
- The builder will disregard the payload boost at this time since attestations were already cast. We make therefore the following assumption $2x > 1 - \beta$. so that the builder decides to reveal his payload.
- The PTC being honest agrees on `PAYLOAD_PRESENT`
- The builder of `N+2` builds on top of the empty block regardless.
- The malicious validators reveal their attestations from committee `N+1` attesting for `N`, that is, as if the block hadn't existed.
There are no attestations yet during `N+2`. At the time of the attestation deadline honest validators will call `get_head_no_il` and to obtain head. The loop starts at `N` and this is the first iteration of the loop
```graphviz
digraph G {
rankdir=RL;
node [style=filled, fillcolor=lightblue];
A [label="slot: N"];
B [label=<slot: N<br/>weight: 1-x<br/>vote: 1-x> style="dashed,filled"];
C [label=<slot: N+1<br/>weight: x+RB<br/>vote: x>];
D [label=<slot: N+1<br/>weight: x + PB<br/>vote: x + PB> fillcolor=orange];
B -> A;
C -> A;
D -> A
}
```
There are many aspects that are exemplified in this diagram:
- The reveal boost is only applied to full `N+1` as seen in [compute_reveal_boost](#New-compute_reveal_boost)
- The proposer boost is only applied to empty `N+1` since the proposer of `N+2` based on it. It does not apply to `N` either since we are calling `get_weight` with `slot = N+1`. It would apply for `N` at slot `N`.
- The attacker has revealed attestations for `N` instead of `N-1`.
As long as $RB > PB$ the full `N+1` will beat the empty one. This is the case with the proposed values. Similarly, as long as
$$ RB > 1 - 2x $$
The full `N+1` block will beat the missed `N+1` slot. This is guaranteed if $RB > \beta$ by the above assumption. Thus, in this loop the best child will be full `N+1`.
The next iteration of the loop will stop early since full `N+1` has no children, hence `N+2` would not even be considered for head! and honest validators will vote for `N+1` as head during `N+2`.
:::info
It may look as though `N+2` never stood a chance, but the gist of the algorithm is that proposer boost was indeed counted towards the empty payload.
:::
#### Attempted full block reorg
In this situation the proposer of `N+1` and `N+2` are colluding to try to reorg the builder of `N+1`, this has more chances to succeed. The attack is very similar to the above example so this section will be more succinct. The attacker witholds attestations for `N+1` and reveals them for `N`. The proposer of `N+2` builds on the missing `N+1` slot, that is on `N`. At the attestation deadline for `N+2` honest validators call to get head. The loop starts now as follows
```graphviz
digraph G {
rankdir=RL;
node [style=filled, fillcolor=lightblue];
A [label="slot: N"];
B [label=<slot: N<br/>weight: 1-x<br/>vote: 1-x + PB> style="dashed,filled"];
C [label=<slot: N+1<br/>weight: x+RB<br/>vote: x>];
D [label=<slot: N+1<br/>weight: x<br/>vote: x> fillcolor=orange];
B -> A;
C -> A;
D -> A
}
```
The only difference is now that the proposer boost is applied to the missing slot instead of the empty slot, as only the missing slot is an ancestor of the boosted block `N+2`. In these conditions, if
$$ RB - PB > 1 - 2x $$
Then the full `N+1` slot will still win by weight, and this is guaranteed by the assumption that $RB - PB > \beta$. The next loop of the iteration again ends early because `N+1` full does not have any descendants.
#### Attempted builder's grief
In this section we analyze the loop for the attack in which the proposers of `N+1` and `N+2` attempt to trick the builder of `N+1` into withholding but still include the consensus block of `N+1` so that the builder has to pay the unconditional bid payment to the proposer of `N+1`.
The attack unfolds as follows
- The proposer of `N+1` reveals a block attempting a split $x - \beta$ sees their block early and $1 - x$ sees them late.
- Numbers are such that the builder withholds their payload
- The attacker then reveals $\beta$ attestations for `N+1`.
- The proposer of `N+2` builds on top of the empty `N+1` block.
Given the above conditions, at the of payload reveal, the builder sees the following diagram
```graphviz
digraph G {
rankdir = RL;
node [style=filled fillcolor=lightblue];
A [label=<slot: N>];
B [label=<slot: N<br/>weight: 1 - x> style="dashed,filled"]
C [label=<slot: N+1<br/>weight x - β>, fillcolor=orange]
B -> A;
C -> A;
}
```
Therefore he withholds if $2x - 1 < \beta$.
At the attestation deadline of `N+2` the loop starts as follows
```graphviz
digraph G {
rankdir=RL;
node [style=filled, fillcolor=lightblue];
A [label="slot: N"];
B [label=<slot: N<br/>weight: 1-x+WB<br/>vote: 1-x> style="dashed,filled"];
D [label=<slot: N+1<br/>weight: x+PB<br/>vote: x> fillcolor=orange];
B -> A;
D -> A
}
```
- There is no full `N+1` node now because the payload was withheld.
- Proposer boost is applied to the empty `N+1` node but not the skipped slot because `N+2` built on top of `N+1`.
- Withholding boost is only applied to the skipped slot because the empty `N+1` is not an ancestor of the `store.payload_withhold_boost_root` which is `N`.
So at this stage the skipped slot will win over the empty one if
$$ WB - PB > 2x - 1$$
And this is guaranteed by $WB - PB > \beta$. At this step the iteration immediately ends because the head is the same previous head
```python
if new_best_child.root == best_child.root:
return new_best_child
```
So we see in this case the iteration didn't even move to consider `N+1`, but still the proposer boost for `N+2` played its role.
### Modified `get_head`
This is a wrapper now over `get_head_no_il` that simply finds the first ancestor with an available inclusion list.
```python
def get_head(store: Store) -> ChildNode:
head = get_head_no_il(store)
while not store.inclusion_list_available(head.root):
head = get_ancestor(store, head.root, head.slot - 1)
return head
```
## Engine APIs
There isn't much with regard to inclusion lists API calls, these simply wrap a call into `engine_newInclusionListV1`
### New `NewInclusionListRequest`
```python
@dataclass
class NewInclusionListRequest(object):
inclusion_list: List[Transaction, MAX_TRANSACTIONS_PER_INCLUSION_LIST]
summary: List[ExecutionAddress, MAX_TRANSACTIONS_PER_INCLUSION_LIST]
parent_block_hash: Hash32
```
### New `notify_new_inclusion_list`
```python
def notify_new_inclusion_list(self: ExecutionEngine,
inclusion_list_request: NewInclusionListRequest) -> bool:
"""
Return ``True`` if and only if the transactions in the inclusion list can be succesfully executed
starting from the execution state corresponding to the `parent_block_hash` in the inclusion list
summary. The execution engine also checks that the total gas limit is less or equal that
```MAX_GAS_PER_INCLUSION_LIST``, and the transactions in the list of transactions correspond to the signed summary
"""
...
```
## `on_block`
The handler `on_block` is modified to consider the pre `state` of the given consensus beacon block depending not only on the parent block root, but also on the parent blockhash. In addition we delay the checking of blob data availability until the processing of the execution payload.
```python
def on_block(store: Store, signed_block: SignedBeaconBlock) -> None:
block = signed_block.message
# Parent block must be known
assert block.parent_root in store.block_states
# Check if this blocks builds on empty or full parent block
parent_block = store.blocks[block.parent_root]
header = block.body.signed_execution_payload_header.message
parent_header = parent_block.body.signed_execution_payload_header.message
# Make a copy of the state to avoid mutability issues
if is_parent_node_full(store, block):
assert block.parent_root in store.execution_payload_states
state = copy(store.execution_payload_states[block.parent_root])
else:
assert header.parent_block_hash == parent_header.parent_block_hash
state = copy(store.block_states[block.parent_root])
```
After this `state` points to the right pre-state, either the one obtained after syncing the parent execution payload (if the parent block was full) or the previous consensus block (if the parent was empty).
:::info
We also check here that the current header commits to building a payload who's parent is the right one in the case of an empty parent.
:::
The rest of block processing goes unchanged until:
```python
store.block_states[block_root] = state
# Add a new PTC voting for this block to the store
store.ptc_vote[block_root] = [PAYLOAD_ABSENT]*PTC_SIZE
```
Thus any newly imported block starts with a PTC vote for the absent payload.
```python
if not is_parent_node_full(store, block):
store.inclusion_list_available = True
```
If the parent block is empty record that the inclusion list for this block has been satisfied.
```python
store.notify_ptc_messages(state, block.body.payload_attestations)
```
We process the payload attestations in the block.
## `on_execution_payload`
This is simply a new handler that is called when receiving a new full execution payload envelope from the builder.
```python
def on_execution_payload(store: Store, signed_envelope: SignedExecutionPayloadEnvelope) -> None:
envelope = signed_envelope.message
# The corresponding beacon block root needs to be known
assert envelope.beacon_block_root in store.block_states
# Check if blob data is available
# If not, this payload MAY be queued and subsequently considered when blob data becomes available
assert is_data_available(envelope.beacon_block_root, envelope.blob_kzg_commitments)
# Make a copy of the state to avoid mutability issues
state = copy(store.block_states[envelope.beacon_block_root])
# Process the execution payload
process_execution_payload(state, signed_envelope, EXECUTION_ENGINE)
# Add new state for this payload to the store
store.execution_payload_states[envelope.beacon_block_root] = state
```
The only new addition is that we add the post-state to the store.
## Payload boosts
### `seconds_into_slot`
This simply returns how many seconds have passed into the slot
```python
def seconds_into_slot(store: Store) -> uint64:
return (store.time - store.genesis_time) % SECONDS_PER_SLOT
```
### Modified `on_tick_per_slot`
Modified to reset the payload boost roots. The payload boost root is reset at the attestation deadline.
:::warning
Perhaps we want to reset it at the payload reveal deadline.
:::
```python
def on_tick_per_slot(store: Store, time: uint64) -> None:
previous_slot = get_current_slot(store)
# Update store time
store.time = time
current_slot = get_current_slot(store)
# If this is a new slot, reset store.proposer_boost_root
if current_slot > previous_slot:
store.proposer_boost_root = Root()
else:
# Reset the payload boost if this is the attestation time
if seconds_into_slot(store) >= SECONDS_PER_SLOT // INTERVALS_PER_SLOT:
store.payload_withhold_boost_root = Root()
store.payload_withhold_boost_full = False
store.payload_reveal_boost_root = Root()
# If a new epoch, pull-up justification and finalization from previous epoch
if current_slot > previous_slot and compute_slots_since_epoch_start(current_slot) == 0:
update_checkpoints(store, store.unrealized_justified_checkpoint, store.unrealized_finalized_checkpoint)
```
## `on_payload_attestation_message`
This is the main handler analogous to `on_attestation` it performs all the checks necessary to process a payload attestation.
```python
def on_payload_attestation_message(store: Store,
ptc_message: PayloadAttestationMessage, is_from_block: bool=False) -> None:
"""
Run ``on_payload_attestation_message`` upon receiving a new ``ptc_message`` directly on the wire.
"""
# The beacon block root must be known
data = ptc_message.data
# PTC attestation must be for a known block. If block is unknown, delay consideration until the block is found
state = store.block_states[data.beacon_block_root]
ptc = get_ptc(state, data.slot)
# PTC votes can only change the vote for their assigned beacon block, return early otherwise
if data.slot != state.slot:
return
# Check that the attester is from the PTC
assert ptc_message.validator_index in ptc
```
We get first check that the payload attestation corresponds to the slot of the voted blockroot. We do not consider payload attestations for past blocks. We also check that the voter was indeed a PTC member.
```python
# Verify the signature and check that its for the current slot if it is coming from the wire
if not is_from_block:
# Check that the attestation is for the current slot
assert data.slot == get_current_slot(store)
# Verify the signature
assert is_valid_indexed_payload_attestation(state,
IndexedPayloadAttestation(attesting_indices = [ptc_message.validator_index], data = data,
signature = ptc_message.signature))
```
If the attestation is not from a block check the attester's signature. Also we impose a strong condition: we only process attestations over the wire if they are for the current slot.
```python
# Update the ptc vote for the block
ptc_index = ptc.index(ptc_message.validator_index)
ptc_vote = store.ptc_vote[data.beacon_block_root]
ptc_vote[ptc_index] = data.payload_status
```
We then update the PTC vote for the given block root.
```
if is_from_block && data.slot + 1 != get_current_slot(store):
return
```
For attestations that are are over the wire we only consider current slot ones. For block attestations, we only allow them to be from the previous slot, that is, we may allow the proposer to assert it's view of the PTC vote from the previous slot.
```python
time_into_slot = (store.time - store.genesis_time) % SECONDS_PER_SLOT
if is_from_block and time_into_slot >= SECONDS_PER_SLOT // INTERVALS_PER_SLOT:
return
```
If the block is late return early cause anyway any payload boost from the previous slot should have been reset by now.
```python
# Update the payload boosts if threshold has been achieved
if ptc_vote.count(PAYLOAD_PRESENT) > PAYLOAD_TIMELY_THRESHOLD:
store.payload_reveal_boost_root = data.beacon_block_root
if ptc_vote.count(PAYLOAD_WITHHELD) > PAYLOAD_TIMELY_THRESHOLD:
block = store.blocks[data.beacon_block_root]
store.payload_withhold_boost_root = block.parent_root
store.payload_withhold_boost_full = is_parent_node_full(block)
```
We finally update the payload boosts if we achieved threshold with these attestations.