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