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, OpcodeRateLimiter, OpcodeRateLimits},
43 request_map::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 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}