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}