Skip to main content

hoprd_api/
network.rs

1use std::{collections::HashMap, sync::Arc};
2
3use axum::{
4    extract::{Json, Query, State},
5    http::status::StatusCode,
6    response::IntoResponse,
7};
8use hopr_lib::{
9    Address, HoprBalance, Multiaddr,
10    api::graph::{
11        EdgeLinkObservable,
12        traits::{EdgeNetworkObservableRead, EdgeObservableRead},
13    },
14};
15use serde_with::{DisplayFromStr, serde_as};
16
17use crate::{ApiError, ApiErrorStatus, BASE_PATH, InternalState, checksum_address_serializer};
18
19#[serde_as]
20#[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)]
21#[schema(example = json!({
22    "price": "0.03 wxHOPR"
23}))]
24#[serde(rename_all = "camelCase")]
25/// Contains the ticket price in HOPR tokens.
26pub(crate) struct TicketPriceResponse {
27    /// Price of the ticket in HOPR tokens.
28    #[serde_as(as = "DisplayFromStr")]
29    #[schema(value_type = String, example = "0.03 wxHOPR")]
30    price: HoprBalance,
31}
32
33/// Gets the current ticket price.
34#[utoipa::path(
35        get,
36        path = const_format::formatcp!("{BASE_PATH}/network/price"),
37        description = "Get the current ticket price",
38        responses(
39            (status = 200, description = "Current ticket price", body = TicketPriceResponse),
40            (status = 401, description = "Invalid authorization token.", body = ApiError),
41            (status = 422, description = "Unknown failure", body = ApiError)
42        ),
43        security(
44            ("api_token" = []),
45            ("bearer_token" = [])
46        ),
47        tag = "Network"
48    )]
49pub(super) async fn price(State(state): State<Arc<InternalState>>) -> impl IntoResponse {
50    let hopr = state.hopr.clone();
51
52    match hopr.get_ticket_price().await {
53        Ok(price) => (StatusCode::OK, Json(TicketPriceResponse { price })).into_response(),
54        Err(e) => (StatusCode::UNPROCESSABLE_ENTITY, ApiErrorStatus::from(e)).into_response(),
55    }
56}
57
58#[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)]
59#[schema(example = json!({
60    "probability": 0.5
61}))]
62#[serde(rename_all = "camelCase")]
63/// Contains the winning probability of a ticket.
64pub(crate) struct TicketProbabilityResponse {
65    #[schema(example = 0.5)]
66    /// Winning probability of a ticket.
67    probability: f64,
68}
69
70/// Gets the current minimum incoming ticket winning probability defined by the network.
71#[utoipa::path(
72        get,
73        path = const_format::formatcp!("{BASE_PATH}/network/probability"),
74        description = "Get the current minimum incoming ticket winning probability defined by the network",
75        responses(
76            (status = 200, description = "Minimum incoming ticket winning probability defined by the network", body = TicketProbabilityResponse),
77            (status = 401, description = "Invalid authorization token.", body = ApiError),
78            (status = 422, description = "Unknown failure", body = ApiError)
79        ),
80        security(
81            ("api_token" = []),
82            ("bearer_token" = [])
83        ),
84        tag = "Network"
85    )]
86pub(super) async fn probability(State(state): State<Arc<InternalState>>) -> impl IntoResponse {
87    let hopr = state.hopr.clone();
88
89    match hopr.get_minimum_incoming_ticket_win_probability().await {
90        Ok(p) => (
91            StatusCode::OK,
92            Json(TicketProbabilityResponse { probability: p.into() }),
93        )
94            .into_response(),
95        Err(e) => (StatusCode::UNPROCESSABLE_ENTITY, ApiErrorStatus::from(e)).into_response(),
96    }
97}
98
99// ── Connected peers endpoint ────────────────────────────────────────────────
100
101#[serde_as]
102#[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)]
103#[serde(rename_all = "camelCase")]
104#[schema(example = json!({
105    "address": "0xb4ce7e6e36ac8b01a974725d5ba730af2b156fbe",
106    "probeRate": 0.476,
107    "lastUpdate": 1690000000000_u128,
108    "averageLatency": 100,
109    "score": 0.7
110}))]
111/// Immediate observation data for a connected peer.
112pub(crate) struct ConnectedPeerResponse {
113    #[serde(serialize_with = "checksum_address_serializer")]
114    #[schema(value_type = String, example = "0xb4ce7e6e36ac8b01a974725d5ba730af2b156fbe")]
115    address: Address,
116    #[schema(example = 0.476)]
117    probe_rate: f64,
118    /// Epoch milliseconds of the last observation update.
119    #[schema(example = 1690000000000_u128)]
120    last_update: u128,
121    /// Average latency in milliseconds, if available.
122    #[schema(example = 100)]
123    average_latency: Option<u128>,
124    #[schema(example = 0.7)]
125    score: f64,
126}
127
128/// Lists peers with immediate observation data from the network graph.
129///
130/// Returns only peers that have at least one edge with immediate QoS data,
131/// representing nodes the current node has direct transport observations for.
132#[utoipa::path(
133    get,
134    path = const_format::formatcp!("{BASE_PATH}/network/connected"),
135    description = "List connected peers with immediate observation data from the network graph",
136    responses(
137        (status = 200, description = "Connected peers with immediate observations", body = Vec<ConnectedPeerResponse>),
138        (status = 401, description = "Invalid authorization token.", body = ApiError),
139        (status = 422, description = "Unknown failure", body = ApiError)
140    ),
141    security(
142        ("api_token" = []),
143        ("bearer_token" = [])
144    ),
145    tag = "Network"
146)]
147pub(super) async fn connected(State(state): State<Arc<InternalState>>) -> impl IntoResponse {
148    let hopr = state.hopr.clone();
149    let graph = hopr.graph();
150    let edges = graph.connected_edges();
151
152    let me_key = graph.me();
153
154    // Collect peers that are connected (is_connected == true) with immediate QoS data.
155    let mut peers = Vec::new();
156    for (src, dst, obs) in &edges {
157        if src != me_key {
158            continue;
159        }
160        let Some(imm) = obs.immediate_qos() else {
161            continue;
162        };
163        if !imm.is_connected() {
164            continue;
165        }
166
167        let address = match hopr.peerid_to_chain_key(&(*dst).into()).await {
168            Ok(Some(addr)) => addr,
169            _ => continue,
170        };
171
172        peers.push(ConnectedPeerResponse {
173            address,
174            probe_rate: imm.average_probe_rate(),
175            last_update: obs.last_update().as_millis(),
176            average_latency: imm.average_latency().map(|l| l.as_millis()),
177            score: obs.score(),
178        });
179    }
180
181    (StatusCode::OK, Json(peers)).into_response()
182}
183
184// ── Announced peers endpoint ────────────────────────────────────────────────
185
186/// How a peer announcement was discovered.
187#[derive(
188    Debug,
189    Clone,
190    Copy,
191    PartialEq,
192    Eq,
193    serde::Serialize,
194    serde::Deserialize,
195    strum::Display,
196    strum::EnumString,
197    utoipa::ToSchema,
198)]
199#[strum(serialize_all = "lowercase", ascii_case_insensitive)]
200#[serde(rename_all = "lowercase")]
201#[schema(example = "chain")]
202pub(crate) enum AnnouncementOriginResponse {
203    /// Announced via on-chain registration.
204    Chain,
205    /// Discovered via DHT.
206    Dht,
207}
208
209impl From<hopr_lib::AnnouncementOrigin> for AnnouncementOriginResponse {
210    fn from(origin: hopr_lib::AnnouncementOrigin) -> Self {
211        match origin {
212            hopr_lib::AnnouncementOrigin::Chain => Self::Chain,
213            hopr_lib::AnnouncementOrigin::DHT => Self::Dht,
214        }
215    }
216}
217
218#[serde_as]
219#[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)]
220#[schema(example = json!({
221    "address": "0xb4ce7e6e36ac8b01a974725d5ba730af2b156fbe",
222    "multiaddrs": ["/ip4/178.12.1.9/tcp/19092"],
223    "origin": "chain"
224}))]
225#[serde(rename_all = "camelCase")]
226/// A peer that has been announced.
227pub(crate) struct AnnouncedPeerResponse {
228    #[serde(serialize_with = "checksum_address_serializer")]
229    #[schema(value_type = String, example = "0xb4ce7e6e36ac8b01a974725d5ba730af2b156fbe")]
230    address: Address,
231    #[serde_as(as = "Vec<DisplayFromStr>")]
232    #[schema(value_type = Vec<String>, example = json!(["/ip4/178.12.1.9/tcp/19092"]))]
233    multiaddrs: Vec<Multiaddr>,
234    #[schema(example = "chain")]
235    origin: AnnouncementOriginResponse,
236}
237
238/// Lists all announced peers.
239#[utoipa::path(
240    get,
241    path = const_format::formatcp!("{BASE_PATH}/network/announced"),
242    description = "List all announced peers",
243    responses(
244        (status = 200, description = "Announced peers", body = Vec<AnnouncedPeerResponse>),
245        (status = 401, description = "Invalid authorization token.", body = ApiError),
246        (status = 422, description = "Unknown failure", body = ApiError)
247    ),
248    security(
249        ("api_token" = []),
250        ("bearer_token" = [])
251    ),
252    tag = "Network"
253)]
254pub(super) async fn announced(State(state): State<Arc<InternalState>>) -> impl IntoResponse {
255    let hopr = state.hopr.clone();
256
257    match hopr.announced_peers().await {
258        Ok(peers) => {
259            let response: Vec<AnnouncedPeerResponse> = peers
260                .into_iter()
261                .map(|peer| AnnouncedPeerResponse {
262                    address: peer.address,
263                    multiaddrs: peer.multiaddresses,
264                    origin: peer.origin.into(),
265                })
266                .collect();
267            (StatusCode::OK, Json(response)).into_response()
268        }
269        Err(e) => (StatusCode::UNPROCESSABLE_ENTITY, ApiErrorStatus::from(e)).into_response(),
270    }
271}
272
273// ── Graph DOT endpoint ──────────────────────────────────────────────────────
274
275#[derive(Debug, Default, Copy, Clone, serde::Deserialize, utoipa::IntoParams, utoipa::ToSchema)]
276#[into_params(parameter_in = Query)]
277#[serde(default, rename_all = "camelCase")]
278#[schema(example = json!({ "reachableOnly": false }))]
279/// Parameters for the network graph endpoint.
280pub(crate) struct GraphQueryRequest {
281    /// When true, only include edges reachable from this node via directed
282    /// traversal. Disconnected subgraphs that cannot be routed through are excluded.
283    #[schema(required = false)]
284    #[serde(default)]
285    reachable_only: bool,
286}
287
288/// Returns the network graph in DOT (Graphviz) format.
289///
290/// Only connected nodes (those with at least one edge) are included.
291/// Nodes are labeled by their on-chain (Ethereum) address when resolvable,
292/// falling back to the offchain public key hex representation.
293/// Edges carry quality annotations: score, latency (ms), and capacity when available.
294///
295/// Pass `?reachableOnly=true` to limit the output to edges reachable from this node.
296#[utoipa::path(
297    get,
298    path = const_format::formatcp!("{BASE_PATH}/network/graph"),
299    description = "Get the network graph in DOT (Graphviz) format",
300    params(GraphQueryRequest),
301    responses(
302        (status = 200, description = "DOT representation of the network graph", body = String, content_type = "text/plain"),
303        (status = 401, description = "Invalid authorization token.", body = ApiError),
304    ),
305    security(
306        ("api_token" = []),
307        ("bearer_token" = [])
308    ),
309    tag = "Network"
310)]
311pub(super) async fn graph(
312    State(state): State<Arc<InternalState>>,
313    Query(query): Query<GraphQueryRequest>,
314) -> impl IntoResponse {
315    let hopr = &state.hopr;
316    let graph = hopr.graph();
317
318    let edges = if query.reachable_only {
319        graph.reachable_edges()
320    } else {
321        graph.connected_edges()
322    };
323
324    // Build offchain key → onchain address mapping for all nodes in the graph.
325    let mut unique_keys = std::collections::HashSet::new();
326    for (src, dst, _) in &edges {
327        unique_keys.insert(*src);
328        unique_keys.insert(*dst);
329    }
330
331    let mut key_to_addr: HashMap<hopr_lib::OffchainPublicKey, String> = HashMap::new();
332    for key in &unique_keys {
333        let label = match hopr.peerid_to_chain_key(&(*key).into()).await {
334            Ok(Some(addr)) => addr.to_string(),
335            _ => key.to_string(),
336        };
337        key_to_addr.insert(*key, label);
338    }
339
340    let label_fn = |key: &hopr_lib::OffchainPublicKey| key_to_addr.get(key).cloned().unwrap_or_else(|| key.to_string());
341
342    let dot = hopr_network_graph::render::render_edges_as_dot(&edges, &label_fn);
343
344    (StatusCode::OK, [(axum::http::header::CONTENT_TYPE, "text/plain")], dot).into_response()
345}
346
347#[cfg(test)]
348mod tests {
349    use std::str::FromStr;
350
351    use super::*;
352
353    #[test]
354    fn announcement_origin_response_should_serialize_as_lowercase_string() {
355        assert_eq!(
356            serde_json::to_string(&AnnouncementOriginResponse::Chain).unwrap(),
357            "\"chain\""
358        );
359        assert_eq!(
360            serde_json::to_string(&AnnouncementOriginResponse::Dht).unwrap(),
361            "\"dht\""
362        );
363    }
364
365    #[test]
366    fn announcement_origin_response_should_deserialize_from_lowercase_string() {
367        assert_eq!(
368            serde_json::from_str::<AnnouncementOriginResponse>("\"chain\"").unwrap(),
369            AnnouncementOriginResponse::Chain
370        );
371        assert_eq!(
372            serde_json::from_str::<AnnouncementOriginResponse>("\"dht\"").unwrap(),
373            AnnouncementOriginResponse::Dht
374        );
375    }
376
377    #[test]
378    fn announcement_origin_response_should_deserialize_case_insensitively_via_strum() {
379        assert_eq!(
380            AnnouncementOriginResponse::from_str("Chain").unwrap(),
381            AnnouncementOriginResponse::Chain
382        );
383        assert_eq!(
384            AnnouncementOriginResponse::from_str("CHAIN").unwrap(),
385            AnnouncementOriginResponse::Chain
386        );
387        assert_eq!(
388            AnnouncementOriginResponse::from_str("DHT").unwrap(),
389            AnnouncementOriginResponse::Dht
390        );
391    }
392
393    #[test]
394    fn announcement_origin_response_should_display_as_lowercase() {
395        assert_eq!(AnnouncementOriginResponse::Chain.to_string(), "chain");
396        assert_eq!(AnnouncementOriginResponse::Dht.to_string(), "dht");
397    }
398
399    #[test]
400    fn announcement_origin_response_should_reject_invalid_string() {
401        assert!(AnnouncementOriginResponse::from_str("invalid").is_err());
402    }
403
404    #[test]
405    fn chain_origin_should_convert_from_domain_type() {
406        assert_eq!(
407            AnnouncementOriginResponse::from(hopr_lib::AnnouncementOrigin::Chain),
408            AnnouncementOriginResponse::Chain
409        );
410    }
411
412    #[test]
413    fn dht_origin_should_convert_from_domain_type() {
414        assert_eq!(
415            AnnouncementOriginResponse::from(hopr_lib::AnnouncementOrigin::DHT),
416            AnnouncementOriginResponse::Dht
417        );
418    }
419
420    #[test]
421    fn announced_peer_response_should_serialize_with_origin() -> anyhow::Result<()> {
422        let response = AnnouncedPeerResponse {
423            address: Address::default(),
424            multiaddrs: vec!["/ip4/1.2.3.4/tcp/9091".parse()?],
425            origin: AnnouncementOriginResponse::Chain,
426        };
427
428        let json = serde_json::to_value(&response)?;
429        assert_eq!(json["origin"], "chain");
430        assert!(json["multiaddrs"].is_array());
431        assert!(json["address"].is_string());
432        Ok(())
433    }
434}