# Unrealized Justification Reorgs Thanks to Paul, Potuz, Danny [toc] ## Background ### `Store` updates The fork choice tracks the states of the beacon chain using the [`Store`](https://github.com/ethereum/consensus-specs/blob/9f643d8dbf678430bc6707f0e5b914d0bf62545c/specs/phase0/fork-choice.md#store). The Store is updated on every clock tick, block, & attestation: - [`on_block`](https://github.com/ethereum/consensus-specs/blob/9f643d8dbf678430bc6707f0e5b914d0bf62545c/specs/phase0/fork-choice.md#on_block) processes the new block and saves the corresponding post-state & just./fin. updates in the `Store`. - [`on_tick`](https://github.com/ethereum/consensus-specs/blob/9f643d8dbf678430bc6707f0e5b914d0bf62545c/specs/phase0/fork-choice.md#on_tick) makes time- and cache-related updates to the `Store`, but does not process or save state updates. ### Block tree filtering The fork choice filters the block tree to [include only those leaves that satisfy](https://github.com/ethereum/consensus-specs/blame/9f643d8dbf678430bc6707f0e5b914d0bf62545c/specs/phase0/fork-choice.md#L223-L226): ```python correct_justified = ( store.justified_checkpoint.epoch == GENESIS_EPOCH or head_state.current_justified_checkpoint == store.justified_checkpoint ) ``` ### Unrealized justification An unrealized jutification is found when a block includes enough attestations in its chain to justify a newer checkpoint as compared to the one in its post-state, because we haven't yet processed FFG votes. FFG votes are processed only at the epoch boundary. ### Validator attestation behavior Validators are assigned attestation duties for specific slots. When making an attestation for a skipped epoch boundary slot, the validator is expected to use the [following `head_block` and `head_state`](https://github.com/ethereum/consensus-specs/blob/9f643d8dbf678430bc6707f0e5b914d0bf62545c/specs/phase0/validator.md#attestation-data): > - Let `head_block` be the result of running the fork choice during the assigned slot. > - Let `head_state` be the state of `head_block` processed through any empty slots up to the assigned slot using `process_slots(state, slot)`. For a skipped epoch boundary slot, such a `head_state` will not be found in any actual block's post state. **Note**: The processed `head_state` for skipped slots is not saved in the `Store`. The current spec defines the general behavior in the validator guide but expects clients to process & save these states using out-of-spec objects. --- ## Problem ### Reorg Attack ![](https://storage.googleapis.com/ethereum-hackmd/upload_66ebfee3db04474eae0b0c31cbb1501e.png) #### Chronology - Epoch 9 progresses normally: - $C_9$ is the checkpoint block at Epoch 9. - Enough attestations are included in the chain to justify $C_9$ by the end of the epoch. - $C_{10}$ is the first block in Epoch 10. - Since $C_{10}$ is an epoch boundary block, the FFG votes from Epoch 9 are processed, so $J_{C_{10}} = {C_9}$ - $X$ is the first block in Epoch 10 that includes enough attestations to justify $C_{10}$, so $U_X = C_{10}$. - $Y$ is the last block in Epoch 10. There are no forks in Epoch 10, so $Y$ is clearly the fork choice winner at the end of Epoch 10. - The attacker controls the first slot of Epoch 11 and proposes $Z$. - $Z$ is a descendant of $X$ that competes with $Y$. - Since $Z$ is the first block of Epoch 11, FFG votes from Epoch 10 are counted to update justified & finalized information in the post-state. $J_Z = C_{10}$. #### Reorg After block $Z$ is received, `store.justified_checkpoint` is set to $C_{10}$. Now, the fork choice filters out all the leaves that do not see $C_{10}$ as justified in their post-state, which forks out $Y$ as a viable head. Even though $Y$ was the fork choice winner at the end of Epoch 10, and $Z$ does not have *any* LMD support, $Z$ is the fork choice winner after its slot. Note that $Z$ could just as easily reorg a chain of length 9 instead of only block $Y$ ($X$ has to be the 23rd block for the unrealized justification). ### Deadlock ![](https://storage.googleapis.com/ethereum-hackmd/upload_e8ef15417824651b8023cb349470b893.png) #### Chronology - Epoch 8 progresses as usual. $C_8$ is the checkpoint block at Epoch 8. - $C_9$ is the checkpoint block at Epoch 9. - $J_{C_9} = C_8$ - During Epoch 9, enough attestations to justify $C_9$ are not included on chain. - $C_{10}$ is the checkpoint block at Epoch 10. - $C_{10}$ is the first block to include enough attestations to justify $C_9$, so $U_{C_{10}} = C_9$. - $X$ is the last block at Epoch 10, and includes enough attestations to justify $C_{10}$, so $U_X = C_{10}$ - The attacker controls the first slot in Epoch 11 - The attacker proposes $Z$ late in the slot, so attesters in that committee vote for "pulled-up" block $X$ - After $Z$ is received by nodes, a reorg similar to the [previous example](https://notes.ethereum.org/SX_8wRoUQkuyrw8Ye4RtPw#Reorg-Attack) is executed - In Epoch 11, enough attestations to justify anything newer than $C_9$ are not included on chain. So, the justified block in $Z$'s chain remains $C_9$. #### Deadlock The first committee in Epoch 11 makes FFG vote $A$. When those attesters vote again in Epoch 12, their fork choice instructs them to make FFG vote $B$, which is clearly a surround vote. --- ## Solutions Proposed solutions are: - **Unrealized Justification Filtering (UJF)**: Filtering based on unrealized justification in the chain, instead of post-state based filtering - Early UJF: Apply UJF as soon as a new unrealized justification is found - On-time UJF: Apply UJF for the epoch only at the end - **Non-highest Justified Filtering (NJF)**: Instead of checking that a leaf's post-state equals the store's highest justified checkpoint, use a different operator than equality - Justified epoch or previous: Justified in leaf's post-state should be either in same or previous epoch as store's justified - Compatible: Justified in leaf's post-state should be in the chain of store's justified ### Unrealized Justification Filtering (UJF) Change the [filteration condition](https://github.com/ethereum/consensus-specs/blame/9f643d8dbf678430bc6707f0e5b914d0bf62545c/specs/phase0/fork-hoice.md#L223-L226) as follows: - `store` keeps track of best unrealized justified checkpoint known yet - Allow the block tree leaves whose unrealized justified checkpoint is the same as the store's unrealized justified checkpoint With this fix in the [reorg example](https://notes.ethereum.org/SX_8wRoUQkuyrw8Ye4RtPw#Reorg-Attack): - `store`'s unrealized justified checkpoint is $C_{10}$ - $U_Y = U_Z = C_{10}$ - $Y$ is not filtered out, and LMD competition between $Y$ & $Z$ is allowed #### Early UJF Update the `store`'s unrealized justified checkpoint upon each block. The following reorg is possible: ![](https://storage.googleapis.com/ethereum-hackmd/upload_cdfabee420975062314481e0cbbdd6c8.png) - Epoch 9 progresses as usual - $C_{10}$ is the checkpoint block of Epoch 10 - $J_{C_{10}} = C_9$ - $X$ contains *almost* enough attestations in its chain to justify $C_{10}$. - $Y$ is a descendant of $X$ that does not include enough attestations to justify $C_{10}$. - $Z$ is a descendant of $X$ competing with $Y$ that includes enough attestations to justify $C_{10}$, i.e., $U_Z = C_{10}$. $Y$ will be filtered out irrespective of LMD support because it does not have the best possible unrealized justification according to the `store`. #### On-time UJF Update the `store`'s unrealized justified checkpoint only at epoch boundary clock ticks. This prevents the reorg in the [previous example](https://notes.ethereum.org/SX_8wRoUQkuyrw8Ye4RtPw#Early-UJF), but still allows for the following similar reorg (block tree structure & attestations exactly as before): - $Y$'s chain is not filtered out in Epoch 10, and competition is allowed between $Y$ & $Z$ - At the epoch boundary, $Y$'s chain is filtered out if it does not include enough attestations to justify $C_{10}$. This makes such reorg attacks harder, because the honest chain ($Y$'s chain) is given time till the end of the epoch to include new attestations. ### Non-highest Justified Filtering (NJF) Potuz write-up: https://hackmd.io/uDF5kmzQQZmono9PtTaSig?view #### Justified epoch or previous NJF Change the [filteration condition](https://github.com/ethereum/consensus-specs/blame/9f643d8dbf678430bc6707f0e5b914d0bf62545c/specs/phase0/fork-choice.md#L223-L226) to: ```python correct_justified = ( store.justified_checkpoint.epoch == GENESIS_EPOCH or head_state.current_justified_checkpoint.epoch >= store.justified_checkpoint.epoch - 1 ) ``` #### Compatible NJF Change the [filteration condition](https://github.com/ethereum/consensus-specs/blame/9f643d8dbf678430bc6707f0e5b914d0bf62545c/specs/phase0/fork-choice.md#L223-L226) to allow leaves whose `head_state.current_justified_checkpoint` is in the same chain as `store.justified_checkpoint` #### Opinion: (solely) NJF is dangerous Implementing solely NJF allows for a deadlock scenario if a reorg from a chain with a higher justified checkpoint to one with a lower justified checkpoint is executed. This is dangerous because the source epoch of FFG votes will move backwards, while the target epoch can move forward - which is the surround vote condition. Open question: Is it possible to have NJF + additional conditions to prevent source moving backward?