Skip to main content

reth_eth_wire/
eth_snap.rs

1//! A dedicated `eth` + `snap/2` stream.
2//!
3//! [`EthSnapStream`] carries `eth` as the primary protocol and `snap/2` (EIP-8189) as a typed
4//! side-channel over a single `RLPx` connection. It owns the raw [`P2PStream`] directly and reuses
5//! the transport-free [`EthStreamInner`] codec for `eth` framing, demultiplexing the two protocols
6//! by capability message-id offset.
7
8use crate::{
9    capability::SharedCapabilities,
10    errors::{EthStreamError, P2PStreamError},
11    handshake::EthRlpxHandshake,
12    message::EthBroadcastMessage,
13    Capability, EthMessage, EthNetworkPrimitives, EthStreamInner, EthVersion, NetworkPrimitives,
14    P2PStream, UnifiedStatus, HANDSHAKE_TIMEOUT,
15};
16use alloy_primitives::bytes::{Bytes, BytesMut};
17use futures::{Sink, SinkExt, Stream, StreamExt};
18use reth_eth_wire_types::{
19    snap::{SnapProtocolMessage, SnapVersion},
20    RawCapabilityMessage,
21};
22use reth_ethereum_forks::ForkFilter;
23use std::{
24    io,
25    pin::Pin,
26    sync::Arc,
27    task::{ready, Context, Poll},
28};
29
30/// A dedicated stream that carries `eth` as the primary protocol and `snap/2` (EIP-8189) as a typed
31/// side-channel on the same `RLPx` connection.
32///
33/// Both protocols are exposed through the [`Stream`]/[`Sink`] impls, which yield and accept
34/// [`EthSnapMessage`] (an `eth` message or a `snap/2` message). A single poll surfaces whichever
35/// protocol the next inbound frame belongs to, so the owner drives one stream rather than two.
36#[derive(Debug)]
37pub struct EthSnapStream<St, N: NetworkPrimitives = EthNetworkPrimitives> {
38    /// The raw `RLPx` stream carrying both `eth` and `snap/2` frames.
39    conn: P2PStream<St>,
40    /// Transport-free `eth` codec, reused from [`EthStream`](crate::EthStream).
41    eth: EthStreamInner<N>,
42    /// Relative message-id offset of the `snap/2` capability in the combined message space. Ids at
43    /// or above it are snap, rebased to snap-relative and validated by
44    /// [`SnapProtocolMessage::decode_versioned`].
45    snap_offset: u8,
46}
47
48impl<St, N> EthSnapStream<St, N>
49where
50    St: Stream<Item = io::Result<BytesMut>> + Sink<Bytes, Error = io::Error> + Unpin + Send + Sync,
51    N: NetworkPrimitives,
52{
53    /// Performs the `eth` status handshake over a connection that negotiated both `eth` and
54    /// `snap/2`, returning the established [`EthSnapStream`] and the remote's status.
55    ///
56    /// The handshake runs directly on the underlying [`P2PStream`]; a `snap/2` message arriving
57    /// before the status exchange completes is a protocol violation and surfaces as a handshake
58    /// error.
59    pub async fn handshake(
60        mut conn: P2PStream<St>,
61        status: UnifiedStatus,
62        fork_filter: ForkFilter,
63        handshake: Arc<dyn EthRlpxHandshake>,
64        eth_max_message_size: usize,
65    ) -> Result<(Self, UnifiedStatus), EthStreamError> {
66        let eth_version = conn.shared_capabilities().eth_version()?;
67        let snap_offset = eth_snap_layout(conn.shared_capabilities())?;
68
69        let their_status =
70            handshake.handshake(&mut conn, status, fork_filter, HANDSHAKE_TIMEOUT).await?;
71
72        let eth = EthStreamInner::with_max_message_size(eth_version, eth_max_message_size);
73        Ok((Self { conn, eth, snap_offset }, their_status))
74    }
75}
76
77impl<St, N: NetworkPrimitives> EthSnapStream<St, N> {
78    /// Returns the negotiated `eth` version.
79    #[inline]
80    pub const fn version(&self) -> EthVersion {
81        self.eth.version()
82    }
83
84    /// Sets whether to reject block announcement messages before RLP decoding.
85    #[inline]
86    pub const fn set_reject_block_announcements(&mut self, reject: bool) {
87        self.eth.set_reject_block_announcements(reject);
88    }
89
90    /// Returns a reference to the underlying [`P2PStream`].
91    #[inline]
92    pub const fn inner(&self) -> &P2PStream<St> {
93        &self.conn
94    }
95
96    /// Returns mutable access to the underlying [`P2PStream`].
97    #[inline]
98    pub const fn inner_mut(&mut self) -> &mut P2PStream<St> {
99        &mut self.conn
100    }
101
102    /// Consumes this type and returns the underlying [`P2PStream`].
103    #[inline]
104    pub fn into_inner(self) -> P2PStream<St> {
105        self.conn
106    }
107}
108
109impl<St, N> EthSnapStream<St, N>
110where
111    St: Stream<Item = io::Result<BytesMut>> + Sink<Bytes, Error = io::Error> + Unpin,
112    N: NetworkPrimitives,
113{
114    /// Queues an [`EthBroadcastMessage`] to be sent on the wire.
115    pub fn start_send_broadcast(
116        &mut self,
117        item: EthBroadcastMessage<N>,
118    ) -> Result<(), EthStreamError> {
119        self.conn.start_send_unpin(item.encoded()).map_err(Into::into)
120    }
121
122    /// Sends a raw capability message over the connection.
123    pub fn start_send_raw(&mut self, msg: RawCapabilityMessage) -> Result<(), EthStreamError> {
124        self.conn.start_send_unpin(msg.encoded()).map_err(Into::into)
125    }
126}
127
128/// A message carried by an [`EthSnapStream`]: either an `eth` message or a `snap/2` message.
129#[derive(Debug)]
130pub enum EthSnapMessage<N: NetworkPrimitives = EthNetworkPrimitives> {
131    /// An `eth` protocol message.
132    Eth(EthMessage<N>),
133    /// A `snap/2` (EIP-8189) protocol message.
134    Snap(SnapProtocolMessage),
135}
136
137impl<St, N> Stream for EthSnapStream<St, N>
138where
139    St: Stream<Item = io::Result<BytesMut>> + Sink<Bytes, Error = io::Error> + Unpin,
140    N: NetworkPrimitives,
141{
142    type Item = Result<EthSnapMessage<N>, EthStreamError>;
143
144    fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
145        let this = self.get_mut();
146        let mut bytes = match ready!(this.conn.poll_next_unpin(cx)) {
147            Some(Ok(bytes)) => bytes,
148            Some(Err(err)) => return Poll::Ready(Some(Err(err.into()))),
149            None => return Poll::Ready(None),
150        };
151
152        let Some(&id) = bytes.first() else {
153            return Poll::Ready(Some(Err(P2PStreamError::EmptyProtocolMessage.into())))
154        };
155
156        // `eth` occupies ids below the snap offset and is decoded by the shared codec. Ids at or
157        // above it are snap: rebased to snap-relative (`0x00..`) and validated by
158        // `decode_versioned`, which rejects ids that are out of range or invalid for `snap/2`.
159        let Some(snap_id) = id.checked_sub(this.snap_offset) else {
160            return Poll::Ready(Some(this.eth.decode_message(bytes).map(EthSnapMessage::Eth)))
161        };
162        bytes[0] = snap_id;
163        Poll::Ready(Some(
164            SnapProtocolMessage::decode_versioned(SnapVersion::V2, &bytes)
165                .map(EthSnapMessage::Snap)
166                .map_err(Into::into),
167        ))
168    }
169}
170
171impl<St, N> Sink<EthSnapMessage<N>> for EthSnapStream<St, N>
172where
173    St: Stream<Item = io::Result<BytesMut>> + Sink<Bytes, Error = io::Error> + Unpin,
174    N: NetworkPrimitives,
175{
176    type Error = EthStreamError;
177
178    fn poll_ready(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
179        self.get_mut().conn.poll_ready_unpin(cx).map_err(Into::into)
180    }
181
182    fn start_send(self: Pin<&mut Self>, item: EthSnapMessage<N>) -> Result<(), Self::Error> {
183        let this = self.get_mut();
184        let bytes = match item {
185            EthSnapMessage::Eth(msg) => this.eth.encode_message(msg)?,
186            EthSnapMessage::Snap(msg) => {
187                // Reclaim the freshly-encoded buffer as mutable without copying the payload, then
188                // rebase the snap-relative id into the combined message space.
189                let mut buf =
190                    msg.encode().0.try_into_mut().unwrap_or_else(|b| BytesMut::from(b.as_ref()));
191                mask_snap(&mut buf, this.snap_offset)?;
192                buf.freeze()
193            }
194        };
195        this.conn.start_send_unpin(bytes).map_err(Into::into)
196    }
197
198    fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
199        self.get_mut().conn.poll_flush_unpin(cx).map_err(Into::into)
200    }
201
202    fn poll_close(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
203        self.get_mut().conn.poll_close_unpin(cx).map_err(Into::into)
204    }
205}
206
207/// Resolves the `snap/2` message-id offset from the negotiated capabilities, accepting only a
208/// connection that shares exactly `eth` at relative offset 0 immediately followed by `snap/2`.
209///
210/// The stream forwards every combined id below the snap offset to the eth codec and treats every
211/// id at or above it as snap, so any other shared capability before `eth`, between `eth` and
212/// `snap/2`, or after `snap/2` would be routed incorrectly. Such layouts belong on the
213/// general-purpose satellite multiplexer and are rejected here.
214fn eth_snap_layout(caps: &SharedCapabilities) -> Result<u8, EthStreamError> {
215    if !caps.is_exact_eth_snap_v2() {
216        return Err(P2PStreamError::CapabilityNotShared.into())
217    }
218    let snap = caps
219        .ensure_matching_capability(&Capability::snap_2())
220        .map_err(|_| EthStreamError::from(P2PStreamError::CapabilityNotShared))?;
221    let snap_offset = snap.relative_message_id_offset();
222
223    let eth = caps.eth()?;
224    if eth.relative_message_id_offset() != 0 || eth.num_messages() != snap_offset {
225        return Err(P2PStreamError::CapabilityNotShared.into())
226    }
227    Ok(snap_offset)
228}
229
230/// Rebases a snap-relative message id to the combined message space.
231#[inline]
232fn mask_snap(bytes: &mut BytesMut, snap_offset: u8) -> Result<(), io::Error> {
233    let id = bytes.first().ok_or_else(|| io::Error::from(io::ErrorKind::InvalidInput))?;
234    bytes[0] =
235        id.checked_add(snap_offset).ok_or_else(|| io::Error::from(io::ErrorKind::InvalidInput))?;
236    Ok(())
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242    use crate::{
243        handshake::EthHandshake,
244        message::MAX_MESSAGE_SIZE,
245        protocol::Protocol,
246        test_utils::{connect_passthrough, eth_handshake, eth_hello},
247        UnauthedP2PStream,
248    };
249    use reth_eth_wire_types::{
250        snap::{BlockAccessListsMessage, GetBlockAccessListsMessage},
251        EthVersion,
252    };
253    use tokio::net::TcpListener;
254    use tokio_util::codec::Decoder;
255
256    /// Builds shared capabilities from matching local protocols and peer capabilities.
257    fn shared_caps(local: Vec<Protocol>, peer: Vec<Capability>) -> SharedCapabilities {
258        SharedCapabilities::try_new(local, peer).unwrap()
259    }
260
261    /// Builds a hello advertising `eth` + `snap/2`.
262    fn eth_snap_hello() -> crate::HelloMessageWithProtocols {
263        let mut hello = eth_hello().0;
264        hello.try_add_protocol(Protocol::snap_2()).unwrap();
265        hello
266    }
267
268    #[test]
269    fn eth_snap_layout_accepts_eth_then_snap() {
270        let caps = shared_caps(
271            vec![EthVersion::Eth68.into(), Protocol::snap_2()],
272            vec![EthVersion::Eth68.into(), Capability::snap_2()],
273        );
274        let offset = eth_snap_layout(&caps).unwrap();
275        assert_eq!(offset, caps.eth().unwrap().num_messages());
276    }
277
278    #[test]
279    fn eth_snap_layout_rejects_missing_snap() {
280        let caps = shared_caps(vec![EthVersion::Eth68.into()], vec![EthVersion::Eth68.into()]);
281        assert!(eth_snap_layout(&caps).is_err());
282    }
283
284    #[test]
285    fn eth_snap_layout_rejects_capability_before_eth() {
286        // "aaa" sorts before "eth", so it takes relative offset 0 and eth no longer starts at 0.
287        let cap = Capability::new_static("aaa", 1);
288        let caps = shared_caps(
289            vec![Protocol::new(cap.clone(), 5), EthVersion::Eth68.into(), Protocol::snap_2()],
290            vec![cap, EthVersion::Eth68.into(), Capability::snap_2()],
291        );
292        assert!(eth_snap_layout(&caps).is_err());
293    }
294
295    #[test]
296    fn eth_snap_layout_rejects_capability_between_eth_and_snap() {
297        // "les" sorts between "eth" and "snap", leaving a gap so snap no longer directly follows
298        // eth.
299        let cap = Capability::new_static("les", 1);
300        let caps = shared_caps(
301            vec![EthVersion::Eth68.into(), Protocol::new(cap.clone(), 5), Protocol::snap_2()],
302            vec![EthVersion::Eth68.into(), cap, Capability::snap_2()],
303        );
304        assert!(eth_snap_layout(&caps).is_err());
305    }
306
307    #[test]
308    fn eth_snap_layout_rejects_capability_after_snap() {
309        // "zzz" sorts after "snap"; eth+snap still line up, but its frames would be routed
310        // incorrectly as snap, so the layout must be rejected (it belongs on the satellite
311        // multiplexer).
312        let cap = Capability::new_static("zzz", 1);
313        let caps = shared_caps(
314            vec![EthVersion::Eth68.into(), Protocol::snap_2(), Protocol::new(cap.clone(), 5)],
315            vec![EthVersion::Eth68.into(), Capability::snap_2(), cap],
316        );
317        assert!(eth_snap_layout(&caps).is_err());
318    }
319
320    #[test]
321    fn mask_snap_rebases_into_combined_space() {
322        let mut bytes = BytesMut::from(&[8u8, 0xcc][..]);
323        mask_snap(&mut bytes, 17).unwrap();
324        assert_eq!(bytes[0], 17 + 8);
325    }
326
327    /// End-to-end: two peers negotiate `eth` + `snap/2`, then a `GetBlockAccessLists` request and
328    /// its `BlockAccessLists` response round-trip over live [`EthSnapStream`]s.
329    #[tokio::test(flavor = "multi_thread")]
330    async fn snap_request_response_round_trips_over_the_wire() {
331        reth_tracing::init_test_tracing();
332        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
333        let local_addr = listener.local_addr().unwrap();
334        let (status, fork_filter) = eth_handshake();
335        let server_status = status;
336        let server_fork_filter = fork_filter.clone();
337
338        // Server: accept, negotiate, and answer one GetBlockAccessLists echoing the request id.
339        let server = tokio::spawn(async move {
340            let (incoming, _) = listener.accept().await.unwrap();
341            let stream = crate::PassthroughCodec::default().framed(incoming);
342            let server_hello = eth_snap_hello();
343            let (conn, _) = UnauthedP2PStream::new(stream).handshake(server_hello).await.unwrap();
344
345            let (mut stream, _) = EthSnapStream::<_, EthNetworkPrimitives>::handshake(
346                conn,
347                server_status,
348                server_fork_filter,
349                Arc::new(EthHandshake::default()),
350                MAX_MESSAGE_SIZE,
351            )
352            .await
353            .unwrap();
354
355            while let Some(Ok(msg)) = stream.next().await {
356                if let EthSnapMessage::Snap(SnapProtocolMessage::GetBlockAccessLists(req)) = msg {
357                    let response = SnapProtocolMessage::BlockAccessLists(BlockAccessListsMessage {
358                        request_id: req.request_id,
359                        block_access_lists: reth_eth_wire_types::BlockAccessLists(vec![None]),
360                    });
361                    stream.send(EthSnapMessage::Snap(response)).await.unwrap();
362                }
363            }
364        });
365
366        // Client: connect, negotiate, send the request, and await the correlated response.
367        let conn = connect_passthrough(local_addr, eth_snap_hello()).await;
368        let (mut stream, _) = EthSnapStream::<_, EthNetworkPrimitives>::handshake(
369            conn,
370            status,
371            fork_filter,
372            Arc::new(EthHandshake::default()),
373            MAX_MESSAGE_SIZE,
374        )
375        .await
376        .unwrap();
377
378        stream
379            .send(EthSnapMessage::Snap(SnapProtocolMessage::GetBlockAccessLists(
380                GetBlockAccessListsMessage {
381                    request_id: 7,
382                    block_hashes: Vec::new(),
383                    response_bytes: u64::MAX,
384                },
385            )))
386            .await
387            .unwrap();
388
389        let response = loop {
390            if let EthSnapMessage::Snap(SnapProtocolMessage::BlockAccessLists(resp)) =
391                stream.next().await.unwrap().unwrap()
392            {
393                break resp;
394            }
395        };
396        assert_eq!(response.request_id, 7);
397
398        server.abort();
399    }
400
401    /// End-to-end: message ids removed by snap/2 are rejected after capability negotiation rather
402    /// than being decoded as trie-node requests or responses.
403    #[tokio::test(flavor = "multi_thread")]
404    async fn snap_two_rejects_removed_trie_node_messages_over_the_wire() {
405        reth_tracing::init_test_tracing();
406
407        for removed_snap_id in [0x06, 0x07] {
408            let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
409            let local_addr = listener.local_addr().unwrap();
410            let (status, fork_filter) = eth_handshake();
411            let server_status = status;
412            let server_fork_filter = fork_filter.clone();
413
414            let server = tokio::spawn(async move {
415                let (incoming, _) = listener.accept().await.unwrap();
416                let stream = crate::PassthroughCodec::default().framed(incoming);
417                let (conn, _) =
418                    UnauthedP2PStream::new(stream).handshake(eth_snap_hello()).await.unwrap();
419                let (mut stream, _) = EthSnapStream::<_, EthNetworkPrimitives>::handshake(
420                    conn,
421                    server_status,
422                    server_fork_filter,
423                    Arc::new(EthHandshake::default()),
424                    MAX_MESSAGE_SIZE,
425                )
426                .await
427                .unwrap();
428
429                stream.next().await.expect("peer should send a frame").unwrap_err()
430            });
431
432            let conn = connect_passthrough(local_addr, eth_snap_hello()).await;
433            let (mut stream, _) = EthSnapStream::<_, EthNetworkPrimitives>::handshake(
434                conn,
435                status,
436                fork_filter,
437                Arc::new(EthHandshake::default()),
438                MAX_MESSAGE_SIZE,
439            )
440            .await
441            .unwrap();
442
443            let combined_id = stream.snap_offset as usize + removed_snap_id;
444            stream
445                .start_send_raw(RawCapabilityMessage::new(
446                    combined_id,
447                    Bytes::from_static(&[alloy_rlp::EMPTY_LIST_CODE]),
448                ))
449                .unwrap();
450            stream.flush().await.unwrap();
451
452            let error = server.await.unwrap();
453            assert!(
454                error.to_string().contains("invalid for snap"),
455                "unexpected error for removed snap id {removed_snap_id:#x}: {error}"
456            );
457        }
458    }
459}