From 5eba88799700ac9d648927a6a6a73ec9fd307e02 Mon Sep 17 00:00:00 2001 From: Barnabas Busa Date: Tue, 21 Apr 2026 17:10:39 +0200 Subject: [PATCH] fix(web): flag the serving checkpoint by block root instead of epoch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The historical-checkpoints table flags the row currently being served by the checkpointz instance. It did this by comparing each row's epoch against `status.data.finality.finalized.epoch`. Two issues: 1. The slots list is built from `d.head` (majority-decided finality), but the status's `finality.finalized` is `d.servingBundle` (the last fully-downloaded bundle). When bundle downloads lag — e.g. after an upstream blip or on short-lived devnets — `d.head` advances while `d.servingBundle` stays behind, so the slots list contains rows for epochs that don't yet have a serving bundle. The flag then either appears on a non-top row with no explanation or disappears entirely until the bundle catches up. 2. Epoch-only comparison is ambiguous in principle (two different roots can share the same finalized epoch across restarts / reorgs, however rare post-finality). Matching by block root is the semantically correct signal: "which row's bundle is the one we're serving". Prefer matching by `slot.block_root === status.finality.finalized.root` and fall back to the old epoch-based comparison for rows whose block_root hasn't been populated yet. --- web/src/parts/checkpoints/Checkpoints.tsx | 4 +++ .../parts/checkpoints/CheckpointsTable.tsx | 26 +++++++++++-------- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/web/src/parts/checkpoints/Checkpoints.tsx b/web/src/parts/checkpoints/Checkpoints.tsx index 9da8e60e..8967821c 100644 --- a/web/src/parts/checkpoints/Checkpoints.tsx +++ b/web/src/parts/checkpoints/Checkpoints.tsx @@ -26,6 +26,9 @@ export default function Checkpoints() { if (!finalizedEpoch) return; return finalizedEpoch; }, [statusData]); + const latestRoot = useMemo(() => { + return statusData?.data?.finality?.finalized?.root; + }, [statusData]); if (isLoading) return ( @@ -48,6 +51,7 @@ export default function Checkpoints() { diff --git a/web/src/parts/checkpoints/CheckpointsTable.tsx b/web/src/parts/checkpoints/CheckpointsTable.tsx index f734815b..27d43301 100644 --- a/web/src/parts/checkpoints/CheckpointsTable.tsx +++ b/web/src/parts/checkpoints/CheckpointsTable.tsx @@ -11,6 +11,7 @@ import { truncateHash } from '@utils'; export default function CheckpointsTable(props: { latestEpoch?: string; + latestRoot?: string; slots: APIBeaconSlot[]; onSlotClick?: (slot: APIBeaconSlot) => void; showCheckpoint?: boolean; @@ -27,6 +28,12 @@ export default function CheckpointsTable(props: { ); }); }, [props.slots, search]); + const isServing = (slot: APIBeaconSlot) => { + if (props.latestRoot && slot.block_root) { + return slot.block_root === props.latestRoot; + } + return Boolean(props.latestEpoch && slot.epoch && slot.epoch === props.latestEpoch); + }; const onClick = (slot: APIBeaconSlot) => { props.onSlotClick?.(slot); }; @@ -112,20 +119,17 @@ export default function CheckpointsTable(props: { {slot.epoch} - {props.showCheckpoint && - props.latestEpoch && - slot.epoch && - slot.epoch === props.latestEpoch && ( - Latest checkpoint - )} + {props.showCheckpoint && isServing(slot) && ( + Latest checkpoint + )} {slot.slot} - {props.latestEpoch && slot.epoch && slot.epoch === props.latestEpoch && ( + {props.showCheckpoint && isServing(slot) && (