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