|
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.""" |
18 | 2 |
|
19 | 3 | from __future__ import annotations |
20 | 4 |
|
|
23 | 7 |
|
24 | 8 | import httpx |
25 | 9 |
|
26 | | -from lean_spec.spec.crypto.merkleization import hash_tree_root |
27 | 10 | from lean_spec.spec.forks import VALIDATOR_REGISTRY_LIMIT, State |
28 | 11 |
|
29 | 12 | logger = logging.getLogger(__name__) |
30 | 13 |
|
31 | 14 | 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 | +""" |
33 | 20 |
|
34 | 21 | 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.""" |
36 | 23 |
|
37 | 24 |
|
38 | 25 | class CheckpointSyncError(Exception): |
39 | 26 | """ |
40 | | - Error during checkpoint sync. |
| 27 | + Checkpoint state could not be fetched or failed validation. |
41 | 28 |
|
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. |
44 | 30 | """ |
45 | 31 |
|
46 | 32 |
|
47 | 33 | async def fetch_finalized_state(url: str, state_class: type[State]) -> State: |
48 | 34 | """ |
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. |
54 | 36 |
|
55 | 37 | 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. |
58 | 40 |
|
59 | 41 | Returns: |
60 | | - The finalized State object. |
| 42 | + The decoded finalized state. |
61 | 43 |
|
62 | 44 | Raises: |
63 | | - CheckpointSyncError: If the request fails or state is invalid. |
| 45 | + CheckpointSyncError: The request failed or the bytes did not decode. |
64 | 46 | """ |
65 | 47 | base_url = url.rstrip("/") |
66 | 48 | full_url = f"{base_url}{FINALIZED_STATE_ENDPOINT}" |
67 | 49 |
|
68 | 50 | logger.info("Fetching finalized state from %s", full_url) |
69 | 51 |
|
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. |
74 | 53 | headers = {"Accept": "application/octet-stream"} |
75 | 54 |
|
76 | 55 | try: |
77 | 56 | async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT) as client: |
78 | 57 | response = await client.get(full_url, headers=headers) |
79 | 58 | 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 |
97 | 60 | except httpx.HTTPStatusError as exception: |
98 | 61 | raise CheckpointSyncError( |
99 | 62 | f"HTTP error {exception.response.status_code}: {exception.response.text[:200]}" |
100 | 63 | ) 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 |
104 | 68 |
|
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)) |
108 | 70 |
|
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 |
112 | 77 |
|
113 | | - The checks are intentionally minimal: |
| 78 | + logger.info("Deserialized state at slot %s", state.slot) |
| 79 | + return state |
114 | 80 |
|
115 | | - - Slot is non-negative (sanity check) |
116 | | - - Validators exist (empty state is useless) |
117 | | - - Validator count within limits (prevents DoS) |
118 | 81 |
|
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. |
121 | 85 |
|
122 | 86 | Args: |
123 | | - state: The state to verify. |
| 87 | + state: The state to check. |
124 | 88 |
|
125 | 89 | Returns: |
126 | | - True if valid, False otherwise. |
| 90 | + True when every invariant holds, False otherwise. |
127 | 91 | """ |
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 |
151 | 97 |
|
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 | + ) |
154 | 105 | return False |
| 106 | + |
| 107 | + logger.info("Checkpoint state verified at slot %s", state.slot) |
| 108 | + return True |
0 commit comments