hoprd_api/
node.rs

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