Skip to main content

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;