decdn_client/probe.rs
1//! Reusable `cdn/probe/v1` client.
2//!
3//! [`probe_once`](crate::probe::probe_once) performs one probe round trip against a remote node
4//! over a plain QUIC handshake. The transport lives here rather than inline
5//! in the `decdn probe` command so the node's cache-miss probe-collection loop
6//! (DHT `FIND_VALUE` → parallel probes, [ADR 001]) can reuse it; that loop
7//! runs in `decdn_node::node_origin`. The reused mechanism is
8//! **transport-only**:
9//! echoed-field correlation (ADR 005) and `slash_sig` validation (ADR 014
10//! §1) are the caller's responsibility, not performed here — see
11//! [`probe_once`](crate::probe::probe_once).
12//!
13//! [ADR 001]: ../../../adr/001-network.md
14
15use std::time::{Duration, Instant};
16
17use alloy::primitives::{Address, B256, Signature};
18use alloy::sol_types::Eip712Domain;
19use decdn_incentive::ProbeSlashData;
20use decdn_protocol::{
21 ALPN_PROBE, ProbeMessage, ProbeResponseExt, decode_message, encode_message,
22 message::{ProbeRequest, ProbeResponse},
23 parse_probe_response_ext, read_frame, write_frame,
24};
25use iroh::endpoint::{Connection, RecvStream, SendStream};
26use iroh::{Endpoint, EndpointAddr};
27
28use crate::rate_limited::transport_error;
29
30/// The round-trip time one probe measured.
31#[derive(Debug, Clone, Copy, PartialEq)]
32pub struct ProbeRtt {
33 /// The round-trip time in milliseconds.
34 pub ms: f64,
35 /// Whether hole punching selected a direct path before the probe ended.
36 /// When `true`, `ms` is no higher than that path's RTT. When `false`, `ms`
37 /// is the exchange RTT, and the exchange can have ridden the relay, so
38 /// `ms` says nothing about how far away the peer is.
39 pub direct: bool,
40}
41
42/// Run one probe round trip and return the decoded response together with
43/// the measured round-trip time.
44///
45/// The round-trip time is the smaller of the request/response exchange on the
46/// established connection and the estimate of the direct path hole punching
47/// selects after it. It never covers the connect:
48/// a cold dial pays handshake, relay, and hole-punch time that says nothing
49/// about the path the paid stream then uses, so timing it would rank a warm
50/// peer far ahead of an equally near cold one.
51///
52/// `timeout` bounds the connect and the exchange. The wait for a direct path
53/// gets only what is left of `timeout`, capped at 500 ms, and it never fails a
54/// probe that already has its answer: when the wait runs out, the probe keeps
55/// the exchange RTT and reports [`ProbeRtt::direct`] as `false`. A cold dial
56/// that rides the relay therefore still returns its answer to a caller with a
57/// tight budget, such as the node's upstream probe round.
58///
59/// The decoded [`ProbeResponse`] is returned without echoed-field
60/// correlation or `slash_sig` validation: the requester-side obligations —
61/// echoed `hash`/`timestamp_us` correlation (ADR 005) and the mandatory
62/// `slash_sig` shape check (`ProbeResponse::validate`, ADR 014 §1) — are the
63/// caller's, so the node's probe-collection loop and the CLI each apply their
64/// own policy on the same transport. The only check performed internally is the
65/// protocol-level one that the server sent a `Response` (not a `Request`)
66/// variant.
67///
68/// # Errors
69///
70/// A node that sheds the probe at the transport with `APP_ERR_RATE_LIMITED`
71/// surfaces as the [`UpstreamRateLimited`](crate::UpstreamRateLimited) sentinel
72/// (recoverable with `downcast_ref` through any added context), so a caller can
73/// tell a peer refusing work from a peer that could not be reached. Every other
74/// failure is a plain message naming the stage.
75pub async fn probe_once(
76 endpoint: &Endpoint,
77 target: EndpointAddr,
78 hash: [u8; 32],
79 timestamp_us: u64,
80 timeout: Duration,
81) -> anyhow::Result<(ProbeResponse, ProbeResponseExt, ProbeRtt)> {
82 let (conn, resp, rtt) = exchange_within(
83 timeout,
84 async {
85 let conn = endpoint
86 .connect(target, ALPN_PROBE)
87 .await
88 .map_err(|e| transport_error("connect failed", e))?;
89 let started = Instant::now();
90 let resp = exchange(&conn, hash, timestamp_us).await?;
91 Ok((conn, resp, started.elapsed()))
92 },
93 async |conn: &Connection| direct_path_rtt(conn).await,
94 )
95 .await?;
96
97 conn.close(0u32.into(), b"probe-done");
98
99 Ok((
100 resp.0,
101 resp.1,
102 ProbeRtt {
103 ms: rtt.rtt.as_secs_f64() * 1000.0,
104 direct: rtt.direct,
105 },
106 ))
107}
108
109/// The longest [`probe_once`] waits after its exchange for hole punching to
110/// select a direct path. The probe's `timeout` can cut the wait shorter.
111const DIRECT_PATH_GRACE: Duration = Duration::from_millis(500);
112
113/// [`ProbeRtt`] before the conversion to milliseconds.
114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115struct PathRtt {
116 /// The smaller of the exchange RTT and the direct-path RTT.
117 rtt: Duration,
118 /// Whether a direct path reported its RTT in time.
119 direct: bool,
120}
121
122/// Run `exchange` within `budget`, then let `direct` report a direct-path RTT
123/// for what is left of `budget`, at most [`DIRECT_PATH_GRACE`].
124///
125/// `exchange` yields the connection, the response, and the exchange RTT. Only
126/// `exchange` can fail the probe. A `direct` that does not answer in time
127/// leaves the exchange RTT in place. A direct-path RTT replaces the exchange
128/// RTT only when it is lower.
129async fn exchange_within<C, R>(
130 budget: Duration,
131 exchange: impl Future<Output = anyhow::Result<(C, R, Duration)>>,
132 direct: impl AsyncFnOnce(&C) -> Option<Duration>,
133) -> anyhow::Result<(C, R, PathRtt)> {
134 let deadline = tokio::time::Instant::now() + budget;
135 let (conn, resp, exchanged) = tokio::time::timeout_at(deadline, exchange)
136 .await
137 .map_err(|_| anyhow::anyhow!("probe timed out after {} ms", budget.as_millis()))??;
138 let grace = deadline
139 .saturating_duration_since(tokio::time::Instant::now())
140 .min(DIRECT_PATH_GRACE);
141 let rtt = match tokio::time::timeout(grace, direct(&conn))
142 .await
143 .ok()
144 .flatten()
145 {
146 Some(direct) => PathRtt {
147 rtt: direct.min(exchanged),
148 direct: true,
149 },
150 None => PathRtt {
151 rtt: exchanged,
152 direct: false,
153 },
154 };
155 Ok((conn, resp, rtt))
156}
157
158/// The RTT estimate of the direct path `conn` selects, or `None` when the
159/// paths stream ends without one. A first exchange rides the relay until hole
160/// punching finishes, and the relay path says nothing about the direct path
161/// the paid stream then uses. A path is selected only once validated, so its
162/// estimate comes from real samples. The caller bounds the wait.
163async fn direct_path_rtt(conn: &Connection) -> Option<Duration> {
164 use futures_util::StreamExt as _;
165 let mut snapshots = conn.paths_stream();
166 while let Some(paths) = snapshots.next().await {
167 if let Some(path) = paths.iter().find(|path| path.is_ip() && path.is_selected()) {
168 return Some(path.rtt());
169 }
170 }
171 None
172}
173
174/// Verify a [`ProbeResponse`] the way a requester must before acting on it
175/// (ADR 005 §Correlation, ADR 014 §1).
176///
177/// Three checks, in the order a cheap one should precede an expensive one:
178/// the value invariants (`ProbeResponse::validate` — non-zero rate, `slash_sig`
179/// length), the echoed-field correlation that ties the answer to *this* request,
180/// and finally the secp256k1 recovery of `slash_sig` to `expected_signer`.
181///
182/// The recovery is the load-bearing one and the reason this exists. `slash_sig`
183/// is what makes a quoted rate non-repudiable: a node that quotes `R₁` on probe
184/// and then charges `R₂ > R₁` within the 30-second window is slashable on the
185/// strength of these two signed messages alone. A response whose signature is
186/// never recovered is not evidence of anything — it is an unattributed claim, so
187/// a node could win selection on a rate it never committed to and face nothing
188/// when it fails to honour it.
189///
190/// Requester-local policy, not an attributable fault: a caller that gets an
191/// `Err` here MUST drop the response and move on, exactly as it would for a
192/// timeout, and MUST NOT feed it to reputation. The signature is the only thing
193/// binding a response to a peer, so a failed verification cannot attribute
194/// anything to that peer — an unverified message is precisely a message with no
195/// established author.
196///
197/// # Errors
198///
199/// A failed invariant, a mismatched echoed `hash`/`timestamp_us`, an unparseable
200/// signature, or a signature that recovers to any address but `expected_signer`.
201pub fn verify_probe_response(
202 resp: &ProbeResponse,
203 expected_signer: Address,
204 slash_domain: &Eip712Domain,
205 hash: [u8; 32],
206 timestamp_us: u64,
207) -> anyhow::Result<()> {
208 resp.validate()
209 .map_err(|e| anyhow::anyhow!("invalid probe response: {e}"))?;
210 if resp.body.hash != hash {
211 anyhow::bail!("probe response hash does not match the request");
212 }
213 if resp.body.timestamp_us != timestamp_us {
214 anyhow::bail!("probe response timestamp_us not echoed");
215 }
216 let sig = Signature::try_from(resp.slash_sig.as_slice())
217 .map_err(|e| anyhow::anyhow!("slash_sig parse: {e}"))?;
218 ProbeSlashData {
219 hash: B256::from(resp.body.hash),
220 has_blob: resp.body.has_blob,
221 rate_per_mb: resp.body.rate_per_mb,
222 timestamp_us: resp.body.timestamp_us,
223 }
224 .verify_signer(&sig, expected_signer, slash_domain)
225 .map_err(|e| anyhow::anyhow!("probe slash_sig verification failed: {e}"))?;
226 Ok(())
227}
228
229/// Open a fresh bidirectional stream, send the request, read the response.
230async fn exchange(
231 conn: &Connection,
232 hash: [u8; 32],
233 timestamp_us: u64,
234) -> anyhow::Result<(ProbeResponse, ProbeResponseExt)> {
235 let (mut send, recv) = conn
236 .open_bi()
237 .await
238 .map_err(|e| transport_error("open_bi failed", e))?;
239 write_request(&mut send, hash, timestamp_us).await?;
240 read_response(recv).await
241}
242
243/// Frame and write a `ProbeMessage::Request`, then finish the send half.
244async fn write_request(
245 send: &mut SendStream,
246 hash: [u8; 32],
247 timestamp_us: u64,
248) -> anyhow::Result<()> {
249 let payload = encode_message(&ProbeMessage::Request(ProbeRequest { hash, timestamp_us }))
250 .map_err(|e| anyhow::anyhow!("encode request: {e}"))?;
251 write_frame(send, &payload)
252 .await
253 .map_err(|e| transport_error("write request", e))?;
254 send.finish()
255 .map_err(|e| anyhow::anyhow!("finish stream: {e}"))?;
256 Ok(())
257}
258
259/// Read and decode one framed `ProbeMessage::Response`. Echoed-field
260/// correlation (`hash`/`timestamp_us`) and the mandatory `slash_sig` shape
261/// check are the caller's obligation (ADR 005 / ADR 014 §1), not enforced
262/// here — see [`probe_once`].
263async fn read_response(mut recv: RecvStream) -> anyhow::Result<(ProbeResponse, ProbeResponseExt)> {
264 let frame = read_frame(&mut recv)
265 .await
266 .map_err(|e| transport_error("read response", e))?;
267 let (msg, rest) = decode_message::<ProbeMessage>(&frame)
268 .map_err(|e| anyhow::anyhow!("decode response: {e}"))?;
269 match msg {
270 ProbeMessage::Response(r) => {
271 // Trailing bytes are the Tier-1 extension, not junk: parse what this
272 // build knows and skip the rest (ADR 013 §Tier 1).
273 let ext = parse_probe_response_ext(rest)
274 .map_err(|e| anyhow::anyhow!("decode response ext: {e}"))?;
275 Ok((r, ext))
276 }
277 ProbeMessage::Request(_) => {
278 anyhow::bail!("unexpected ProbeMessage::Request from server");
279 }
280 }
281}
282
283#[cfg(test)]
284mod tests;