|
| 1 | +# Reorg Tracking |
| 2 | + |
| 3 | +This document describes how `apps/sequencer/src/providers/reorg_tracking.rs` monitors |
| 4 | +canonical chain progress, detects reorganizations, and recovers the Sequencer’s |
| 5 | +state. |
| 6 | + |
| 7 | +## Tracker State and Inputs |
| 8 | + |
| 9 | +- `ReorgTracker` keeps per-network state: the most recent finalized height, |
| 10 | + the highest height we have observed locally, an iteration counter, and the |
| 11 | + RPC timeout derived from `ReorgConfig`. |
| 12 | +- Access to providers is shared via `SharedRpcProviders`, where each |
| 13 | + `RpcProvider` owns the HTTP transport, optional websocket transport, and an |
| 14 | + `InflightObservations` cache (`observed_block_hashes` plus |
| 15 | + `non_finalized_updates`). |
| 16 | +- Reintroduced batches are sent through a `CountedSender<BatchOfUpdatesToProcess>` |
| 17 | + so the relayer loop can replay messages that were lost on a fork. |
| 18 | +- When websocket support is configured (`Provider.websocket_url` with optional |
| 19 | + `WebsocketReconnectConfig`) the tracker builds a `ResilientWsConnect` to |
| 20 | + subscribe to `eth_subscribe:newHeads`. |
| 21 | + |
| 22 | +## Control Loop (`loop_tracking_for_reorg_in_network`) |
| 23 | + |
| 24 | +1. **Transport setup** – If a websocket URL is present we attempt to connect |
| 25 | + and subscribe to `newHeads`. The handshake and subsequent reconnects use the |
| 26 | + `WsReconnectPolicy` backoff helpers (`should_attempt_ws`, `schedule_ws_retry`, |
| 27 | + `reset_ws_backoff`). On failure the tracker logs the reason, schedules a |
| 28 | + retry, and temporarily falls back to HTTP polling. |
| 29 | +2. **Polling cadence** – For pure HTTP operation we call |
| 30 | + `calculate_block_generation_time_in_network` (look back 100 blocks) to |
| 31 | + estimate the poll interval. All block reads are wrapped in |
| 32 | + `actix_web::rt::time::timeout`; warnings are emitted when a 5‑second deadline |
| 33 | + is exceeded. |
| 34 | +3. **Per-iteration work** – Each loop iteration: |
| 35 | + - Clones the provider handle and a snapshot of `observed_block_hashes`. |
| 36 | + - Selects the websocket provider when available (HTTP otherwise) for reads. |
| 37 | + - Fetches the latest head (`BlockNumberOrTag::Latest`) and, if successful, |
| 38 | + passes it to `process_new_block`. |
| 39 | + - Reads the on-chain ADFS root via `rpc_get_storage_at`. If the storage slot |
| 40 | + differs from the locally tracked Merkle root we update |
| 41 | + `RpcProvider.merkle_root_in_contract` and mark that the ring buffer indices |
| 42 | + must be resynchronized. |
| 43 | + - Fetches `BlockNumberOrTag::Finalized` to advance |
| 44 | + `observer_finalized_height`. When finalization moves forward we prune both |
| 45 | + `non_finalized_updates` and `observed_block_hashes` through |
| 46 | + `InflightObservations::prune_observed_up_to`. If the tracker falls behind |
| 47 | + finalization we reset `observed_latest_height` to the finalized height and |
| 48 | + seed the observed hash from the finalized block. |
| 49 | + - When `need_resync_indices` is true we call `try_to_sync` so the ring-buffer |
| 50 | + indices are refreshed directly from the contract state. |
| 51 | + - If the provider disappears from the shared map we log and terminate the |
| 52 | + loop for that network. |
| 53 | + |
| 54 | +## Detecting Divergence (`process_new_block`) |
| 55 | + |
| 56 | +- **Tip pre-check** – Before ingesting new blocks we refetch the chain block at |
| 57 | + `observed_latest_height`. A hash mismatch signals that the tracked tip was |
| 58 | + replaced, so we increment `ProviderMetrics.observed_reorgs` and delegate to |
| 59 | + `handle_reorg`. |
| 60 | +- **Parent mismatch** – When new blocks appear we load the first new block and |
| 61 | + compare its parent hash to the stored hash for `observed_latest_height`. Any |
| 62 | + difference indicates a fork at or above that height. |
| 63 | +- **Same-height hash change** – Even if the height does not advance, the latest |
| 64 | + header fetched from RPC is compared with the cached hash. If it changes we |
| 65 | + treat it as a reorg. |
| 66 | +- Successful ingestion stores fresh hashes in |
| 67 | + `provider.inflight.observed_block_hashes` for every block seen, ensuring we |
| 68 | + can later walk backwards to locate a common ancestor. |
| 69 | + |
| 70 | +All block and storage reads funnel through `rpc_get_block_by_number` and |
| 71 | +`rpc_get_storage_at`. These helpers prefer the websocket provider when it is |
| 72 | +healthy and fall back to HTTP otherwise, still enforcing the per-call timeout. |
| 73 | + |
| 74 | +## Handling a Reorg (`handle_reorg`) |
| 75 | + |
| 76 | +1. Walk the cached heights in descending order (starting from the newest cached |
| 77 | + block) and refetch each block from the chain until we locate the first height |
| 78 | + where the stored hash matches the canonical hash. The function logs any |
| 79 | + diverged heights it sees along the way. |
| 80 | +2. The fork point is `first_common + 1`. We log both the ancestor and the fork |
| 81 | + height for operators. |
| 82 | +3. Holding the provider lock, we remove every entry in |
| 83 | + `non_finalized_updates` with a height ≥ fork height. Each batch is sent back |
| 84 | + to the relayer channel in ascending order and the resend outcome is logged. |
| 85 | + Pre-fork entries stay untouched so they can be |
| 86 | + pruned only when the network finalizes them. |
| 87 | +4. If no common ancestor is found within the cached history we warn, but the |
| 88 | + loop continues on the canonical head revealed by RPC. |
| 89 | + |
| 90 | +## Finalization and Cleanup |
| 91 | + |
| 92 | +- `observer_finalized_height` tracks the latest finalized block we have seen. |
| 93 | + Advancing it triggers a pruning pass in `InflightObservations` which clears |
| 94 | + outdated block hashes and relayer batches that can never reorg again. |
| 95 | +- If we discover that `observed_latest_height` lags behind finalization we |
| 96 | + fast-forward it to the finalized height and overwrite the cached hash to keep |
| 97 | + the tracker anchored to known-final data. |
| 98 | +- Additional blocks observed after a fork are appended to |
| 99 | + `observed_block_hashes`, giving the tracker the history it needs for future |
| 100 | + reorg detection. |
| 101 | + |
| 102 | +## Websocket Strategy and Fallbacks |
| 103 | + |
| 104 | +- When websockets are configured the tracker continuously consumes the |
| 105 | + `newHeads` stream. Every received header simply wakes the loop so the same |
| 106 | + verification logic runs against HTTP (to reuse existing RPC primitives). |
| 107 | +- Disconnects or subscription failures trigger a configurable backoff sequence. |
| 108 | + While waiting for the next retry we revert to the polling cadence determined |
| 109 | + by the average block time. |
| 110 | +- If no websocket URL is configured we never try to connect; the loop purely |
| 111 | + relies on the adaptive polling interval. |
| 112 | + |
| 113 | +## Metrics and Observability |
| 114 | + |
| 115 | +- `ProviderMetrics.observed_reorgs` (an `IntCounterVec` keyed by network) is the |
| 116 | + primary signal that a reorg was detected. Every detection increments it before |
| 117 | + the corrective flow begins. |
| 118 | +- Logs include network, observed/latest heights, finalized checkpoints, and |
| 119 | + loop counters, mirroring the legacy `eth_send_utils` diagnostics so existing |
| 120 | + dashboards remain useful. Diverged block hashes, fork points, and resend |
| 121 | + activity are explicitly printed. |
| 122 | + |
| 123 | +## Test Coverage |
| 124 | + |
| 125 | +- `test_loop_tracking_reorg_detect_and_resync_indices_http` and |
| 126 | + `test_loop_tracking_reorg_detect_and_resync_indices_websock` |
| 127 | + (`apps/sequencer/src/providers/reorg_tracking.rs:1573`) spin up an Anvil node, |
| 128 | + inject synthetic non-finalized batches (tagged via `EncodedFeedId`), and drive |
| 129 | + a deterministic reorg using snapshot/revert. |
| 130 | +- The tests assert that: |
| 131 | + - `observed_reorgs` increments for the network. |
| 132 | + - Only the batches at or above the fork height are replayed through the |
| 133 | + relayer channel and they arrive in ascending order. |
| 134 | + - Pre-fork batches remain cached until later finalization causes pruning. |
| 135 | + - The on-chain ADFS root drift triggers a `try_to_sync` resync, even without |
| 136 | + deploying contracts. |
| 137 | + - Both the HTTP polling path and websocket-triggered path exercise the same |
| 138 | + logic by running the scenario twice. |
| 139 | + |
| 140 | +Together these pieces ensure the Sequencer can withstand short-lived forks, |
| 141 | +recover the state required to keep publishing updates, and provide operators the |
| 142 | +signals they need to observe the system’s behavior. |
0 commit comments