Skip to main content

dig_peer_protocol/
link.rs

1//! [`DigLink`] — a websocket peer link that frames [`DigMessage`].
2//!
3//! ## Why this exists
4//!
5//! `chia_sdk_client::Peer` speaks `chia_protocol::Message`, whose `msg_type` is the closed
6//! `ProtocolMessageTypes` enum (it stops at `RespondCostInfo = 107`, with no `Unknown(u8)` and no
7//! `#[non_exhaustive]`). Two consequences make it unusable as a DIG transport:
8//!
9//! 1. A DIG opcode has no `ProtocolMessageTypes` value, so no `Message` can name one. The
10//!    fields are public — `tests/wire_compatibility.rs` builds one with a struct literal — but
11//!    the closed enum is a sufficient blocker on its own.
12//! 2. Its inbound loop calls `Message::from_bytes`, which returns `Err` on any unknown opcode —
13//!    and that error terminates the receive loop. A single inbound DIG frame therefore kills the
14//!    whole connection, not just that frame.
15//!
16//! DIG previously worked around this by vendoring forks of `chia-protocol` and
17//! `chia-sdk-client`. `DigLink` replaces the forks with an implementation written directly
18//! against the wire format, which is possible because [`DigMessage`] is byte-identical to
19//! `chia_protocol::Message` (asserted exhaustively in `tests/wire_compatibility.rs`).
20//!
21//! ## What it is not
22//!
23//! It is not a port of upstream's `Peer`. Most of that type is Chia wallet RPC
24//! (`request_puzzle_state`, `register_for_ph_updates`, …) which a gossip transport never calls;
25//! those helpers stay upstream, where `chia_sdk_client::Peer` is still re-exported for anyone who
26//! wants them.
27
28use std::{net::SocketAddr, sync::Arc, time::Duration};
29
30use chia_protocol::{Bytes, ChiaProtocolMessage};
31use chia_traits::Streamable;
32use futures_util::{SinkExt, StreamExt};
33use tokio::{
34    net::TcpStream,
35    sync::{mpsc, oneshot, Mutex},
36    task::JoinHandle,
37};
38use tokio_tungstenite::{MaybeTlsStream, WebSocketStream};
39use tracing::{debug, warn};
40
41use crate::{
42    rate_limit::{Admission, OpcodeRateLimiter, OpcodeRateLimits},
43    request_map::RequestMap,
44    DigMessage, LinkError,
45};
46
47#[cfg(any(feature = "native-tls", feature = "rustls"))]
48use tokio_tungstenite::Connector;
49
50/// How many inbound messages may queue for the application before the reader backs up.
51const INBOUND_CHANNEL_CAPACITY: usize = 32;
52
53/// How long to wait before re-testing the rate limiter after it refuses an outbound message.
54const RATE_LIMIT_BACKOFF: Duration = Duration::from_secs(1);
55
56/// The window over which outbound rate-limit budgets reset.
57const RATE_LIMIT_WINDOW_SECONDS: u64 = 60;
58
59/// Tunables for a single link.
60///
61/// The type is `#[non_exhaustive]` because a link acquires tunables as it hardens — two arrived
62/// in one release — and this crate is released ahead of every consumer of it. Without the
63/// attribute each new tunable would be a major bump cascading through dig-gossip and everything
64/// downstream of it; with it, adding one is additive.
65///
66/// The cost is that consumers cannot name the type in a struct expression at all — not even with
67/// `..Default::default()`, which Rust also forbids for a `#[non_exhaustive]` struct. Start from
68/// [`Default`] and assign the fields you care about:
69///
70/// ```
71/// use std::time::Duration;
72/// use dig_peer_protocol::LinkOptions;
73///
74/// let mut options = LinkOptions::default();
75/// options.request_timeout = Duration::from_secs(5);
76/// ```
77#[derive(Debug, Clone, Copy)]
78#[non_exhaustive]
79pub struct LinkOptions {
80    /// Scales every outbound rate-limit budget. `1.0` is the nominal Chia allowance.
81    pub rate_limit_factor: f64,
82
83    /// How long [`DigLink::send_message`] may wait for rate-limit budget before giving up.
84    ///
85    /// Only a *deferrable* refusal waits at all — an oversized message is refused immediately,
86    /// since no amount of waiting makes it fit.
87    pub send_timeout: Duration,
88
89    /// How long a correlated request waits for its reply before erroring.
90    ///
91    /// Without a deadline a silent or wedged peer leaves the caller pending forever, which is
92    /// indistinguishable from a lost future.
93    pub request_timeout: Duration,
94}
95
96impl Default for LinkOptions {
97    fn default() -> Self {
98        Self {
99            rate_limit_factor: 0.6,
100            // Two full windows: long enough that a genuinely transient budget exhaustion always
101            // clears (a roll resets every counter), short enough to surface a stuck sender.
102            send_timeout: Duration::from_secs(RATE_LIMIT_WINDOW_SECONDS * 2),
103            request_timeout: Duration::from_secs(60),
104        }
105    }
106}
107
108/// The write half, type-erased.
109///
110/// Boxing keeps [`DigLink`] non-generic while still accepting a **server-side** TLS stream (e.g.
111/// `tokio_rustls::server::TlsStream`), which cannot inhabit the `#[non_exhaustive]`,
112/// client-oriented [`MaybeTlsStream`] enum that [`DigLink::from_websocket`] takes.
113type BoxedSink =
114    Box<dyn futures_util::Sink<tungstenite::Message, Error = tungstenite::Error> + Send + Unpin>;
115
116/// The read half, type-erased — counterpart to [`BoxedSink`].
117type BoxedStream = Box<
118    dyn futures_util::Stream<Item = Result<tungstenite::Message, tungstenite::Error>>
119        + Send
120        + Unpin,
121>;
122
123/// A live websocket link to one peer, framing every message as a [`DigMessage`].
124///
125/// Cheap to clone: every clone shares one connection, one request map and one rate-limit budget.
126#[derive(Debug, Clone)]
127pub struct DigLink(Arc<LinkInner>);
128
129struct LinkInner {
130    sink: Mutex<BoxedSink>,
131    inbound_handle: JoinHandle<()>,
132    requests: Arc<RequestMap>,
133    socket_addr: SocketAddr,
134    outbound_rate_limiter: Mutex<OpcodeRateLimiter>,
135    options: LinkOptions,
136}
137
138// Hand-written because `BoxedSink`/`JoinHandle` carry no useful `Debug`; only the stable,
139// printable identity of the link is worth showing.
140impl std::fmt::Debug for LinkInner {
141    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
142        f.debug_struct("DigLink")
143            .field("socket_addr", &self.socket_addr)
144            .finish_non_exhaustive()
145    }
146}
147
148impl Drop for LinkInner {
149    fn drop(&mut self) {
150        self.inbound_handle.abort();
151    }
152}
153
154impl DigLink {
155    /// Connect to a peer at `socket_addr` over TLS.
156    #[cfg(any(feature = "native-tls", feature = "rustls"))]
157    pub async fn connect(
158        socket_addr: SocketAddr,
159        connector: Connector,
160        options: LinkOptions,
161    ) -> Result<(Self, mpsc::Receiver<DigMessage>), LinkError> {
162        Self::connect_full_uri(&format!("wss://{socket_addr}/ws"), connector, options).await
163    }
164
165    /// Connect to a peer at a full websocket URI, for example `wss://127.0.0.1:8444/ws`.
166    ///
167    /// Needed where the URI is not derivable from a socket address — an introducer reached by
168    /// hostname, or a peer behind a path-routed relay.
169    #[cfg(any(feature = "native-tls", feature = "rustls"))]
170    pub async fn connect_full_uri(
171        uri: &str,
172        connector: Connector,
173        options: LinkOptions,
174    ) -> Result<(Self, mpsc::Receiver<DigMessage>), LinkError> {
175        let (ws, _) =
176            tokio_tungstenite::connect_async_tls_with_config(uri, None, false, Some(connector))
177                .await?;
178        Self::from_websocket(ws, options)
179    }
180
181    /// Adopt an already-established **client-side** websocket.
182    ///
183    /// The peer address is recovered from the underlying stream. The connection is expected to
184    /// be TLS-secured, so that a peer id can be derived from the certificate.
185    pub fn from_websocket(
186        ws: WebSocketStream<MaybeTlsStream<TcpStream>>,
187        options: LinkOptions,
188    ) -> Result<(Self, mpsc::Receiver<DigMessage>), LinkError> {
189        let socket_addr = peer_addr_of(&ws)?;
190        let (sink, stream) = ws.split();
191        Ok(Self::from_parts(
192            Box::new(sink),
193            Box::new(stream),
194            socket_addr,
195            options,
196        ))
197    }
198
199    /// Adopt an already-established **server-side** websocket.
200    ///
201    /// An inbound acceptor already knows `socket_addr` and holds a server-side TLS stream that
202    /// cannot inhabit [`MaybeTlsStream`], hence the separate constructor generic over the
203    /// transport.
204    ///
205    /// The caller must derive the peer id from the client certificate *before* calling this: the
206    /// certificate is no longer reachable once the stream has been split.
207    pub fn from_server_websocket<S>(
208        ws: WebSocketStream<S>,
209        socket_addr: SocketAddr,
210        options: LinkOptions,
211    ) -> (Self, mpsc::Receiver<DigMessage>)
212    where
213        S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
214    {
215        let (sink, stream) = ws.split();
216        Self::from_parts(Box::new(sink), Box::new(stream), socket_addr, options)
217    }
218
219    /// Wire split halves into a live link plus its inbound channel — the one construction path
220    /// both public constructors funnel through, so client and server links behave identically.
221    fn from_parts(
222        sink: BoxedSink,
223        stream: BoxedStream,
224        socket_addr: SocketAddr,
225        options: LinkOptions,
226    ) -> (Self, mpsc::Receiver<DigMessage>) {
227        let (sender, receiver) = mpsc::channel(INBOUND_CHANNEL_CAPACITY);
228        let requests = Arc::new(RequestMap::new());
229        let requests_for_reader = requests.clone();
230
231        let inbound_handle = tokio::spawn(async move {
232            if let Err(error) = read_inbound(stream, sender, requests_for_reader).await {
233                debug!("dig link inbound loop ended: {error}");
234            }
235        });
236
237        let link = Self(Arc::new(LinkInner {
238            sink: Mutex::new(sink),
239            inbound_handle,
240            requests,
241            socket_addr,
242            outbound_rate_limiter: Mutex::new(OpcodeRateLimiter::new(
243                RATE_LIMIT_WINDOW_SECONDS,
244                options.rate_limit_factor,
245                OpcodeRateLimits::default(),
246            )),
247            options,
248        }));
249
250        (link, receiver)
251    }
252
253    /// The address of the peer on the other end.
254    #[must_use]
255    pub fn socket_addr(&self) -> SocketAddr {
256        self.0.socket_addr
257    }
258
259    /// Send a Chia-typed body with no correlation id and no expected reply.
260    pub async fn send<T>(&self, body: T) -> Result<(), LinkError>
261    where
262        T: Streamable + ChiaProtocolMessage,
263    {
264        self.send_message(DigMessage::new(
265            opcode_of::<T>()?,
266            None,
267            body.to_bytes()?.into(),
268        ))
269        .await
270    }
271
272    /// Send a DIG-band body with no correlation id and no expected reply.
273    ///
274    /// The payload is opaque to the link: framing an opcode says nothing about what its body
275    /// means, which is precisely what lets the free band (220+) carry protocols this crate does
276    /// not know about.
277    pub async fn send_dig(&self, opcode: u8, data: Bytes) -> Result<(), LinkError> {
278        self.send_message(DigMessage::new(opcode, None, data)).await
279    }
280
281    /// Send a fully-formed message, preserving its `id`.
282    ///
283    /// This is how an inbound *request* is answered: the reply must carry the requester's id,
284    /// which neither [`Self::send`] nor [`Self::send_dig`] can express.
285    /// Rate-limit refusals are handled by kind, never by blanket retry: an over-budget message
286    /// waits for the next window (up to [`LinkOptions::send_timeout`]), while a message that no
287    /// window could ever admit fails immediately. Retrying the latter is an infinite loop with
288    /// no error, which is how a caller silently disappears.
289    pub async fn send_message(&self, message: DigMessage) -> Result<(), LinkError> {
290        let deadline = tokio::time::Instant::now() + self.0.options.send_timeout;
291
292        loop {
293            match self.0.outbound_rate_limiter.lock().await.admit(&message) {
294                Admission::Admitted => break,
295                Admission::Unsendable => {
296                    return Err(LinkError::Unsendable(message.msg_type, message.data.len()))
297                }
298                Admission::Deferred => {}
299            }
300
301            if tokio::time::Instant::now() + RATE_LIMIT_BACKOFF > deadline {
302                return Err(LinkError::SendTimeout(message.msg_type));
303            }
304            tokio::time::sleep(RATE_LIMIT_BACKOFF).await;
305        }
306
307        self.0
308            .sink
309            .lock()
310            .await
311            .send(tungstenite::Message::Binary(message.to_bytes()))
312            .await?;
313        Ok(())
314    }
315
316    /// Send a Chia-typed body and await the correlated reply, unparsed.
317    pub async fn request_raw<T>(&self, body: T) -> Result<DigMessage, LinkError>
318    where
319        T: Streamable + ChiaProtocolMessage,
320    {
321        self.request_message(opcode_of::<T>()?, body.to_bytes()?.into())
322            .await
323    }
324
325    /// Send a DIG-band body and await the correlated reply, unparsed.
326    pub async fn request_dig(&self, opcode: u8, data: Bytes) -> Result<DigMessage, LinkError> {
327        self.request_message(opcode, data).await
328    }
329
330    /// Send a Chia-typed body and await a reply of exactly one expected type.
331    pub async fn request_infallible<T, B>(&self, body: B) -> Result<T, LinkError>
332    where
333        T: Streamable + ChiaProtocolMessage,
334        B: Streamable + ChiaProtocolMessage,
335    {
336        let expected = opcode_of::<T>()?;
337        let message = self.request_raw(body).await?;
338        if message.msg_type != expected {
339            return Err(LinkError::InvalidResponse(vec![expected], message.msg_type));
340        }
341        Ok(T::from_bytes(&message.data)?)
342    }
343
344    /// Send a Chia-typed body and await either the expected reply or its rejection.
345    pub async fn request_fallible<T, E, B>(&self, body: B) -> Result<Result<T, E>, LinkError>
346    where
347        T: Streamable + ChiaProtocolMessage,
348        E: Streamable + ChiaProtocolMessage,
349        B: Streamable + ChiaProtocolMessage,
350    {
351        let (accepted, rejected) = (opcode_of::<T>()?, opcode_of::<E>()?);
352        let message = self.request_raw(body).await?;
353
354        if message.msg_type == accepted {
355            Ok(Ok(T::from_bytes(&message.data)?))
356        } else if message.msg_type == rejected {
357            Ok(Err(E::from_bytes(&message.data)?))
358        } else {
359            Err(LinkError::InvalidResponse(
360                vec![accepted, rejected],
361                message.msg_type,
362            ))
363        }
364    }
365
366    /// Register a correlation id, send, and await the reply routed back to it.
367    ///
368    /// The wait is bounded by [`LinkOptions::request_timeout`]. On expiry the id is reclaimed
369    /// immediately rather than left occupying the map until the link drops — an unbounded wait
370    /// against a silent peer leaks ids as well as hanging the caller.
371    async fn request_message(&self, opcode: u8, data: Bytes) -> Result<DigMessage, LinkError> {
372        let (sender, receiver) = oneshot::channel();
373        let id = self.0.requests.insert(sender).await;
374
375        if let Err(error) = self
376            .send_message(DigMessage::new(opcode, Some(id), data))
377            .await
378        {
379            self.0.requests.remove(id).await;
380            return Err(error);
381        }
382
383        match tokio::time::timeout(self.0.options.request_timeout, receiver).await {
384            Ok(received) => Ok(received?),
385            Err(_) => {
386                self.0.requests.remove(id).await;
387                Err(LinkError::RequestTimeout(opcode))
388            }
389        }
390    }
391
392    /// Close the connection.
393    pub async fn close(&self) -> Result<(), LinkError> {
394        self.0.sink.lock().await.close().await?;
395        Ok(())
396    }
397}
398
399/// The wire opcode of a Chia message type, via its single-byte `Streamable` encoding.
400fn opcode_of<T: ChiaProtocolMessage>() -> Result<u8, LinkError> {
401    T::msg_type()
402        .to_bytes()?
403        .first()
404        .copied()
405        .ok_or(LinkError::MalformedOpcode)
406}
407
408/// Recover the peer address from a client-side websocket's underlying transport.
409fn peer_addr_of(ws: &WebSocketStream<MaybeTlsStream<TcpStream>>) -> Result<SocketAddr, LinkError> {
410    let addr = match ws.get_ref() {
411        #[cfg(feature = "native-tls")]
412        MaybeTlsStream::NativeTls(tls) => tls.get_ref().get_ref().get_ref().peer_addr()?,
413        #[cfg(feature = "rustls")]
414        MaybeTlsStream::Rustls(tls) => tls.get_ref().0.peer_addr()?,
415        MaybeTlsStream::Plain(plain) => plain.peer_addr()?,
416        _ => return Err(LinkError::UnsupportedTls),
417    };
418    Ok(addr)
419}
420
421/// The inbound loop: decode every binary frame as a [`DigMessage`] and route it.
422///
423/// Three deliberate differences from `chia_sdk_client`'s loop, all of which are the reason a DIG
424/// transport could not use it. Each removes one way for a single frame to kill a whole link:
425///
426/// 1. **Decoding never depends on the opcode being known.** `DigMessage::from_bytes` accepts any
427///    `u8`, so an inbound DIG opcode is a normal message rather than a fatal decode error.
428/// 2. **An unmatched correlation id is delivered, not fatal.** Upstream returns `Err` — which
429///    ends the loop and drops the connection — when a reply arrives for an id it is not waiting
430///    on. But ids are chosen independently by each side, so a peer's *request* id routinely
431///    collides with one of our outstanding request ids; and a hostile peer could drop the link at
432///    will by sending one unknown id. Here, anything not matching a live waiter goes to the
433///    application, which is where an inbound request belongs anyway.
434/// 3. **A frame that does not decode is skipped, not fatal.** Websocket frames are
435///    self-delimiting: tungstenite hands this loop whole `Binary` payloads, and the loop never
436///    reads a length off a byte stream itself. So an undecodable payload costs exactly that
437///    payload — there is no shared stream position for it to corrupt, and the frames after it
438///    decode normally. Ending the loop instead would restore the same one-frame kill switch that
439///    difference 2 exists to remove, and would do it *silently*: the reader stops, but every
440///    outstanding request stays parked in the [`RequestMap`] until its own deadline expires, so
441///    the caller sees an unexplained stall rather than a dropped connection.
442///
443/// ## Why unmatched frames are dropped rather than queued
444///
445/// Delivery to the application is non-blocking: a full inbound channel drops the frame instead
446/// of parking the loop. Parking looks harmless and is not — a peer that floods ids nobody is
447/// waiting on fills the channel, the loop stops, and from then on **no correlated reply is ever
448/// routed**, so every outstanding request hangs with no error. Correlated routing is the one
449/// thing on this link that has no fallback, so it is never allowed to queue behind traffic the
450/// application has not kept up with. A dropped inbound frame is a visible, recoverable loss on
451/// a best-effort transport; a wedged reader is not.
452async fn read_inbound(
453    mut stream: BoxedStream,
454    sender: mpsc::Sender<DigMessage>,
455    requests: Arc<RequestMap>,
456) -> Result<(), LinkError> {
457    use tungstenite::Message::{Binary, Close, Frame, Ping, Pong, Text};
458
459    while let Some(frame) = stream.next().await {
460        match frame? {
461            Close(..) => break,
462            Ping(..) | Pong(..) | Frame(..) => {}
463            Text(text) => warn!("dig link received an unexpected text frame: {text}"),
464            Binary(binary) => {
465                let Some(message) = DigMessage::from_bytes_owned(binary) else {
466                    warn!("dig link skipped a malformed frame");
467                    continue;
468                };
469
470                let unmatched = match message.id {
471                    Some(id) => match requests.remove(id).await {
472                        Some(waiter) => {
473                            waiter.send(message);
474                            continue;
475                        }
476                        None => message,
477                    },
478                    None => message,
479                };
480
481                if let Err(mpsc::error::TrySendError::Full(dropped)) = sender.try_send(unmatched) {
482                    warn!(
483                        "dig link dropped an inbound frame (opcode {}): the application is not \
484                         keeping up",
485                        dropped.msg_type
486                    );
487                }
488            }
489        }
490    }
491    Ok(())
492}