Skip to content

Commit 46f9532

Browse files
tcoratgerclaude
andauthored
refactor(checkpoint-sync): drop dead checks and tighten error handling (leanEthereum#1111)
Clean up the checkpoint sync client without changing the conformance verdict it produces. The boolean result of checkpoint-state verification is pinned into sync test vectors, so its accept/reject behavior on real states is left untouched. - Remove the false "slot is non-negative" docstring bullet. Slot is an unsigned Uint64, so the check it claimed neither exists nor could exist. - Remove the hash-tree-root computation from verification. It rooted the state, logged a truncated prefix, compared it to nothing, and only existed to justify a broad except. SSZ decode already validated structure, so this is conformance-safe: real states never raise here. - Drop the now-dead mocked-hashing-failure test and the unused import. - Narrow the catch-all in the fetch path: separate transport errors from decode errors so a corrupt body reports a payload error and an unrelated bug surfaces as itself. Rename ssz_data to ssz_bytes. - Trim the trust-model module header and state plainly that no checkpoint root is pinned, so the source is trusted outright. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a2991cb commit 46f9532

2 files changed

Lines changed: 52 additions & 113 deletions

File tree

Lines changed: 49 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,4 @@
1-
"""
2-
Checkpoint sync client for downloading finalized state from another node.
3-
4-
Checkpoint sync enables fast startup by skipping historical block processing.
5-
Instead of replaying every block from genesis, a node downloads a recent
6-
finalized state and starts from there.
7-
8-
Trust model:
9-
10-
- The operator trusts the checkpoint source to provide valid finalized state
11-
- This trust is acceptable because finalized state has 2/3 validator support
12-
- The alternative (genesis sync) may take hours or days on mainnet
13-
14-
The trade-off is trustlessness for speed. Most operators accept this because
15-
they already trust their checkpoint source (often their own infrastructure
16-
or a well-known provider).
17-
"""
1+
"""Checkpoint sync: download a recent finalized state instead of replaying from genesis."""
182

193
from __future__ import annotations
204

@@ -23,132 +7,102 @@
237

248
import httpx
259

26-
from lean_spec.spec.crypto.merkleization import hash_tree_root
2710
from lean_spec.spec.forks import VALIDATOR_REGISTRY_LIMIT, State
2811

2912
logger = logging.getLogger(__name__)
3013

3114
DEFAULT_TIMEOUT: Final = 60.0
32-
"""HTTP request timeout in seconds. Large states may take time to transfer."""
15+
"""
16+
Seconds allowed per request.
17+
18+
Finalized state runs tens of megabytes, so the transfer needs a wide window.
19+
"""
3320

3421
FINALIZED_STATE_ENDPOINT: Final = "/lean/v0/states/finalized"
35-
"""API endpoint for fetching finalized state. Follows Beacon API conventions."""
22+
"""Beacon API path for the finalized state."""
3623

3724

3825
class CheckpointSyncError(Exception):
3926
"""
40-
Error during checkpoint sync.
27+
Checkpoint state could not be fetched or failed validation.
4128
42-
Raised when the checkpoint state cannot be fetched or is invalid.
43-
Callers should handle this by aborting startup (not falling back).
29+
Startup aborts on this error rather than falling back to genesis sync.
4430
"""
4531

4632

4733
async def fetch_finalized_state(url: str, state_class: type[State]) -> State:
4834
"""
49-
Fetch finalized state from a node via checkpoint sync.
50-
51-
Downloads the state as SSZ binary and deserializes it. SSZ format is
52-
preferred over JSON because state objects are large (tens of MB) and
53-
SSZ is more compact and faster to parse.
35+
Download and decode finalized state from a node.
5436
5537
Args:
56-
url: Base URL of the node API (e.g., "http://localhost:5052").
57-
state_class: State class used to decode SSZ bytes.
38+
url: Base URL of the node API.
39+
state_class: State class used to decode the SSZ bytes.
5840
5941
Returns:
60-
The finalized State object.
42+
The decoded finalized state.
6143
6244
Raises:
63-
CheckpointSyncError: If the request fails or state is invalid.
45+
CheckpointSyncError: The request failed or the bytes did not decode.
6446
"""
6547
base_url = url.rstrip("/")
6648
full_url = f"{base_url}{FINALIZED_STATE_ENDPOINT}"
6749

6850
logger.info("Fetching finalized state from %s", full_url)
6951

70-
# Request SSZ binary format.
71-
#
72-
# The Accept header tells the server we want raw bytes, not JSON.
73-
# This is faster to transfer and parse than JSON encoding.
52+
# Ask for raw SSZ bytes rather than JSON.
7453
headers = {"Accept": "application/octet-stream"}
7554

7655
try:
7756
async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT) as client:
7857
response = await client.get(full_url, headers=headers)
7958
response.raise_for_status()
80-
81-
ssz_data = response.content
82-
logger.info("Downloaded %d bytes of SSZ state data", len(ssz_data))
83-
84-
# Deserialize from SSZ bytes.
85-
#
86-
# This validates the byte stream matches the expected schema.
87-
# Malformed data will raise an exception here.
88-
state = state_class.decode_bytes(ssz_data)
89-
logger.info("Deserialized state at slot %s", state.slot)
90-
91-
return state
92-
93-
except httpx.RequestError as exception:
94-
raise CheckpointSyncError(
95-
f"Network error while connecting to {exception.request.url}: {exception}"
96-
) from exception
59+
ssz_bytes = response.content
9760
except httpx.HTTPStatusError as exception:
9861
raise CheckpointSyncError(
9962
f"HTTP error {exception.response.status_code}: {exception.response.text[:200]}"
10063
) from exception
101-
except Exception as exception:
102-
raise CheckpointSyncError(f"Failed to fetch state: {exception}") from exception
103-
64+
except httpx.RequestError as exception:
65+
raise CheckpointSyncError(
66+
f"Network error while connecting to {exception.request.url}: {exception}"
67+
) from exception
10468

105-
def verify_checkpoint_state(state: State) -> bool:
106-
"""
107-
Verify that a checkpoint state is structurally valid.
69+
logger.info("Downloaded %d bytes of SSZ state data", len(ssz_bytes))
10870

109-
This is defense-in-depth validation. We trust the checkpoint source,
110-
but still verify basic invariants before using the state. These checks
111-
catch corrupted downloads or misconfigured servers.
71+
# SSZ decode validates the byte stream against the schema.
72+
# A truncated download or a JSON body fails here.
73+
try:
74+
state = state_class.decode_bytes(ssz_bytes)
75+
except Exception as exception:
76+
raise CheckpointSyncError(f"Corrupt checkpoint state payload: {exception}") from exception
11277

113-
The checks are intentionally minimal:
78+
logger.info("Deserialized state at slot %s", state.slot)
79+
return state
11480

115-
- Slot is non-negative (sanity check)
116-
- Validators exist (empty state is useless)
117-
- Validator count within limits (prevents DoS)
11881

119-
We do NOT verify cryptographic proofs here. That would require
120-
the full block history, defeating the purpose of checkpoint sync.
82+
def verify_checkpoint_state(state: State) -> bool:
83+
"""
84+
Check structural invariants on a downloaded checkpoint state.
12185
12286
Args:
123-
state: The state to verify.
87+
state: The state to check.
12488
12589
Returns:
126-
True if valid, False otherwise.
90+
True when every invariant holds, False otherwise.
12791
"""
128-
try:
129-
# A state with no validators cannot produce blocks.
130-
validator_count = len(state.validators)
131-
if validator_count == 0:
132-
logger.error("Invalid state: no validators")
133-
return False
134-
135-
# Guard against oversized states that could exhaust memory.
136-
if validator_count > int(VALIDATOR_REGISTRY_LIMIT):
137-
logger.error(
138-
"Invalid state: validator count %d exceeds registry limit %s",
139-
validator_count,
140-
VALIDATOR_REGISTRY_LIMIT,
141-
)
142-
return False
143-
144-
# Compute state root to verify SSZ deserialization worked correctly.
145-
#
146-
# If the data was corrupted, hashing will likely fail or produce
147-
# an unexpected result. We log the root for debugging.
148-
state_root = hash_tree_root(state)
149-
logger.info("Checkpoint state verified: slot=%s, root=%s...", state.slot, state_root)
150-
return True
92+
# A state with no validators cannot drive fork choice or produce blocks.
93+
validator_count = len(state.validators)
94+
if validator_count == 0:
95+
logger.error("Invalid checkpoint state: no validators")
96+
return False
15197

152-
except Exception as exception:
153-
logger.error("State verification failed: %s", exception)
98+
# Bound an attacker-supplied blob against the registry capacity.
99+
if validator_count > int(VALIDATOR_REGISTRY_LIMIT):
100+
logger.error(
101+
"Invalid checkpoint state: validator count %d exceeds registry limit %s",
102+
validator_count,
103+
VALIDATOR_REGISTRY_LIMIT,
104+
)
154105
return False
106+
107+
logger.info("Checkpoint state verified at slot %s", state.slot)
108+
return True

tests/node/sync/test_checkpoint_sync.py

Lines changed: 3 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -83,23 +83,6 @@ async def test_state_exceeding_validator_limit_fails(self) -> None:
8383
is_valid_checkpoint = verify_checkpoint_state(mock_state)
8484
assert is_valid_checkpoint is False
8585

86-
async def test_exception_during_hash_tree_root_returns_false(
87-
self, genesis_state: State
88-
) -> None:
89-
"""
90-
Verification never crashes the caller on unexpected hashing errors.
91-
92-
Any exception from the state root computation is caught and treated
93-
as a verification failure so startup can abort cleanly.
94-
"""
95-
with patch(
96-
"lean_spec.node.sync.checkpoint_sync.hash_tree_root",
97-
side_effect=RuntimeError("hash error"),
98-
):
99-
is_valid_checkpoint = verify_checkpoint_state(genesis_state)
100-
101-
assert is_valid_checkpoint is False
102-
10386

10487
class TestFetchFinalizedState:
10588
"""
@@ -183,7 +166,9 @@ async def test_corrupt_ssz_raises_checkpoint_sync_error(self) -> None:
183166
pytest.raises(CheckpointSyncError) as exception_info,
184167
):
185168
await fetch_finalized_state("http://example.com", State)
186-
assert str(exception_info.value) == "Failed to fetch state: Slot: expected 8 bytes, got 2"
169+
assert str(exception_info.value) == (
170+
"Corrupt checkpoint state payload: Slot: expected 8 bytes, got 2"
171+
)
187172

188173
async def test_trailing_slash_stripped_from_url(self) -> None:
189174
"""

0 commit comments

Comments
 (0)