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::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, Direction, OpcodeRateLimiter, OpcodeRateLimits},
43    request_map::{Correlation, Request, RequestMap},
44    Bytes, 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                // The send path: we chose not to send, so a refusal must not penalise a caller
244                // that backs off and retries.
245                Direction::Outbound,
246                RATE_LIMIT_WINDOW_SECONDS,
247                options.rate_limit_factor,
248                OpcodeRateLimits::default(),
249            )),
250            options,
251        }));
252
253        (link, receiver)
254    }
255
256    /// The address of the peer on the other end.
257    #[must_use]
258    pub fn socket_addr(&self) -> SocketAddr {
259        self.0.socket_addr
260    }
261
262    /// Send a Chia-typed body with no correlation id and no expected reply.
263    pub async fn send<T>(&self, body: T) -> Result<(), LinkError>
264    where
265        T: Streamable + ChiaProtocolMessage,
266    {
267        self.send_message(DigMessage::new(
268            opcode_of::<T>()?,
269            None,
270            body.to_bytes()?.into(),
271        ))
272        .await
273    }
274
275    /// Send a DIG-band body with no correlation id and no expected reply.
276    ///
277    /// The payload is opaque to the link: framing an opcode says nothing about what its body
278    /// means, which is precisely what lets the free band (220+) carry protocols this crate does
279    /// not know about.
280    pub async fn send_dig(&self, opcode: u8, data: Bytes) -> Result<(), LinkError> {
281        self.send_message(DigMessage::new(opcode, None, data)).await
282    }
283
284    /// Send a fully-formed message, preserving its `id`.
285    ///
286    /// This is how an inbound *request* is answered: the reply must carry the requester's id,
287    /// which neither [`Self::send`] nor [`Self::send_dig`] can express.
288    /// Rate-limit refusals are handled by kind, never by blanket retry: an over-budget message
289    /// waits for the next window (up to [`LinkOptions::send_timeout`]), while a message that no
290    /// window could ever admit fails immediately. Retrying the latter is an infinite loop with
291    /// no error, which is how a caller silently disappears.
292    pub async fn send_message(&self, message: DigMessage) -> Result<(), LinkError> {
293        let deadline = tokio::time::Instant::now() + self.0.options.send_timeout;
294
295        loop {
296            match self.0.outbound_rate_limiter.lock().await.admit(&message) {
297                Admission::Admitted => break,
298                Admission::Unsendable => {
299                    return Err(LinkError::Unsendable(message.msg_type, message.data.len()))
300                }
301                Admission::Deferred => {}
302            }
303
304            if tokio::time::Instant::now() + RATE_LIMIT_BACKOFF > deadline {
305                return Err(LinkError::SendTimeout(message.msg_type));
306            }
307            tokio::time::sleep(RATE_LIMIT_BACKOFF).await;
308        }
309
310        self.0
311            .sink
312            .lock()
313            .await
314            .send(tungstenite::Message::Binary(message.to_bytes()))
315            .await?;
316        Ok(())
317    }
318
319    /// Send a Chia-typed body and await the correlated reply of type `R`, unparsed.
320    ///
321    /// `R` names the reply that completes the request. It is required rather than inferred
322    /// because a correlation id alone cannot identify a reply: both peers allocate ids from their
323    /// own counter, so the peer's *request* can carry an id we are waiting on. See
324    /// [`RequestMap::take`](crate::request_map::RequestMap::take).
325    pub async fn request_raw<R, B>(&self, body: B) -> Result<DigMessage, LinkError>
326    where
327        R: ChiaProtocolMessage,
328        B: Streamable + ChiaProtocolMessage,
329    {
330        self.request_message(
331            opcode_of::<B>()?,
332            body.to_bytes()?.into(),
333            vec![opcode_of::<R>()?],
334        )
335        .await
336    }
337
338    /// Send a DIG-band body and await a correlated reply carrying one of `expected`, unparsed.
339    ///
340    /// The reply opcodes are the caller's to state: the link deliberately knows nothing about
341    /// which DIG opcode answers which, and guessing would put protocol semantics into a
342    /// transport. Listing more than one accommodates a protocol with an accept/reject pair.
343    pub async fn request_dig(
344        &self,
345        opcode: u8,
346        expected: &[u8],
347        data: Bytes,
348    ) -> Result<DigMessage, LinkError> {
349        self.request_message(opcode, data, expected.to_vec()).await
350    }
351
352    /// Send a Chia-typed body and await a reply of exactly one expected type.
353    pub async fn request_infallible<T, B>(&self, body: B) -> Result<T, LinkError>
354    where
355        T: Streamable + ChiaProtocolMessage,
356        B: Streamable + ChiaProtocolMessage,
357    {
358        let expected = opcode_of::<T>()?;
359        let message = self
360            .request_message(opcode_of::<B>()?, body.to_bytes()?.into(), vec![expected])
361            .await?;
362        // Defence in depth: `request_message` only resolves on a declared opcode, so this
363        // cannot fire today. It is kept because the guarantee lives in another module, and a
364        // silently mis-parsed body is a worse failure than a redundant comparison.
365        if message.msg_type != expected {
366            return Err(LinkError::InvalidResponse(vec![expected], message.msg_type));
367        }
368        Ok(T::from_bytes(&message.data)?)
369    }
370
371    /// Send a Chia-typed body and await either the expected reply or its rejection.
372    pub async fn request_fallible<T, E, B>(&self, body: B) -> Result<Result<T, E>, LinkError>
373    where
374        T: Streamable + ChiaProtocolMessage,
375        E: Streamable + ChiaProtocolMessage,
376        B: Streamable + ChiaProtocolMessage,
377    {
378        let (accepted, rejected) = (opcode_of::<T>()?, opcode_of::<E>()?);
379        let message = self
380            .request_message(
381                opcode_of::<B>()?,
382                body.to_bytes()?.into(),
383                vec![accepted, rejected],
384            )
385            .await?;
386
387        if message.msg_type == accepted {
388            Ok(Ok(T::from_bytes(&message.data)?))
389        } else if message.msg_type == rejected {
390            Ok(Err(E::from_bytes(&message.data)?))
391        } else {
392            // Unreachable for the same reason as in `request_infallible`, and kept for the same
393            // reason: the exhaustive arm must not guess which of the two bodies to parse.
394            Err(LinkError::InvalidResponse(
395                vec![accepted, rejected],
396                message.msg_type,
397            ))
398        }
399    }
400
401    /// Register a correlation id, send, and await the reply routed back to it.
402    ///
403    /// The wait is bounded by [`LinkOptions::request_timeout`]. On expiry the id is reclaimed
404    /// immediately rather than left occupying the map until the link drops — an unbounded wait
405    /// against a silent peer leaks ids as well as hanging the caller.
406    async fn request_message(
407        &self,
408        opcode: u8,
409        data: Bytes,
410        expected: Vec<u8>,
411    ) -> Result<DigMessage, LinkError> {
412        let (sender, receiver) = oneshot::channel();
413        let id = self.0.requests.insert(sender, expected).await;
414
415        if let Err(error) = self
416            .send_message(DigMessage::new(opcode, Some(id), data))
417            .await
418        {
419            self.0.requests.cancel(id).await;
420            return Err(error);
421        }
422
423        match tokio::time::timeout(self.0.options.request_timeout, receiver).await {
424            Ok(received) => Ok(received?),
425            Err(_) => Err(
426                match self
427                    .0
428                    .requests
429                    .cancel(id)
430                    .await
431                    .and_then(Request::into_diagnosis)
432                {
433                    // The peer did answer on this id — just never with anything this request asked
434                    // for. Reporting a bare timeout would describe a silent peer, which is a
435                    // different fault with a different remedy, and would give a peer-penalty layer
436                    // nothing to charge the sender for.
437                    Some((expected, found)) => LinkError::InvalidResponse(expected, found),
438                    None => LinkError::RequestTimeout(opcode),
439                },
440            ),
441        }
442    }
443
444    /// Close the connection.
445    pub async fn close(&self) -> Result<(), LinkError> {
446        self.0.sink.lock().await.close().await?;
447        Ok(())
448    }
449}
450
451/// The wire opcode of a Chia message type, via its single-byte `Streamable` encoding.
452fn opcode_of<T: ChiaProtocolMessage>() -> Result<u8, LinkError> {
453    T::msg_type()
454        .to_bytes()?
455        .first()
456        .copied()
457        .ok_or(LinkError::MalformedOpcode)
458}
459
460/// Recover the peer address from a client-side websocket's underlying transport.
461fn peer_addr_of(ws: &WebSocketStream<MaybeTlsStream<TcpStream>>) -> Result<SocketAddr, LinkError> {
462    let addr = match ws.get_ref() {
463        #[cfg(feature = "native-tls")]
464        MaybeTlsStream::NativeTls(tls) => tls.get_ref().get_ref().get_ref().peer_addr()?,
465        #[cfg(feature = "rustls")]
466        MaybeTlsStream::Rustls(tls) => tls.get_ref().0.peer_addr()?,
467        MaybeTlsStream::Plain(plain) => plain.peer_addr()?,
468        _ => return Err(LinkError::UnsupportedTls),
469    };
470    Ok(addr)
471}
472
473/// The inbound loop: decode every binary frame as a [`DigMessage`] and route it.
474///
475/// Three deliberate differences from `chia_sdk_client`'s loop, all of which are the reason a DIG
476/// transport could not use it. Each removes one way for a single frame to kill a whole link:
477///
478/// 1. **Decoding never depends on the opcode being known.** `DigMessage::from_bytes` accepts any
479///    `u8`, so an inbound DIG opcode is a normal message rather than a fatal decode error.
480/// 2. **An unmatched frame is delivered, not fatal.** Upstream returns `Err` — which ends the
481///    loop and drops the connection — when a reply arrives for an id it is not waiting on. But
482///    ids are chosen independently by each side, so a peer's *request* id routinely collides with
483///    one of our outstanding request ids; and a hostile peer could drop the link at will by
484///    sending one unknown id. Here, anything not matching a live waiter goes to the application,
485///    which is where an inbound request belongs anyway.
486///
487///    "Matching" means the id AND the opcode. A live id is not on its own evidence that a frame
488///    answers our request: because both counters start at 0, the peer's own request frequently
489///    carries an id we are waiting on, and completing the waiter with it loses the request and
490///    fails the reply in one step. A waiter is therefore *answered* only by an opcode it declared.
491///
492///    A frame carrying a live id under an undeclared opcode is delivered to the application and
493///    the waiter stays parked, because at this point the two situations that produce one are
494///    indistinguishable: an honest peer's own request that happened to allocate the same id,
495///    whose real reply is still in flight, and a peer answering with junk, whose real reply
496///    never will. They are literally the same bytes, so failing the waiter on arrival would
497///    resolve the ambiguity in favour of the second and let any peer abort an outstanding
498///    request by guessing a low id.
499///
500///    The collision is instead RECORDED against the waiter, which costs nothing if the real
501///    reply arrives. If the deadline expires instead, the caller is told what did arrive —
502///    [`LinkError::InvalidResponse`] naming the declared opcodes and the offending one — rather
503///    than a bare `RequestTimeout` that would describe a silent peer, a different fault with a
504///    different remedy and nothing for a peer-penalty layer to charge.
505/// 3. **A frame that does not decode is skipped, not fatal.** Websocket frames are
506///    self-delimiting: tungstenite hands this loop whole `Binary` payloads, and the loop never
507///    reads a length off a byte stream itself. So an undecodable payload costs exactly that
508///    payload — there is no shared stream position for it to corrupt, and the frames after it
509///    decode normally. Ending the loop instead would restore the same one-frame kill switch that
510///    difference 2 exists to remove, and would do it *silently*: the reader stops, but every
511///    outstanding request stays parked in the [`RequestMap`] until its own deadline expires, so
512///    the caller sees an unexplained stall rather than a dropped connection.
513///
514/// ## Why unmatched frames are dropped rather than queued
515///
516/// Delivery to the application is non-blocking: a full inbound channel drops the frame instead
517/// of parking the loop. Parking looks harmless and is not — a peer that floods ids nobody is
518/// waiting on fills the channel, the loop stops, and from then on **no correlated reply is ever
519/// routed**, so every outstanding request hangs with no error. Correlated routing is the one
520/// thing on this link that has no fallback, so it is never allowed to queue behind traffic the
521/// application has not kept up with. A dropped inbound frame is a visible, recoverable loss on
522/// a best-effort transport; a wedged reader is not.
523async fn read_inbound(
524    mut stream: BoxedStream,
525    sender: mpsc::Sender<DigMessage>,
526    requests: Arc<RequestMap>,
527) -> Result<(), LinkError> {
528    use tungstenite::Message::{Binary, Close, Frame, Ping, Pong, Text};
529
530    while let Some(frame) = stream.next().await {
531        match frame? {
532            Close(..) => break,
533            Ping(..) | Pong(..) | Frame(..) => {}
534            Text(text) => warn!("dig link received an unexpected text frame: {text}"),
535            Binary(binary) => {
536                let Some(message) = DigMessage::from_bytes_owned(binary) else {
537                    warn!("dig link skipped a malformed frame");
538                    continue;
539                };
540
541                let unmatched = match message.id {
542                    Some(id) => match requests.take(id, message.msg_type).await {
543                        Correlation::Answer(waiter) => {
544                            waiter.send(message);
545                            continue;
546                        }
547                        Correlation::Undeclared => {
548                            warn!(
549                                "opcode {} arrived on live request id {id}, undeclared",
550                                message.msg_type
551                            );
552                            message
553                        }
554                        Correlation::Unknown => message,
555                    },
556                    None => message,
557                };
558
559                if let Err(mpsc::error::TrySendError::Full(dropped)) = sender.try_send(unmatched) {
560                    warn!(
561                        "dig link dropped an inbound frame (opcode {}): the application is not \
562                         keeping up",
563                        dropped.msg_type
564                    );
565                }
566            }
567        }
568    }
569    Ok(())
570}