Skip to main content

ethrex_blockchain/
fork_choice.rs

1use ethrex_common::{
2    H256,
3    types::{BlockHash, BlockHeader, BlockNumber},
4};
5use ethrex_metrics::metrics;
6use ethrex_storage::{Store, error::StoreError};
7use tracing::{error, warn};
8
9use crate::{
10    error::{self, InvalidForkChoice},
11    is_canonical,
12};
13
14/// Maximum number of canonical blocks ethrex can revert in a single forkchoice update.
15///
16/// This is an implementation cap, not a spec policy. ethrex's state-history retention
17/// keeps the last ~128 blocks of state diffs, so reorgs deeper than this cannot be
18/// undone regardless of finalization status — the data simply isn't there.
19///
20/// The spec (execution-apis PR 786, "engine: Restrict no-reorg to the prefix of known
21/// finalized") only forbids reorging past the finalized prefix. The finalized check is
22/// applied first; this cap is a secondary guard for the implementation limit.
23///
24/// Reference values across ELs (devnet branches, 2026-04-30):
25/// - besu (main): 90_000 — effectively unlimited
26/// - erigon (glamsterdam-devnet-0): 96, env-configurable via `MAX_REORG_DEPTH`
27/// - geth / nethermind / reth: no engine-API rejection; trust the CL's fork choice
28pub const REORG_DEPTH_LIMIT: u64 = 128;
29
30/// Applies new fork choice data to the current blockchain. It performs validity checks:
31/// - The finalized, safe and head hashes must correspond to already saved blocks.
32/// - The saved blocks should be in the correct order (finalized <= safe <= head).
33/// - They must be connected.
34///
35/// After the validity checks, the canonical chain is updated so that all head's ancestors
36/// and itself are made canonical.
37///
38/// If the fork choice state is applied correctly, the head block header is returned.
39pub async fn apply_fork_choice(
40    store: &Store,
41    head_hash: H256,
42    safe_hash: H256,
43    finalized_hash: H256,
44) -> Result<BlockHeader, InvalidForkChoice> {
45    if head_hash.is_zero() {
46        return Err(InvalidForkChoice::InvalidHeadHash);
47    }
48
49    let finalized_res = if !finalized_hash.is_zero() {
50        store.get_block_header_by_hash(finalized_hash)?
51    } else {
52        None
53    };
54
55    let safe_res = if !safe_hash.is_zero() {
56        store.get_block_header_by_hash(safe_hash)?
57    } else {
58        None
59    };
60
61    let head_res = store.get_block_header_by_hash(head_hash)?;
62
63    if !safe_hash.is_zero() {
64        check_order(&safe_res, &head_res)?;
65    }
66
67    if !finalized_hash.is_zero() && !safe_hash.is_zero() {
68        check_order(&finalized_res, &safe_res)?;
69    }
70
71    let Some(head) = head_res else {
72        return Err(InvalidForkChoice::Syncing);
73    };
74
75    let latest = store.get_latest_block_number().await?;
76    let head_is_canonical = is_canonical(store, head.number, head_hash).await?;
77
78    // execution-apis PR 786: the no-reorg skip is only allowed when there is a known
79    // finalized block and the head is at or below it on the canonical chain. Skipping for
80    // unfinalized canonical ancestors is no longer permitted - those must trigger a reorg.
81    //
82    // `head.number < latest` is the strict-ancestor check; equality (head IS the current
83    // canonical head) falls through to normal FCU so the CL can still build a payload on
84    // top, mirroring geth's `head == current_head` carve-out in `eth/catalyst/api.go`.
85    if let Some(stored_finalized) = store.get_finalized_block_number().await?
86        && head.number < latest
87        && head.number <= stored_finalized
88        && head_is_canonical
89    {
90        return Err(InvalidForkChoice::NewHeadAlreadyCanonical);
91    }
92
93    // Find blocks that will be part of the new canonical chain.
94    let Some(new_canonical_blocks) = find_link_with_canonical_chain(store, &head).await? else {
95        return Err(InvalidForkChoice::UnlinkedHead);
96    };
97
98    let (link_block_number, link_block_hash) = match new_canonical_blocks.last() {
99        Some((number, hash)) => (*number, *hash),
100        None => (head.number, head_hash),
101    };
102
103    // Check that finalized and safe blocks are part of the new canonical chain.
104    if let Some(ref finalized) = finalized_res
105        && !((is_canonical(store, finalized.number, finalized_hash).await?
106            && finalized.number <= link_block_number)
107            || (finalized.number == head.number && finalized_hash == head_hash)
108            || new_canonical_blocks.contains(&(finalized.number, finalized_hash)))
109    {
110        return Err(InvalidForkChoice::Disconnected(
111            error::ForkChoiceElement::Head,
112            error::ForkChoiceElement::Finalized,
113        ));
114    }
115
116    if let Some(ref safe) = safe_res
117        && !((is_canonical(store, safe.number, safe_hash).await?
118            && safe.number <= link_block_number)
119            || (safe.number == head.number && safe_hash == head_hash)
120            || new_canonical_blocks.contains(&(safe.number, safe_hash)))
121    {
122        return Err(InvalidForkChoice::Disconnected(
123            error::ForkChoiceElement::Head,
124            error::ForkChoiceElement::Safe,
125        ));
126    }
127
128    // execution-apis PR 786 point 6: -38006 TooDeepReorg is returned when the reorg
129    // depth exceeds the limitation specific to the client software. ethrex's limit
130    // is its state-history retention: we keep the last REORG_DEPTH_LIMIT blocks of
131    // state diffs, so reorgs deeper than that cannot be unwound. We do not reject
132    // reorgs that would cross the finalized prefix — the spec's only requirement on
133    // finalized is point 2 (skip-when-ancestor-of-finalized, handled above) and
134    // point 5 (-38002 for disconnected safe/finalized). The CL is authoritative on
135    // fork choice and an EL must honor what the CL sends if it physically can.
136    //
137    // The shared canonical ancestor is `head` itself when head is canonical (the
138    // FCU truncates the canonical chain), or one below the lowest sidechain block
139    // in `new_canonical_blocks` otherwise.
140    let canonical_link_height = if head_is_canonical {
141        head.number
142    } else {
143        new_canonical_blocks
144            .last()
145            .map(|(n, _)| *n)
146            .unwrap_or(head.number)
147            .saturating_sub(1)
148    };
149    let reorg_depth = latest.saturating_sub(canonical_link_height);
150    if reorg_depth > REORG_DEPTH_LIMIT {
151        return Err(InvalidForkChoice::TooDeepReorg {
152            reorg_depth,
153            limit: REORG_DEPTH_LIMIT,
154        });
155    }
156
157    let Some(link_header) = store.get_block_header_by_hash(link_block_hash)? else {
158        // Probably unreachable, but we return this error just in case.
159        error!("Link block not found although it was just retrieved from the DB");
160        return Err(InvalidForkChoice::UnlinkedHead);
161    };
162
163    // If the state can't be constructed from the DB, the caller starts a sync
164    // toward the head instead of ignoring the FCU.
165    // TODO(#5564): handle arbitrary reorgs
166    if !store.has_state_root(link_header.state_root)? {
167        warn!(
168            link_block=%link_block_hash,
169            link_number=%link_header.number,
170            head_number=%head.number,
171            "FCU head state not reachable from DB state. Starting sync toward head. This is expected if the consensus client is currently syncing."
172        );
173        return Err(InvalidForkChoice::StateNotReachable);
174    }
175
176    // Finished all validations.
177
178    store
179        .forkchoice_update(
180            new_canonical_blocks,
181            head.number,
182            head_hash,
183            safe_res.map(|h| h.number),
184            finalized_res.map(|h| h.number),
185        )
186        .await?;
187
188    metrics!(
189        use ethrex_metrics::blocks::METRICS_BLOCKS;
190
191        METRICS_BLOCKS.set_head_height(head.number);
192    );
193
194    Ok(head)
195}
196
197// Checks that block 1 is prior to block 2 and that if the second is present, the first one is too.
198fn check_order(
199    block_1: &Option<BlockHeader>,
200    block_2: &Option<BlockHeader>,
201) -> Result<(), InvalidForkChoice> {
202    // We don't need to perform the check if the hashes are null
203    match (block_1, block_2) {
204        (None, Some(_)) => Err(InvalidForkChoice::ElementNotFound(
205            error::ForkChoiceElement::Finalized,
206        )),
207        (Some(b1), Some(b2)) => {
208            if b1.number > b2.number {
209                Err(InvalidForkChoice::Unordered)
210            } else {
211                Ok(())
212            }
213        }
214        _ => Err(InvalidForkChoice::Syncing),
215    }
216}
217
218// Find branch of the blockchain connecting a block with the canonical chain. Returns the
219// number-hash pairs representing all blocks in that brunch. If genesis is reached and the link
220// hasn't been found, an error is returned.
221//
222// Return values:
223// - Err(StoreError): a db-related error happened.
224// - Ok(None): The block is not connected to the canonical chain.
225// - Ok(Some([])): the block is already canonical.
226// - Ok(Some(branch)): the "branch" is a sequence of blocks that connects the ancestor and the
227//   descendant.
228async fn find_link_with_canonical_chain(
229    store: &Store,
230    block_header: &BlockHeader,
231) -> Result<Option<Vec<(BlockNumber, BlockHash)>>, StoreError> {
232    let mut block_number = block_header.number;
233    let block_hash = block_header.hash();
234    let mut branch = Vec::new();
235
236    if is_canonical(store, block_number, block_hash).await? {
237        return Ok(Some(branch));
238    }
239
240    let genesis_number = store.get_earliest_block_number().await?;
241    let mut header = block_header.clone();
242
243    while block_number > genesis_number {
244        block_number -= 1;
245        let parent_hash = header.parent_hash;
246
247        // Check that the parent exists.
248        let parent_header = match store.get_block_header_by_hash(parent_hash) {
249            Ok(Some(header)) => header,
250            Ok(None) => return Ok(None),
251            Err(error) => return Err(error),
252        };
253
254        if is_canonical(store, block_number, parent_hash).await? {
255            return Ok(Some(branch));
256        } else {
257            branch.push((block_number, parent_hash));
258        }
259
260        header = parent_header;
261    }
262
263    Ok(None)
264}