OP-Geth Catch-Up & Recovery Runbook
1. Background
When an op-geth node has been offline long enough that the L1 blobs covering the gap have expired (L1 blob retention is a fixed number of epochs, typically on the order of days), op-node may fail to catch it up normally:
op-nodesees afinalizedblock already present in the datadir and chooses to CL-sync instead of EL-sync. CL-sync needs the (now expired) blobs for the gap, so it deadlocks permanently.Separately,
engine_forkchoiceUpdatedignores a zerofinalizedBlockHashinstead of clearing it, so the engine'sfinalizedlabel stays pinned at whatever it was before the outage. Ifop-node'sFindL2Headswalk ever starts from that stale label, it can walk back past a fullseq_windowand drop below the L1 blob retention boundary, reproducing the same deadlock.
The fix bypasses op-node entirely and drives op-geth's execution layer straight from a healthy public B2 peer via the Engine API, then repairs the safe/finalized labels so that once op-node is turned back on, its backward walk terminates near the new head instead of at the stale pre-outage finalized block.
Two scripts implement this:
drive_sync.py
Feeds the current network tip to geth (engine_newPayloadV3 + engine_forkchoiceUpdatedV3) so geth's BeaconSync walks backward from the tip and fills in the gap.
set_labels.py
Once geth is caught up, advances the safe/finalized labels to blocks near the new head so op-node's backstop is no longer stale.
2. Symptoms
op-nodelogs show it stuck on CL sync / waiting on blob retrieval that will never succeed.eth_getBlockByNumber("finalized")on geth returns a very old block compared to"latest".geth's head is not advancing even though the network
tip(from a public peer) is far ahead.
3. Prerequisites
Network access from wherever
drive_sync.pyandset_labels.pyrun to:the public B2 RPC endpoint:
https://b2-mainnet.alt.technology(this is the fixed value forPUBLIC_RPCindrive_sync.py)the local geth node's Engine API and RPC ports (values depend on the deployment — host networking, Docker Compose service name, or Kubernetes pod/service, etc.)
Read access to whatever Engine API JWT secret file geth was started with, from the same execution context that will run the scripts.
op-nodemust be stopped for the entire duration of this procedure.op-node,drive_sync.py, andset_labels.pyall issueengine_forkchoiceUpdatedcalls; running any of them concurrently races and can corrupt the sync state.Before running either script, set its constants to match the execution context it will actually run from — these are deployment-specific and will differ between environments/incidents:
drive_sync.py:JWT_PATH,ENGINE,LOCAL_RPC(PUBLIC_RPCis fixed, see above).set_labels.py:JWT_PATH,ENGINE,LOCAL_RPC,FINALIZED_LAG.If a script is copied into a different host/container than the one it was last configured for, verify connectivity (e.g. a simple RPC call) before trusting any output it produces — a wrong
ENGINE/LOCAL_RPCvalue will just fail to connect rather than silently doing the wrong thing, but a wrongJWT_PATHpointing at the right host's JWT for the wrong geth instance would not necessarily fail loudly.example:
JWT_PATH = "/jwt.txt" ENGINE = "http://127.0.0.1:8551" PUBLIC_RPC = "https://b2-mainnet.alt.technology" LOCAL_RPC = "http://127.0.0.1:8545" FINALIZED_LAG = 2000 # ~1.1h behind head, comfortably inside blob retention
4. Procedure
Step 0 — Stop op-node
Stop whatever supervises op-node in the target deployment (e.g. its Docker Compose service, Kubernetes deployment, or systemd unit).
Confirm it is actually stopped (no process issuing forkchoiceUpdated) before continuing.
Step 1 — Run drive_sync.py
Deploy drive_sync.py to wherever it can reach both the public RPC and the local geth Engine API (copy method depends on the environment — scp, kubectl cp, etc.), configure its constants per Step 3 above, then run:
This pulls the current tip block and its raw transactions from PUBLIC_RPC, submits the tip via engine_newPayloadV3, and points engine_forkchoiceUpdatedV3 at it. This kicks off (or continues) geth's BeaconSync, which then backfills from its peers on its own.
A SYNCING status in the output is expected and fine. An INVALID status is fatal — the script exits non-zero; stop and investigate (mismatched JWT, wrong RPC endpoint, or a bad payload) before retrying.
Since the tip keeps advancing and geth may lose peers over time, re-run this script as needed until the local head has caught up to (or is acceptably close to) the network tip — compare the local head= / behind= values it prints, or query eth_blockNumber directly against both the local node and PUBLIC_RPC.
Step 2 — Run set_labels.py
Once geth's head is caught up, deploy set_labels.py to an execution context that can reach the local geth Engine API and RPC (configure its constants per Step 3 above — this may be a different execution context than drive_sync.py, e.g. a container on the geth's own network rather than the host), then run:
It will:
Read the current
latesthead.Compute
finalized = head - FINALIZED_LAG.Call
engine_forkchoiceUpdatedV3withhead = safe = latest,finalized= the computed block.Read back and print the
safeandfinalizedlabels to confirm they moved.
If the resulting status is not VALID, the script exits non-zero — stop and investigate before restarting op-node.
Step 3 — Start op-node
Start op-node back up under its normal supervision (Docker Compose, Kubernetes, systemd, etc.).
5. Verification / Success Criteria
eth_getBlockByNumber("finalized")and("safe")on geth now return recent blocks, not the stale pre-outagefinalizedblock.op-nodelogs show it deriving/verifying new L2 blocks rather than stalling on blob retrieval.geth's
latesthead continues to advance in step with the network afterop-noderesumes control of forkchoice updates.
6. Rollback / Troubleshooting
newPayloadV3returnsINVALID: stop immediately (the script already exits1). Re-check that the block header and raw RLP came from the same block (the script pins both fetches to one block hash) and that the JWT secret matches the target geth instance.geth has too few devp2p peers: manually re-dial known bootnodes for the network (e.g. via
admin.addPeer(...)on the geth console) and re-rundrive_sync.pyafterward.A script can't connect to the configured
ENGINE/LOCAL_RPC: double check which host/container it's actually running from vs. the one its constants were set for (see Step 3 in Prerequisites) — the two scripts may need different values depending on where each is executed.Accidentally ran
op-nodeat the same time asdrive_sync.pyorset_labels.py: stop everything, re-check the currentlatest/safe/finalizedlabels, and re-run from Step 0 — do not assume the state is consistent.
7. Safety Notes
op-nodemust never run at the same time asdrive_sync.pyorset_labels.py— all of them issue competingengine_forkchoiceUpdatedcalls.All block data driving the recovery comes from the canonical B2 network over devp2p/RPC (a public peer and the local peer set) — the same trust assumption the rest of the node already relies on. No new trust boundary is introduced.
FINALIZED_LAGmust stay small enough to land after the L1 blob retention boundary and after any node offline gaps, but large enough to give a safety margin behind the head. Choose its value per incident — do not set it to0, givenengine_forkchoiceUpdated's known behavior of ignoring a zerofinalizedBlockHashinstead of clearing it.
Last updated