tenzro_network/block_sync_proto.rs
1//! Block-sync wire protocol for Tenzro Network.
2//!
3//! Modeled on Sui's `state_sync` protocol
4//! (`MystenLabs/sui/crates/sui-network/src/state_sync`) and Aptos' `storage-service`
5//! request/response. A single `libp2p::request_response` behaviour multiplexes
6//! all sync RPC methods as enum variants on `BlockSyncRequest` /
7//! `BlockSyncResponse`.
8//!
9//! Why this shape over Eth CL's chunked-stream `BeaconBlocksByRange`:
10//! * Empty-block payloads are small (≪10 MiB) — chunked framing is overkill.
11//! * CBOR-over-libp2p with bounded response size is dramatically simpler.
12//! * Production HotStuff-family chains (Sui, Aptos) use this pattern.
13//!
14//! Trust model — each `Block` carries a serialized `QuorumCertificate` in
15//! `header.consensus_proof.proof_data` (populated by
16//! `tenzro_consensus::HotStuff2Engine::finalize_with_commit_qc`). A receiving
17//! node verifies the QC against the validator set known at the certifying
18//! epoch before importing the block. No additional out-of-band trust is
19//! required beyond a recent validator-set commitment.
20//!
21//! ## Concurrency limits
22//! * Per-peer in-flight cap: 10 (Lighthouse convention)
23//! * Inbound concurrent streams per peer: 5 (reject overflow, do not queue —
24//! queueing causes timeout cascades on the requester)
25//! * Max blocks per `GetBlockRange`: 128 (matches Eth CL `MAX_REQUEST_BLOCKS_DENEB`)
26//! * Max request size: 1 MiB (CBOR codec default)
27//! * Max response size: 10 MiB (CBOR codec default; sufficient for 128
28//! empty blocks, header+body)
29
30use libp2p::{
31 request_response::{self, ProtocolSupport},
32 StreamProtocol,
33};
34use serde::{Deserialize, Serialize};
35use std::time::Duration;
36use tenzro_types::block::Block;
37use tenzro_types::primitives::{BlockHeight, Hash};
38
39/// Stream-protocol identifier for the block-sync RPC.
40///
41/// The version segment is required by libp2p's `StreamProtocol` rules
42/// (third-party / external surface). Internal Tenzro identifiers (gossipsub
43/// topics, JSON-RPC methods) intentionally omit version segments per the
44/// project's identifier conventions.
45pub const BLOCK_SYNC_PROTOCOL: &str = "/tenzro/block-sync/1.0.0";
46
47/// Maximum blocks a peer may request in a single `GetBlockRange` call.
48/// Mirrors Eth CL `MAX_REQUEST_BLOCKS_DENEB` (128).
49pub const MAX_BLOCKS_PER_RANGE: u32 = 128;
50
51/// Maximum block hashes a peer may request in a single `GetBlockBodies` call.
52pub const MAX_BLOCK_HASHES_PER_REQUEST: usize = 128;
53
54/// Per-peer in-flight outbound request cap (Lighthouse `SyncManager` convention).
55pub const MAX_INFLIGHT_REQUESTS_PER_PEER: usize = 10;
56
57/// Per-peer inbound concurrent stream cap. Overflow MUST be rejected, not
58/// queued — queueing causes the requester's response timeout to fire while
59/// the request still sits in our backlog.
60pub const MAX_INBOUND_STREAMS_PER_PEER: usize = 5;
61
62/// Outbound request timeout for header-only / metadata methods.
63pub const REQUEST_TIMEOUT_HEADERS: Duration = Duration::from_secs(10);
64
65/// Outbound request timeout for full-block methods.
66pub const REQUEST_TIMEOUT_BODIES: Duration = Duration::from_secs(30);
67
68/// Block-sync request envelope.
69///
70/// All variants are size-bounded by the CBOR codec's 1 MiB request cap.
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72pub enum BlockSyncRequest {
73 /// Probe a peer's chain tip and lowest-available block.
74 ///
75 /// Equivalent to Sui's `GetCheckpointAvailability`. Used by the sync state
76 /// machine to choose target peers and detect partitions.
77 GetTipInfo,
78
79 /// Fetch a contiguous range of blocks (header + body) starting at `start`,
80 /// up to `count` blocks. The peer SHOULD return as many blocks as it can
81 /// up to `count`; partial responses are valid (e.g., peer's tip is
82 /// `start + count - 5`).
83 ///
84 /// Constraints:
85 /// * `count` MUST be in `1..=MAX_BLOCKS_PER_RANGE`. Larger values are
86 /// rejected with `BlockSyncError::RangeTooLarge`.
87 GetBlockRange { start: BlockHeight, count: u32 },
88
89 /// Fetch a specific block by its hash. Used for fork resolution
90 /// (`parent_lookup` flow): when a peer advertises an unknown head, the
91 /// syncer first asks for the block by hash, then walks backward via
92 /// `prev_hash` until it finds a known ancestor.
93 GetBlockByHash { hash: Hash },
94}
95
96/// Block-sync response envelope.
97#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
98pub enum BlockSyncResponse {
99 /// Response to `GetTipInfo`.
100 TipInfo {
101 /// Highest finalized block height the peer can serve.
102 tip_height: BlockHeight,
103 /// Hash of the block at `tip_height`.
104 tip_hash: Hash,
105 /// Lowest block height the peer has on disk. Pruning nodes report a
106 /// value > 0; archive nodes report 0 (genesis).
107 lowest_available: BlockHeight,
108 },
109
110 /// Response to `GetBlockRange`. Blocks are returned in ascending height
111 /// order. May be shorter than the requested `count` if the peer's tip is
112 /// reached or if the response would exceed the 10 MiB CBOR cap.
113 BlockRange { blocks: Vec<Block> },
114
115 /// Response to `GetBlockByHash`. `None` if the peer does not have the
116 /// block (e.g., pruned, fork it doesn't follow).
117 BlockByHash { block: Option<Block> },
118
119 /// Server-side error for any request variant. The requesting node uses
120 /// this to score the peer down and choose another.
121 Error(BlockSyncError),
122}
123
124/// Errors a serving peer reports back to the requester.
125#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
126pub enum BlockSyncError {
127 /// `count` exceeded `MAX_BLOCKS_PER_RANGE`.
128 #[error("requested range too large (max {max}, got {got})")]
129 RangeTooLarge { got: u32, max: u32 },
130
131 /// Server is at its inbound concurrency cap. Caller should retry against
132 /// a different peer or back off.
133 #[error("server busy — exceeded {limit} concurrent inbound streams from this peer")]
134 ServerBusy { limit: usize },
135
136 /// `start` is below the peer's `lowest_available` (peer pruned the range).
137 #[error("requested block below pruned tail (start={start}, lowest_available={lowest_available})")]
138 BelowPrunedTail {
139 start: BlockHeight,
140 lowest_available: BlockHeight,
141 },
142
143 /// Internal storage error on the serving side.
144 #[error("server-side storage error: {0}")]
145 Storage(String),
146}
147
148/// Type alias for the libp2p request-response behaviour parameterized with
149/// our wire types. Uses CBOR (`cbor4ii`) framing — the production default.
150pub type BlockSyncBehaviour =
151 request_response::cbor::Behaviour<BlockSyncRequest, BlockSyncResponse>;
152
153/// Constructs a fresh block-sync `Behaviour` with production-tuned config.
154///
155/// The CBOR codec defaults already set the size limits we want
156/// (1 MiB request, 10 MiB response). The per-request timeout is set to
157/// `REQUEST_TIMEOUT_BODIES` (30s) to accommodate the slowest variant; the
158/// state machine layer enforces shorter per-method timeouts where
159/// appropriate.
160pub fn new_behaviour() -> BlockSyncBehaviour {
161 let protocol = StreamProtocol::new(BLOCK_SYNC_PROTOCOL);
162 let cfg = request_response::Config::default()
163 .with_request_timeout(REQUEST_TIMEOUT_BODIES);
164 request_response::cbor::Behaviour::new(
165 std::iter::once((protocol, ProtocolSupport::Full)),
166 cfg,
167 )
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173
174 /// Serde round-trip: every request variant must encode and decode cleanly.
175 /// We use `bincode` here as a stand-in for the runtime CBOR codec —
176 /// what matters is that the types implement `Serialize + DeserializeOwned`
177 /// correctly. The actual CBOR framing is exercised by libp2p's codec at
178 /// runtime; the upstream cbor4ii crate's own tests cover that path.
179 #[test]
180 fn request_round_trip() {
181 let cases = vec![
182 BlockSyncRequest::GetTipInfo,
183 BlockSyncRequest::GetBlockRange {
184 start: BlockHeight::from(42u64),
185 count: 16,
186 },
187 BlockSyncRequest::GetBlockByHash {
188 hash: Hash::from_bytes(&[7u8; 32]).unwrap(),
189 },
190 ];
191 for req in cases {
192 let bytes = bincode::serialize(&req).expect("encode");
193 let decoded: BlockSyncRequest =
194 bincode::deserialize(&bytes).expect("decode");
195 assert_eq!(req, decoded);
196 }
197 }
198
199 /// Serde round-trip for response variants. We don't construct a full
200 /// `Block` here (it requires consensus context); a `BlockRange { blocks: [] }`
201 /// + the other simple variants exercise the enum discriminant path.
202 #[test]
203 fn response_round_trip() {
204 let cases = vec![
205 BlockSyncResponse::TipInfo {
206 tip_height: BlockHeight::from(1000u64),
207 tip_hash: Hash::from_bytes(&[1u8; 32]).unwrap(),
208 lowest_available: BlockHeight::from(0u64),
209 },
210 BlockSyncResponse::BlockRange { blocks: vec![] },
211 BlockSyncResponse::BlockByHash { block: None },
212 BlockSyncResponse::Error(BlockSyncError::RangeTooLarge {
213 got: 999,
214 max: MAX_BLOCKS_PER_RANGE,
215 }),
216 BlockSyncResponse::Error(BlockSyncError::ServerBusy {
217 limit: MAX_INBOUND_STREAMS_PER_PEER,
218 }),
219 ];
220 for resp in cases {
221 let bytes = bincode::serialize(&resp).expect("encode");
222 let decoded: BlockSyncResponse =
223 bincode::deserialize(&bytes).expect("decode");
224 assert_eq!(resp, decoded);
225 }
226 }
227
228 #[test]
229 fn behaviour_constructs() {
230 // Smoke-test that constructing the behaviour doesn't panic on the
231 // protocol name or config.
232 let _ = new_behaviour();
233 }
234}