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    #[test]
262    fn eth_snap_layout_accepts_eth_then_snap() {
263        let caps = shared_caps(
264            vec![EthVersion::Eth68.into(), Protocol::snap_2()],
265            vec![EthVersion::Eth68.into(), Capability::snap_2()],
266        );
267        let offset = eth_snap_layout(&caps).unwrap();
268        assert_eq!(offset, caps.eth().unwrap().num_messages());
269    }
270
271    #[test]
272    fn eth_snap_layout_rejects_missing_snap() {
273        let caps = shared_caps(vec![EthVersion::Eth68.into()], vec![EthVersion::Eth68.into()]);
274        assert!(eth_snap_layout(&caps).is_err());
275    }
276
277    #[test]
278    fn eth_snap_layout_rejects_capability_before_eth() {
279        // "aaa" sorts before "eth", so it takes relative offset 0 and eth no longer starts at 0.
280        let cap = Capability::new_static("aaa", 1);
281        let caps = shared_caps(
282            vec![Protocol::new(cap.clone(), 5), EthVersion::Eth68.into(), Protocol::snap_2()],
283            vec![cap, EthVersion::Eth68.into(), Capability::snap_2()],
284        );
285        assert!(eth_snap_layout(&caps).is_err());
286    }
287
288    #[test]
289    fn eth_snap_layout_rejects_capability_between_eth_and_snap() {
290        // "les" sorts between "eth" and "snap", leaving a gap so snap no longer directly follows
291        // eth.
292        let cap = Capability::new_static("les", 1);
293        let caps = shared_caps(
294            vec![EthVersion::Eth68.into(), Protocol::new(cap.clone(), 5), Protocol::snap_2()],
295            vec![EthVersion::Eth68.into(), cap, Capability::snap_2()],
296        );
297        assert!(eth_snap_layout(&caps).is_err());
298    }
299
300    #[test]
301    fn eth_snap_layout_rejects_capability_after_snap() {
302        // "zzz" sorts after "snap"; eth+snap still line up, but its frames would be routed
303        // incorrectly as snap, so the layout must be rejected (it belongs on the satellite
304        // multiplexer).
305        let cap = Capability::new_static("zzz", 1);
306        let caps = shared_caps(
307            vec![EthVersion::Eth68.into(), Protocol::snap_2(), Protocol::new(cap.clone(), 5)],
308            vec![EthVersion::Eth68.into(), Capability::snap_2(), cap],
309        );
310        assert!(eth_snap_layout(&caps).is_err());
311    }
312
313    #[test]
314    fn mask_snap_rebases_into_combined_space() {
315        let mut bytes = BytesMut::from(&[8u8, 0xcc][..]);
316        mask_snap(&mut bytes, 17).unwrap();
317        assert_eq!(bytes[0], 17 + 8);
318    }
319
320    /// End-to-end: two peers negotiate `eth` + `snap/2`, then a `GetBlockAccessLists` request and
321    /// its `BlockAccessLists` response round-trip over live [`EthSnapStream`]s.
322    #[tokio::test(flavor = "multi_thread")]
323    async fn snap_request_response_round_trips_over_the_wire() {
324        reth_tracing::init_test_tracing();
325        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
326        let local_addr = listener.local_addr().unwrap();
327        let (status, fork_filter) = eth_handshake();
328        let server_status = status;
329        let server_fork_filter = fork_filter.clone();
330
331        // Server: accept, negotiate, and answer one GetBlockAccessLists echoing the request id.
332        let server = tokio::spawn(async move {
333            let (incoming, _) = listener.accept().await.unwrap();
334            let stream = crate::PassthroughCodec::default().framed(incoming);
335            let server_hello = eth_snap_hello();
336            let (conn, _) = UnauthedP2PStream::new(stream).handshake(server_hello).await.unwrap();
337
338            let (mut stream, _) = EthSnapStream::<_, EthNetworkPrimitives>::handshake(
339                conn,
340                server_status,
341                server_fork_filter,
342                Arc::new(EthHandshake::default()),
343                MAX_MESSAGE_SIZE,
344            )
345            .await
346            .unwrap();
347
348            while let Some(Ok(msg)) = stream.next().await {
349                if let EthSnapMessage::Snap(SnapProtocolMessage::GetBlockAccessLists(req)) = msg {
350                    let response = SnapProtocolMessage::BlockAccessLists(BlockAccessListsMessage {
351                        request_id: req.request_id,
352                        block_access_lists: reth_eth_wire_types::BlockAccessLists(vec![None]),
353                    });
354                    stream.send(EthSnapMessage::Snap(response)).await.unwrap();
355                }
356            }
357        });
358
359        // Client: connect, negotiate, send the request, and await the correlated response.
360        let conn = connect_passthrough(local_addr, eth_snap_hello()).await;
361        let (mut stream, _) = EthSnapStream::<_, EthNetworkPrimitives>::handshake(
362            conn,
363            status,
364            fork_filter,
365            Arc::new(EthHandshake::default()),
366            MAX_MESSAGE_SIZE,
367        )
368        .await
369        .unwrap();
370
371        stream
372            .send(EthSnapMessage::Snap(SnapProtocolMessage::GetBlockAccessLists(
373                GetBlockAccessListsMessage {
374                    request_id: 7,
375                    block_hashes: Vec::new(),
376                    response_bytes: u64::MAX,
377                },
378            )))
379            .await
380            .unwrap();
381
382        let response = loop {
383            if let EthSnapMessage::Snap(SnapProtocolMessage::BlockAccessLists(resp)) =
384                stream.next().await.unwrap().unwrap()
385            {
386                break resp;
387            }
388        };
389        assert_eq!(response.request_id, 7);
390
391        server.abort();
392    }
393
394    /// Builds a hello advertising `eth` + `snap/2`.
395    fn eth_snap_hello() -> crate::HelloMessageWithProtocols {
396        let mut hello = eth_hello().0;
397        hello.try_add_protocol(Protocol::snap_2()).unwrap();
398        hello
399    }
400}