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