hoprd_api/
node.rs

1use std::{collections::HashMap, sync::Arc};
2
3use axum::{
4    extract::{Json, Query, State},
5    http::status::StatusCode,
6    response::IntoResponse,
7};
8use futures::{StreamExt, stream::FuturesUnordered};
9use hopr_lib::{Address, AsUnixTimestamp, GraphExportConfig, Health, Multiaddr, prelude::Hash};
10use serde::{Deserialize, Serialize};
11use serde_with::{DisplayFromStr, serde_as};
12
13use crate::{
14    ApiError, ApiErrorStatus, BASE_PATH, InternalState, checksum_address_serializer, option_checksum_address_serializer,
15};
16
17#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
18#[schema(example = json!({
19        "version": "2.1.0",
20    }))]
21#[serde(rename_all = "camelCase")]
22/// Running node version.
23pub(crate) struct NodeVersionResponse {
24    #[schema(example = "2.1.0")]
25    version: String,
26}
27
28/// Get the release version of the running node.
29#[utoipa::path(
30        get,
31        path = const_format::formatcp!("{BASE_PATH}/node/version"),
32        description = "Get the release version of the running node",
33        responses(
34            (status = 200, description = "Fetched node version", body = NodeVersionResponse),
35            (status = 401, description = "Invalid authorization token.", body = ApiError),
36        ),
37        security(
38            ("api_token" = []),
39            ("bearer_token" = [])
40        ),
41        tag = "Node"
42    )]
43pub(super) async fn version() -> impl IntoResponse {
44    let version = hopr_lib::constants::APP_VERSION.to_string();
45    (StatusCode::OK, Json(NodeVersionResponse { version })).into_response()
46}
47
48/// Get the configuration of the running node.
49#[utoipa::path(
50    get,
51    path = const_format::formatcp!("{BASE_PATH}/node/configuration"),
52    description = "Get the configuration of the running node",
53    responses(
54        (status = 200, description = "Fetched node configuration", body = HashMap<String, String>, example = json!({
55        "network": "anvil-localhost",
56        "provider": "http://127.0.0.1:8545",
57        "hoprToken": "0x9a676e781a523b5d0c0e43731313a708cb607508",
58        "hoprChannels": "0x9a9f2ccfde556a7e9ff0848998aa4a0cfd8863ae",
59        "...": "..."
60        })),
61        (status = 401, description = "Invalid authorization token.", body = ApiError),
62    ),
63    security(
64        ("api_token" = []),
65        ("bearer_token" = [])
66    ),
67    tag = "Configuration"
68    )]
69pub(super) async fn configuration(State(state): State<Arc<InternalState>>) -> impl IntoResponse {
70    (StatusCode::OK, Json(state.hoprd_cfg.clone())).into_response()
71}
72
73#[derive(Debug, Clone, Deserialize, utoipa::ToSchema, utoipa::IntoParams)]
74#[into_params(parameter_in = Query)]
75#[schema(example = json!({
76        "quality": 0.7
77    }))]
78/// Quality information for a peer.
79pub(crate) struct NodePeersQueryRequest {
80    #[serde(default)]
81    #[schema(required = false, example = 0.7)]
82    /// Minimum peer quality to be included in the response.
83    quality: f64,
84}
85
86#[derive(Debug, Default, Clone, Serialize, utoipa::ToSchema)]
87#[schema(example = json!({
88    "sent": 10,
89    "success": 10
90}))]
91#[serde(rename_all = "camelCase")]
92/// Heartbeat information for a peer.
93pub(crate) struct HeartbeatInfo {
94    #[schema(example = 10)]
95    sent: u64,
96    #[schema(example = 10)]
97    success: u64,
98}
99
100#[serde_as]
101#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
102#[serde(rename_all = "camelCase")]
103#[schema(example = json!({
104    "address": "0xb4ce7e6e36ac8b01a974725d5ba730af2b156fbe",
105    "multiaddr": "/ip4/178.12.1.9/tcp/19092",
106    "heartbeats": {
107        "sent": 10,
108        "success": 10
109    },
110    "lastSeen": 1690000000,
111    "lastSeenLatency": 100,
112    "quality": 0.7,
113    "backoff": 0.5,
114    "isNew": true,
115    "reportedVersion": "2.1.0"
116}))]
117/// All information about a known peer.
118pub(crate) struct PeerInfo {
119    #[serde(serialize_with = "option_checksum_address_serializer")]
120    #[schema(value_type = Option<String>, example = "0xb4ce7e6e36ac8b01a974725d5ba730af2b156fbe")]
121    address: Option<Address>,
122    #[serde_as(as = "Option<DisplayFromStr>")]
123    #[schema(value_type = Option<String>, example = "/ip4/178.12.1.9/tcp/19092")]
124    multiaddr: Option<Multiaddr>,
125    #[schema(example = json!({
126        "sent": 10,
127        "success": 10
128    }))]
129    heartbeats: HeartbeatInfo,
130    #[schema(example = 1690000000)]
131    last_seen: u128,
132    #[schema(example = 100)]
133    last_seen_latency: u128,
134    #[schema(example = 0.7)]
135    quality: f64,
136    #[schema(example = 0.5)]
137    backoff: f64,
138    #[schema(example = true)]
139    is_new: bool,
140}
141
142#[serde_as]
143#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
144#[schema(example = json!({
145    "address": "0xb4ce7e6e36ac8b01a974725d5ba730af2b156fbe",
146    "multiaddr": "/ip4/178.12.1.9/tcp/19092"
147}))]
148#[serde(rename_all = "camelCase")]
149/// Represents a peer that has been announced on-chain.
150pub(crate) struct AnnouncedPeer {
151    #[serde(serialize_with = "checksum_address_serializer")]
152    #[schema(value_type = String, example = "0xb4ce7e6e36ac8b01a974725d5ba730af2b156fbe")]
153    address: Address,
154    #[serde_as(as = "Option<DisplayFromStr>")]
155    #[schema(value_type = Option<String>, example = "/ip4/178.12.1.9/tcp/19092")]
156    multiaddr: Option<Multiaddr>,
157}
158
159#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
160#[serde(rename_all = "camelCase")]
161#[schema(example = json!({
162    "connected": [{
163        "address": "0xb4ce7e6e36ac8b01a974725d5ba730af2b156fbe",
164        "multiaddr": "/ip4/178.12.1.9/tcp/19092",
165        "heartbeats": {
166            "sent": 10,
167            "success": 10
168        },
169        "lastSeen": 1690000000,
170        "lastSeenLatency": 100,
171        "quality": 0.7,
172        "backoff": 0.5,
173        "isNew": true,
174        "reportedVersion": "2.1.0"
175    }],
176    "announced": [{
177        "address": "0xb4ce7e6e36ac8b01a974725d5ba730af2b156fbe",
178        "multiaddr": "/ip4/178.12.1.9/tcp/19092"
179    }]
180}))]
181/// All connected and announced peers.
182pub(crate) struct NodePeersResponse {
183    #[schema(example = json!([{
184        "address": "0xb4ce7e6e36ac8b01a974725d5ba730af2b156fbe",
185        "multiaddr": "/ip4/178.12.1.9/tcp/19092",
186        "heartbeats": {
187            "sent": 10,
188            "success": 10
189        },
190        "lastSeen": 1690000000,
191        "lastSeenLatency": 100,
192        "quality": 0.7,
193        "backoff": 0.5,
194        "isNew": true,
195        "reportedVersion": "2.1.0"
196    }]))]
197    connected: Vec<PeerInfo>,
198    #[schema(example = json!([{
199        "address": "0xb4ce7e6e36ac8b01a974725d5ba730af2b156fbe",
200        "multiaddr": "/ip4/178.12.1.9/tcp/19092"
201    }]))]
202    announced: Vec<AnnouncedPeer>,
203}
204
205/// Lists information for `connected peers` and `announced peers`.
206///
207/// Connected peers are nodes which are connected to the node while announced peers are
208/// nodes which have announced to the network.
209///
210/// Optionally pass `quality` parameter to get only peers with higher or equal quality
211/// to the specified value.
212#[utoipa::path(
213        get,
214        path = const_format::formatcp!("{BASE_PATH}/node/peers"),
215        description = "Lists information for connected and announced peers",
216        params(NodePeersQueryRequest),
217        responses(
218            (status = 200, description = "Successfully returned observed peers", body = NodePeersResponse),
219            (status = 400, description = "Failed to extract a valid quality parameter", body = ApiError),
220            (status = 401, description = "Invalid authorization token.", body = ApiError),
221        ),
222        security(
223            ("api_token" = []),
224            ("bearer_token" = [])
225        ),
226        tag = "Node"
227    )]
228pub(super) async fn peers(
229    Query(NodePeersQueryRequest { quality }): Query<NodePeersQueryRequest>,
230    State(state): State<Arc<InternalState>>,
231) -> Result<impl IntoResponse, ApiError> {
232    if !(0.0f64..=1.0f64).contains(&quality) {
233        return Ok((StatusCode::BAD_REQUEST, ApiErrorStatus::InvalidQuality).into_response());
234    }
235
236    let hopr = state.hopr.clone();
237
238    let all_network_peers = futures::stream::iter(hopr.network_connected_peers().await?)
239        .filter_map(|peer| {
240            let hopr = hopr.clone();
241
242            async move {
243                if let Ok(Some(info)) = hopr.network_peer_info(&peer).await {
244                    let avg_quality = info.get_average_quality();
245                    if avg_quality >= quality {
246                        Some((peer, info))
247                    } else {
248                        None
249                    }
250                } else {
251                    None
252                }
253            }
254        })
255        .filter_map(|(peer_id, info)| {
256            let hopr = hopr.clone();
257
258            async move {
259                let address = hopr.peerid_to_chain_key(&peer_id).await.ok().flatten();
260
261                // WARNING: Only in Providence and Saint-Louis are all peers public
262                let multiaddresses = hopr.network_observed_multiaddresses(&peer_id).await;
263
264                Some((address, multiaddresses, info))
265            }
266        })
267        .map(|(address, mas, info)| PeerInfo {
268            address,
269            multiaddr: mas.first().cloned(),
270            heartbeats: HeartbeatInfo {
271                sent: info.heartbeats_sent,
272                success: info.heartbeats_succeeded,
273            },
274            last_seen: info.last_seen.as_unix_timestamp().as_millis(),
275            last_seen_latency: info.last_seen_latency.as_millis() / 2,
276            quality: info.get_average_quality(),
277            backoff: info.backoff,
278            is_new: info.heartbeats_sent == 0u64,
279        })
280        .collect::<Vec<_>>()
281        .await;
282
283    let announced_peers = hopr
284        .accounts_announced_on_chain()
285        .await?
286        .into_iter()
287        .map(|announced| async move {
288            AnnouncedPeer {
289                address: announced.chain_addr,
290                multiaddr: announced.get_multiaddr(),
291            }
292        })
293        .collect::<FuturesUnordered<_>>()
294        .collect()
295        .await;
296
297    let body = NodePeersResponse {
298        connected: all_network_peers,
299        announced: announced_peers,
300    };
301
302    Ok((StatusCode::OK, Json(body)).into_response())
303}
304
305#[derive(Debug, Clone, Deserialize, Default, utoipa::IntoParams, utoipa::ToSchema)]
306#[into_params(parameter_in = Query)]
307#[serde(default, rename_all = "camelCase")]
308#[schema(example = json!({
309        "ignoreDisconnectedComponents": true,
310        "ignoreNonOpenedChannels": true,
311        "only3HopPaths": true,
312        "rawGraph": true
313    }))]
314/// Query parameters for the channel graph export.
315pub(crate) struct GraphExportQuery {
316    /// If set, nodes that are not connected to this node (via open channels) will not be exported.
317    /// This setting automatically implies `ignore_non_opened_channels`.
318    #[schema(required = false)]
319    #[serde(default)]
320    pub ignore_disconnected_components: bool,
321    /// Do not export channels that are not in the `Open` state.
322    #[schema(required = false)]
323    #[serde(default)]
324    pub ignore_non_opened_channels: bool,
325    /// Show only nodes that are accessible via 3-hops (via open channels) from this node.
326    #[schema(required = false)]
327    #[serde(default)]
328    pub only_3_hop_paths: bool,
329    /// Export the entire graph in raw JSON format, that can be later
330    /// used to load the graph into e.g., a unit test.
331    ///
332    /// Note that `ignore_disconnected_components` and `ignore_non_opened_channels` are ignored.
333    #[schema(required = false)]
334    #[serde(default)]
335    pub raw_graph: bool,
336}
337
338impl From<GraphExportQuery> for GraphExportConfig {
339    fn from(value: GraphExportQuery) -> Self {
340        Self {
341            ignore_disconnected_components: value.ignore_disconnected_components,
342            ignore_non_opened_channels: value.ignore_non_opened_channels,
343            only_3_hop_accessible_nodes: value.only_3_hop_paths,
344        }
345    }
346}
347
348#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
349#[schema(example = json!({
350        "graph": "
351        ...
352        242 -> 381 [ label = 'Open channel 0x82a72e271cdedd56c29e970ced3517ba93b679869c729112b5a56fa08698df8f; stake=100000000000000000 HOPR; score=None; status=open;' ]
353        ...",
354    }))]
355#[serde(rename_all = "camelCase")]
356/// Response body for the channel graph export.
357pub(crate) struct NodeGraphResponse {
358    graph: String,
359}
360
361/// Retrieve node's channel graph in DOT or JSON format.
362#[utoipa::path(
363    get,
364    path = const_format::formatcp!("{BASE_PATH}/node/graph"),
365    description = "Retrieve node's channel graph in DOT or JSON format",
366    params(GraphExportQuery),
367    responses(
368            (status = 200, description = "Fetched channel graph", body = NodeGraphResponse),
369            (status = 401, description = "Invalid authorization token.", body = ApiError),
370    ),
371    security(
372            ("api_token" = []),
373            ("bearer_token" = [])
374    ),
375    tag = "Node"
376)]
377pub(super) async fn channel_graph(
378    State(state): State<Arc<InternalState>>,
379    Query(args): Query<GraphExportQuery>,
380) -> impl IntoResponse {
381    if args.raw_graph {
382        match state.hopr.export_raw_channel_graph().await {
383            Ok(raw_graph) => (StatusCode::OK, Json(NodeGraphResponse { graph: raw_graph })).into_response(),
384            Err(error) => (
385                StatusCode::UNPROCESSABLE_ENTITY,
386                ApiErrorStatus::UnknownFailure(error.to_string()),
387            )
388                .into_response(),
389        }
390    } else {
391        (StatusCode::OK, state.hopr.export_channel_graph(args.into()).await).into_response()
392    }
393}
394
395#[serde_as]
396#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
397#[schema(example = json!({
398        "announcedAddress": [
399            "/ip4/10.0.2.100/tcp/19092"
400        ],
401        "chain": "anvil-localhost",
402        "provider": "http://127.0.0.1:8545",
403        "channelClosurePeriod": 15,
404        "connectivityStatus": "Green",
405        "hoprChannels": "0x9a9f2ccfde556a7e9ff0848998aa4a0cfd8863ae",
406        "hoprManagementModule": "0xa51c1fc2f0d1a1b8494ed1fe312d7c3a78ed91c0",
407        "hoprNetworkRegistry": "0x3aa5ebb10dc797cac828524e59a333d0a371443c",
408        "hoprNodeSafe": "0x42bc901b1d040f984ed626eff550718498a6798a",
409        "hoprNodeSafeRegistry": "0x0dcd1bf9a1b36ce34237eeafef220932846bcd82",
410        "hoprToken": "0x9a676e781a523b5d0c0e43731313a708cb607508",
411        "isEligible": true,
412        "listeningAddress": [
413            "/ip4/10.0.2.100/tcp/19092"
414        ],
415        "network": "anvil-localhost",
416        "indexerBlock": 123456,
417        "indexerChecksum": "0000000000000000000000000000000000000000000000000000000000000000",
418        "indexBlockPrevChecksum": 0,
419        "indexerLastLogBlock": 123450,
420        "indexerLastLogChecksum": "cfde556a7e9ff0848998aa4a9a9f2ccfde556a7e9ff0848998aa4a0cfd8863ae",
421        "isIndexerCorrupted": false,
422    }))]
423#[serde(rename_all = "camelCase")]
424/// Information about the current node. Covers network, addresses, eligibility, connectivity status, contracts addresses
425/// and indexer state.
426pub(crate) struct NodeInfoResponse {
427    #[schema(value_type = String, example = "anvil-localhost")]
428    network: String,
429    #[serde_as(as = "Vec<DisplayFromStr>")]
430    #[schema(value_type = Vec<String>, example = json!(["/ip4/10.0.2.100/tcp/19092"]))]
431    announced_address: Vec<Multiaddr>,
432    #[serde_as(as = "Vec<DisplayFromStr>")]
433    #[schema(value_type = Vec<String>, example = json!(["/ip4/10.0.2.100/tcp/19092"]))]
434    listening_address: Vec<Multiaddr>,
435    #[schema(example = "anvil-localhost")]
436    chain: String,
437    #[schema(example = "http://127.0.0.1:8545")]
438    provider: String,
439    #[serde(serialize_with = "checksum_address_serializer")]
440    #[schema(value_type = String, example = "0x9a676e781a523b5d0c0e43731313a708cb607508")]
441    hopr_token: Address,
442    #[serde(serialize_with = "checksum_address_serializer")]
443    #[schema(value_type = String, example = "0x9a9f2ccfde556a7e9ff0848998aa4a0cfd8863ae")]
444    hopr_channels: Address,
445    #[serde(serialize_with = "checksum_address_serializer")]
446    #[schema(value_type = String, example = "0x3aa5ebb10dc797cac828524e59a333d0a371443c")]
447    hopr_network_registry: Address,
448    #[serde(serialize_with = "checksum_address_serializer")]
449    #[schema(value_type = String, example = "0x0dcd1bf9a1b36ce34237eeafef220932846bcd82")]
450    hopr_node_safe_registry: Address,
451    #[serde(serialize_with = "checksum_address_serializer")]
452    #[schema(value_type = String, example = "0xa51c1fc2f0d1a1b8494ed1fe312d7c3a78ed91c0")]
453    hopr_management_module: Address,
454    #[serde(serialize_with = "checksum_address_serializer")]
455    #[schema(value_type = String, example = "0x42bc901b1d040f984ed626eff550718498a6798a")]
456    hopr_node_safe: Address,
457    #[schema(example = true)]
458    is_eligible: bool,
459    #[serde_as(as = "DisplayFromStr")]
460    #[schema(value_type = String, example = "Green")]
461    connectivity_status: Health,
462    /// Channel closure period in seconds
463    #[schema(example = 15)]
464    channel_closure_period: u64,
465    #[schema(example = 123456)]
466    indexer_block: u32,
467    #[schema(example = 123450)]
468    indexer_last_log_block: u32,
469    #[serde_as(as = "DisplayFromStr")]
470    #[schema(value_type = String, example = "cfde556a7e9ff0848998aa4a9a9f2ccfde556a7e9ff0848998aa4a0cfd8863ae")]
471    indexer_last_log_checksum: Hash,
472    #[schema(example = true)]
473    is_indexer_corrupted: bool,
474}
475
476/// Get information about this HOPR Node.
477#[utoipa::path(
478        get,
479        path = const_format::formatcp!("{BASE_PATH}/node/info"),
480        description = "Get information about this HOPR Node",
481        responses(
482            (status = 200, description = "Fetched node version", body = NodeInfoResponse),
483            (status = 422, description = "Unknown failure", body = ApiError)
484        ),
485        security(
486            ("api_token" = []),
487            ("bearer_token" = [])
488        ),
489        tag = "Node"
490    )]
491pub(super) async fn info(State(state): State<Arc<InternalState>>) -> Result<impl IntoResponse, ApiError> {
492    let hopr = state.hopr.clone();
493
494    let chain_config = hopr.chain_config();
495    let safe_config = hopr.get_safe_config();
496    let network = hopr.network();
497
498    let indexer_state_info = match hopr.get_indexer_state().await {
499        Ok(info) => info,
500        Err(error) => return Ok((StatusCode::UNPROCESSABLE_ENTITY, ApiErrorStatus::from(error)).into_response()),
501    };
502
503    // If one channel or more are corrupted, we consider the indexer as corrupted.
504    let is_indexer_corrupted = hopr
505        .corrupted_channels()
506        .await
507        .map(|channels| !channels.is_empty())
508        .unwrap_or_default();
509
510    match hopr.get_channel_closure_notice_period().await {
511        Ok(channel_closure_notice_period) => {
512            let body = NodeInfoResponse {
513                network,
514                announced_address: hopr.local_multiaddresses(),
515                listening_address: hopr.local_multiaddresses(),
516                chain: chain_config.id,
517                provider: hopr.get_provider(),
518                hopr_token: chain_config.token,
519                hopr_channels: chain_config.channels,
520                hopr_network_registry: chain_config.network_registry,
521                hopr_node_safe_registry: chain_config.node_safe_registry,
522                hopr_management_module: chain_config.module_implementation,
523                hopr_node_safe: safe_config.safe_address,
524                is_eligible: true,
525                connectivity_status: hopr.network_health().await,
526                channel_closure_period: channel_closure_notice_period.as_secs(),
527                indexer_block: indexer_state_info.latest_block_number,
528                indexer_last_log_block: indexer_state_info.latest_log_block_number,
529                indexer_last_log_checksum: indexer_state_info.latest_log_checksum,
530                is_indexer_corrupted,
531            };
532
533            Ok((StatusCode::OK, Json(body)).into_response())
534        }
535        Err(error) => Ok((StatusCode::UNPROCESSABLE_ENTITY, ApiErrorStatus::from(error)).into_response()),
536    }
537}
538
539#[serde_as]
540#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
541#[serde(rename_all = "camelCase")]
542#[schema(example = json!({
543        "isEligible": true,
544        "multiaddrs": ["/ip4/10.0.2.100/tcp/19091"]
545}))]
546/// Reachable entry node information
547pub(crate) struct EntryNode {
548    #[serde_as(as = "Vec<DisplayFromStr>")]
549    #[schema(value_type = Vec<String>, example = json!(["/ip4/10.0.2.100/tcp/19091"]))]
550    multiaddrs: Vec<Multiaddr>,
551    #[schema(example = true)]
552    is_eligible: bool,
553}
554
555/// List all known entry nodes with multiaddrs and eligibility.
556#[utoipa::path(
557        get,
558        path = const_format::formatcp!("{BASE_PATH}/node/entry-nodes"),
559        description = "List all known entry nodes with multiaddrs and eligibility",
560        responses(
561            (status = 200, description = "Fetched public nodes' information", body = HashMap<String, EntryNode>, example = json!({
562                "0x188c4462b75e46f0c7262d7f48d182447b93a93c": {
563                    "isEligible": true,
564                    "multiaddrs": ["/ip4/10.0.2.100/tcp/19091"]
565                }
566            })),
567            (status = 401, description = "Invalid authorization token.", body = ApiError),
568            (status = 422, description = "Unknown failure", body = ApiError)
569        ),
570        security(
571            ("api_token" = []),
572            ("bearer_token" = [])
573        ),
574        tag = "Node"
575    )]
576pub(super) async fn entry_nodes(State(state): State<Arc<InternalState>>) -> Result<impl IntoResponse, ApiError> {
577    let hopr = state.hopr.clone();
578
579    match hopr.get_public_nodes().await {
580        Ok(nodes) => {
581            let mut body = HashMap::new();
582            for (_, address, mas) in nodes.into_iter() {
583                body.insert(
584                    address.to_string(),
585                    EntryNode {
586                        multiaddrs: mas,
587                        is_eligible: true,
588                    },
589                );
590            }
591
592            Ok((StatusCode::OK, Json(body)).into_response())
593        }
594        Err(error) => Ok((StatusCode::UNPROCESSABLE_ENTITY, ApiErrorStatus::from(error)).into_response()),
595    }
596}