Skip to main content

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}