diff --git a/nym-api/nym-api-requests/src/models.rs b/nym-api/nym-api-requests/src/models.rs index 4367a71ff2..be88d48087 100644 --- a/nym-api/nym-api-requests/src/models.rs +++ b/nym-api/nym-api-requests/src/models.rs @@ -29,6 +29,7 @@ use std::fmt::{Debug, Display, Formatter}; use std::net::IpAddr; use std::ops::{Deref, DerefMut}; use std::{fmt, time::Duration}; +use thiserror::Error; use time::{Date, OffsetDateTime}; use utoipa::{IntoParams, ToResponse, ToSchema}; @@ -192,6 +193,15 @@ pub struct MixNodeBondAnnotated { pub ip_addresses: Vec, } +#[derive(Debug, Error)] +pub enum MalformedNodeBond { + #[error("the associated ed25519 identity key is malformed")] + InvalidEd25519Key, + + #[error("the associated x25519 sphinx key is malformed")] + InvalidX25519Key, +} + impl MixNodeBondAnnotated { pub fn mix_node(&self) -> &MixNode { &self.mixnode_details.bond_information.mix_node @@ -212,6 +222,32 @@ impl MixNodeBondAnnotated { pub fn version(&self) -> &str { &self.mixnode_details.bond_information.mix_node.version } + + pub fn try_to_skimmed_node(&self, role: NodeRole) -> Result { + Ok(SkimmedNode { + node_id: self.mix_id(), + ed25519_identity_pubkey: self + .identity_key() + .parse() + .map_err(|_| MalformedNodeBond::InvalidEd25519Key)?, + ip_addresses: self.ip_addresses.clone(), + mix_port: self.mix_node().mix_port, + x25519_sphinx_pubkey: self + .mix_node() + .sphinx_key + .parse() + .map_err(|_| MalformedNodeBond::InvalidX25519Key)?, + epoch_role: role, + supported_roles: DeclaredRoles { + mixnode: true, + entry: false, + exit_nr: false, + exit_ipr: false, + }, + entry: None, + performance: self.node_performance.last_24h, + }) + } } #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, ToSchema)] @@ -242,6 +278,39 @@ impl GatewayBondAnnotated { pub fn owner(&self) -> &Addr { self.gateway_bond.bond.owner() } + + pub fn try_to_skimmed_node(&self, role: NodeRole) -> Result { + Ok(SkimmedNode { + node_id: self.gateway_bond.node_id, + ip_addresses: self.ip_addresses.clone(), + ed25519_identity_pubkey: self + .gateway_bond + .gateway + .identity_key + .parse() + .map_err(|_| MalformedNodeBond::InvalidEd25519Key)?, + mix_port: self.gateway_bond.bond.gateway.mix_port, + x25519_sphinx_pubkey: self + .gateway_bond + .gateway + .sphinx_key + .parse() + .map_err(|_| MalformedNodeBond::InvalidX25519Key)?, + epoch_role: role, + supported_roles: DeclaredRoles { + mixnode: false, + entry: true, + exit_nr: false, + exit_ipr: false, + }, + entry: Some(BasicEntryInformation { + hostname: None, + ws_port: self.gateway_bond.bond.gateway.clients_port, + wss_port: None, + }), + performance: self.node_performance.last_24h, + }) + } } #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] @@ -640,7 +709,7 @@ impl NymNodeDescription { } } - pub fn try_to_skimmed_node(&self, role: NodeRole, performance: Performance) -> SkimmedNode { + pub fn to_skimmed_node(&self, role: NodeRole, performance: Performance) -> SkimmedNode { let keys = &self.description.host_information.keys; let entry = if self.description.declared_role.entry { Some(self.entry_information()) @@ -650,15 +719,15 @@ impl NymNodeDescription { SkimmedNode { node_id: self.node_id, - ed25519_identity_pubkey: keys.ed25519.clone(), + ed25519_identity_pubkey: keys.ed25519, ip_addresses: self.description.host_information.ip_address.clone(), mix_port: self.description.mix_port(), - x25519_sphinx_pubkey: keys.x25519.clone(), + x25519_sphinx_pubkey: keys.x25519, // we can't use the declared roles, we have to take whatever was provided in the contract. // why? say this node COULD operate as an exit, but it might be the case the contract decided // to assign it an ENTRY role only. we have to use that one instead. epoch_role: role, - supported_roles: self.description.declared_role.into(), + supported_roles: self.description.declared_role, entry, performance, } diff --git a/nym-api/nym-api-requests/src/nym_nodes.rs b/nym-api/nym-api-requests/src/nym_nodes.rs index 75eaffd3ca..bb7e9abe6c 100644 --- a/nym-api/nym-api-requests/src/nym_nodes.rs +++ b/nym-api/nym-api-requests/src/nym_nodes.rs @@ -1,11 +1,8 @@ // Copyright 2024 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 -use crate::models::{ - DeclaredRoles, GatewayBondAnnotated, MixNodeBondAnnotated, NymNodeData, - OffsetDateTimeJsonSchemaWrapper, -}; -use crate::pagination::PaginatedResponse; +use crate::models::{DeclaredRoles, NymNodeData, OffsetDateTimeJsonSchemaWrapper}; +use crate::pagination::{PaginatedResponse, Pagination}; use nym_crypto::asymmetric::ed25519::serde_helpers::bs58_ed25519_pubkey; use nym_crypto::asymmetric::x25519::serde_helpers::bs58_x25519_pubkey; use nym_crypto::asymmetric::{ed25519, x25519}; @@ -43,6 +40,25 @@ pub struct PaginatedCachedNodesResponse { pub nodes: PaginatedResponse, } +impl PaginatedCachedNodesResponse { + pub fn new_full( + refreshed_at: impl Into, + nodes: Vec, + ) -> Self { + PaginatedCachedNodesResponse { + refreshed_at: refreshed_at.into(), + nodes: PaginatedResponse { + pagination: Pagination { + total: nodes.len(), + page: 0, + size: nodes.len(), + }, + data: nodes, + }, + } + } +} + #[derive(Clone, Copy, Debug, Serialize, Deserialize, schemars::JsonSchema, utoipa::ToSchema)] #[serde(rename_all = "kebab-case")] pub enum NodeRoleQueryParam { @@ -125,86 +141,8 @@ impl SkimmedNode { _ => None, } } - - // pub fn from_described_gateway( - // annotated: &GatewayBondAnnotated, - // description: Option<&NymNodeData>, - // ) -> Self { - // let mut base: SkimmedNode = annotated.into(); - // let Some(description) = description else { - // return base; - // }; - // - // // safety: the conversion always sets the entry field - // let entry = base.entry.as_mut().unwrap(); - // entry - // .hostname - // .clone_from(&description.host_information.hostname); - // entry.ws_port = description.mixnet_websockets.ws_port; - // entry.wss_port = description.mixnet_websockets.wss_port; - // - // // always prefer self-described data - // if !description.host_information.ip_address.is_empty() { - // base.ip_addresses - // .clone_from(&description.host_information.ip_address) - // } - // - // base.supported_roles = description.declared_role; - // - // base - // } } -// impl<'a> From<&'a MixNodeBondAnnotated> for SkimmedNode { -// fn from(value: &'a MixNodeBondAnnotated) -> Self { -// todo!() -// // SkimmedNode { -// // node_id: value.mix_id(), -// // ed25519_identity_pubkey: value.identity_key().to_string(), -// // ip_addresses: value.ip_addresses.clone(), -// // mix_port: value.mix_node().mix_port, -// // x25519_sphinx_pubkey: value.mix_node().sphinx_key.clone(), -// // epoch_role: NodeRole::Mixnode { -// // layer: value.mixnode_details.bond_information.layer.into(), -// // }, -// // supported_roles: DeclaredRoles { -// // mixnode: true, -// // entry: false, -// // exit_nr: false, -// // exit_ipr: false, -// // }, -// // entry: None, -// // performance: value.node_performance.last_24h, -// // } -// } -// } - -// impl<'a> From<&'a GatewayBondAnnotated> for SkimmedNode { -// fn from(value: &'a GatewayBondAnnotated) -> Self { -// todo!() -// // SkimmedNode { -// // node_id: value.gateway_bond.node_id, -// // ip_addresses: value.ip_addresses.clone(), -// // ed25519_identity_pubkey: value.gateway_bond.bond.identity().clone(), -// // mix_port: value.gateway_bond.bond.gateway.mix_port, -// // x25519_sphinx_pubkey: value.gateway_bond.bond.gateway.sphinx_key.clone(), -// // epoch_role: NodeRole::EntryGateway, -// // supported_roles: DeclaredRoles { -// // mixnode: false, -// // entry: true, -// // exit_nr: false, -// // exit_ipr: false, -// // }, -// // entry: Some(BasicEntryInformation { -// // hostname: None, -// // ws_port: value.gateway_bond.bond.gateway.clients_port, -// // wss_port: None, -// // }), -// // performance: value.node_performance.last_24h, -// // } -// } -// } - // an intermediate variant that exposes additional data such as noise keys but without // the full fat of the self-described data #[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, ToSchema)] diff --git a/nym-api/src/main.rs b/nym-api/src/main.rs index 8534879327..4ee4205ea2 100644 --- a/nym-api/src/main.rs +++ b/nym-api/src/main.rs @@ -1,6 +1,9 @@ // Copyright 2020-2023 - Nym Technologies SA // SPDX-License-Identifier: GPL-3.0-only +#![warn(clippy::todo)] +#![warn(clippy::dbg_macro)] + use crate::epoch_operations::EpochAdvancer; use crate::support::cli; use crate::support::storage; diff --git a/nym-api/src/network_monitor/monitor/preparer.rs b/nym-api/src/network_monitor/monitor/preparer.rs index 3567563e63..94c8d116f5 100644 --- a/nym-api/src/network_monitor/monitor/preparer.rs +++ b/nym-api/src/network_monitor/monitor/preparer.rs @@ -9,7 +9,7 @@ use crate::support::caching::cache::SharedCache; use nym_api_requests::legacy::{LegacyGatewayBondWithId, LegacyMixNodeBondWithLayer}; use nym_api_requests::models::NymNodeDescription; use nym_crypto::asymmetric::{encryption, identity}; -use nym_mixnet_contract_common::{LegacyMixLayer, NodeId, RewardedSet}; +use nym_mixnet_contract_common::{LegacyMixLayer, NodeId}; use nym_node_tester_utils::node::TestableNode; use nym_node_tester_utils::NodeTester; use nym_sphinx::acknowledgements::AckKey; @@ -344,7 +344,7 @@ impl PacketPreparer { let mixing_nym_nodes = descriptions.mixing_nym_nodes(); // last I checked `gatewaying` wasn't a word : ) - let gateway_capable_nym_nodes = descriptions.gateway_capable_nym_nodes(); + let gateway_capable_nym_nodes = descriptions.entry_capable_nym_nodes(); let mut rng = thread_rng(); @@ -527,7 +527,7 @@ impl PacketPreparer { .await .expect("the cache must have been initialised!"); let mixing_nym_nodes = descriptions.mixing_nym_nodes(); - let gateway_capable_nym_nodes = descriptions.gateway_capable_nym_nodes(); + let gateway_capable_nym_nodes = descriptions.entry_capable_nym_nodes(); let (mixnodes, invalid_mixnodes) = self.filter_outdated_and_malformed_mixnodes(mixnodes); let (gateways, invalid_gateways) = self.filter_outdated_and_malformed_gateways(gateways); diff --git a/nym-api/src/node_describe_cache/mod.rs b/nym-api/src/node_describe_cache/mod.rs index 3e66fd132e..28d231639a 100644 --- a/nym-api/src/node_describe_cache/mod.rs +++ b/nym-api/src/node_describe_cache/mod.rs @@ -11,7 +11,6 @@ use async_trait::async_trait; use futures::{stream, StreamExt}; use nym_api_requests::models::{DescribedNodeType, NymNodeData, NymNodeDescription}; use nym_config::defaults::DEFAULT_NYM_NODE_HTTP_PORT; -use nym_crypto::asymmetric::{ed25519, encryption, identity, x25519}; use nym_mixnet_contract_common::{LegacyMixLayer, NodeId}; use nym_node_requests::api::client::{NymNodeApiClientError, NymNodeApiClientExt}; use nym_topology::gateway::GatewayConversionError; @@ -159,25 +158,30 @@ impl DescribedNodes { self.nodes.get(node_id) } - pub fn all_nodes<'a>(&'a self) -> impl Iterator + 'a { + pub fn all_nodes(&self) -> impl Iterator { self.nodes.values() } - pub fn mixing_nym_nodes<'a>(&'a self) -> impl Iterator + 'a { + pub fn mixing_nym_nodes(&self) -> impl Iterator { self.nodes .values() .filter(|n| n.contract_node_type == DescribedNodeType::NymNode) .filter(|n| n.description.declared_role.mixnode) } - pub fn gateway_capable_nym_nodes<'a>( - &'a self, - ) -> impl Iterator + 'a { + pub fn entry_capable_nym_nodes(&self) -> impl Iterator { self.nodes .values() .filter(|n| n.contract_node_type == DescribedNodeType::NymNode) .filter(|n| n.description.declared_role.entry) } + + pub fn exit_capable_nym_nodes(&self) -> impl Iterator { + self.nodes + .values() + .filter(|n| n.contract_node_type == DescribedNodeType::NymNode) + .filter(|n| n.description.declared_role.can_operate_exit_gateway()) + } } pub struct NodeDescriptionProvider { diff --git a/nym-api/src/nym_nodes/handlers/unstable/helpers.rs b/nym-api/src/nym_nodes/handlers/unstable/helpers.rs new file mode 100644 index 0000000000..60309114d5 --- /dev/null +++ b/nym-api/src/nym_nodes/handlers/unstable/helpers.rs @@ -0,0 +1,21 @@ +// Copyright 2024 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use nym_api_requests::models::OffsetDateTimeJsonSchemaWrapper; +use nym_bin_common::version_checker; +use time::OffsetDateTime; + +pub(crate) fn refreshed_at( + iter: impl IntoIterator, +) -> OffsetDateTimeJsonSchemaWrapper { + iter.into_iter().min().unwrap().into() +} + +pub(crate) fn semver(requirement: &Option, declared: &str) -> bool { + if let Some(semver_compat) = requirement.as_ref() { + if !version_checker::is_minor_version_compatible(declared, semver_compat) { + return false; + } + } + true +} diff --git a/nym-api/src/nym_nodes/handlers/unstable/mod.rs b/nym-api/src/nym_nodes/handlers/unstable/mod.rs index 1102fa8874..a3f50283ac 100644 --- a/nym-api/src/nym_nodes/handlers/unstable/mod.rs +++ b/nym-api/src/nym_nodes/handlers/unstable/mod.rs @@ -36,6 +36,7 @@ use nym_api_requests::nym_nodes::NodeRoleQueryParam; use serde::Deserialize; pub(crate) mod full_fat; +mod helpers; pub(crate) mod semi_skimmed; pub(crate) mod skimmed; diff --git a/nym-api/src/nym_nodes/handlers/unstable/skimmed.rs b/nym-api/src/nym_nodes/handlers/unstable/skimmed.rs index e547b7fc63..6904d265cc 100644 --- a/nym-api/src/nym_nodes/handlers/unstable/skimmed.rs +++ b/nym-api/src/nym_nodes/handlers/unstable/skimmed.rs @@ -2,16 +2,15 @@ // SPDX-License-Identifier: GPL-3.0-only use crate::node_status_api::models::{AxumErrorResponse, AxumResult}; +use crate::nym_nodes::handlers::unstable::helpers::{refreshed_at, semver}; use crate::nym_nodes::handlers::unstable::{NodesParams, NodesParamsWithRole}; -use crate::support::http::helpers::PaginationRequest; use crate::support::http::state::AppState; use axum::extract::{Query, State}; use axum::Json; use nym_api_requests::nym_nodes::{ CachedNodesResponse, NodeRole, NodeRoleQueryParam, PaginatedCachedNodesResponse, SkimmedNode, }; -use nym_bin_common::version_checker; -use std::collections::HashSet; +use tracing::trace; pub type PaginatedSkimmedNodes = AxumResult>>; @@ -31,116 +30,15 @@ pub(super) async fn deprecated_gateways_basic( state: State, query_params: Query, ) -> AxumResult>> { - // TODO: - // 1. call '/v1/unstable/entry-gateways/mixnodes/skimmed/all' - // 2. remove pagination + // 1. call '/v1/unstable/skimmed/entry-gateways/all' + let all_gateways = entry_gateways_basic_all(state, query_params).await?; + // 3. return result - - todo!() - - // let status_cache = state.node_status_cache(); - // let contract_cache = state.nym_contract_cache(); - // let describe_cache = state.described_nodes_cache(); - // - // // 1. get the rewarded set - // let rewarded_set = contract_cache - // .rewarded_set() - // .await - // .ok_or_else(AxumErrorResponse::internal)?; - // - // // determine which gateways are active, i.e. which gateways the clients should be using for connecting and routing the traffic - // let active_gateways = rewarded_set.gateways().into_iter().collect::>(); - // - // // 2. grab all annotations so that we could attach scores to the [nym] nodes - // let annotations = status_cache - // .node_annotations() - // .await - // .ok_or_else(AxumErrorResponse::internal)?; - // - // // 3. grab all legacy gateways - // // due to legacy endpoints we already have fully annotated data on them - // let annotated_legacy_gateways = status_cache - // .annotated_legacy_gateways() - // .await - // .ok_or_else(AxumErrorResponse::internal)?; - // - // // 4. grab all relevant described nym-nodes - // let describe_cache = describe_cache.get().await?; - // let gateway_capable_nym_nodes = describe_cache.gateway_capable_nym_nodes(); - // - // // 5. only return nodes that are present in the active set - // let mut active_skimmed_gateways = Vec::new(); - // - // for (node_id, legacy) in annotated_legacy_gateways.iter() { - // if !active_gateways.contains(node_id) { - // continue; - // } - // - // if let Some(semver_compat) = semver_compatibility.as_ref() { - // let version = legacy.version(); - // if !version_checker::is_minor_version_compatible(version, semver_compat) { - // continue; - // } - // } - // - // // if we have self-described info, prefer it over contract data - // if let Some(described) = describe_cache.get_node(node_id) { - // active_skimmed_gateways.push( - // described.to_skimmed_node(NodeRole::EntryGateway, legacy.node_performance.last_24h), - // ) - // } else { - // active_skimmed_gateways.push(legacy.into()); - // } - // } - // - // for nym_node in gateway_capable_nym_nodes { - // // if this node is not an active gateway, ignore it - // if !active_gateways.contains(&nym_node.node_id) { - // continue; - // } - // - // // if we have wrong version, ignore - // if let Some(semver_compat) = semver_compatibility.as_ref() { - // let version = nym_node.version(); - // if !version_checker::is_minor_version_compatible(version, semver_compat) { - // continue; - // } - // } - // - // // NOTE: if we determined our node IS an active gateway, it MUST be EITHER entry or exit - // let role = if rewarded_set.is_exit(&nym_node.node_id) { - // NodeRole::ExitGateway - // } else { - // NodeRole::EntryGateway - // }; - // - // // honestly, not sure under what exact circumstances this value could be missing, - // // but in that case just use 0 performance - // let annotation = annotations - // .get(&nym_node.node_id) - // .copied() - // .unwrap_or_default(); - // - // active_skimmed_gateways - // .push(nym_node.to_skimmed_node(role, annotation.last_24h_performance)); - // } - // - // // min of all caches - // let refreshed_at = [ - // rewarded_set.timestamp(), - // annotations.timestamp(), - // describe_cache.timestamp(), - // annotated_legacy_gateways.timestamp(), - // ] - // .into_iter() - // .min() - // .unwrap() - // .into(); - // - // Ok(Json(CachedNodesResponse { - // refreshed_at, - // nodes: active_skimmed_gateways, - // })) + Ok(Json(CachedNodesResponse { + refreshed_at: all_gateways.refreshed_at, + // 2. remove pagination + nodes: all_gateways.0.nodes.data, + })) } /// Deprecated query that gets ACTIVE-ONLY mixnodes @@ -159,123 +57,15 @@ pub(super) async fn deprecated_mixnodes_basic( state: State, query_params: Query, ) -> AxumResult>> { - // TODO: - // 1. call '/v1/unstable/nym-nodes/mixnodes/skimmed/active' - // 2. remove pagination - // 3. return result + // 1. call '/v1/unstable/nym-nodes/skimmed/mixnodes/active' + let active_mixnodes = mixnodes_basic_active(state, query_params).await?; - todo!() - // - // let status_cache = state.node_status_cache(); - // let contract_cache = state.nym_contract_cache(); - // let describe_cache = state.described_nodes_cache(); - // - // // 1. get the rewarded set - // let rewarded_set = contract_cache - // .rewarded_set() - // .await - // .ok_or_else(AxumErrorResponse::internal)?; - // - // // determine which mixnodes are active, i.e. which mixnodes the clients should be using for routing the traffic - // let active_mixnodes = rewarded_set - // .active_mixnodes() - // .into_iter() - // .collect::>(); - // - // // 2. grab all annotations so that we could attach scores to the [nym] nodes - // let annotations = status_cache - // .node_annotations() - // .await - // .ok_or_else(AxumErrorResponse::internal)?; - // - // // 3. grab all legacy mixnodes - // // due to legacy endpoints we already have fully annotated data on them - // let annotated_legacy_mixnodes = status_cache - // .annotated_legacy_mixnodes() - // .await - // .ok_or_else(AxumErrorResponse::internal)?; - // - // // 4. grab all relevant described nym-nodes - // let describe_cache = describe_cache.get().await?; - // let mixing_nym_nodes = describe_cache.mixing_nym_nodes(); - // - // // TODO: in the future, only use the self-described cache and simply reject mixnodes that did not expose it - // - // // 5. only return nodes that are present in the active set - // let mut active_skimmed_mixnodes = Vec::new(); - // - // for (mix_id, legacy) in annotated_legacy_mixnodes.iter() { - // if !active_mixnodes.contains(mix_id) { - // continue; - // } - // - // if let Some(semver_compat) = semver_compatibility.as_ref() { - // let version = legacy.version(); - // if !version_checker::is_minor_version_compatible(version, semver_compat) { - // continue; - // } - // } - // - // // if we have self-described info, prefer it over contract data - // if let Some(described) = describe_cache.get_node(mix_id) { - // active_skimmed_mixnodes.push(described.to_skimmed_node( - // NodeRole::Mixnode { - // layer: legacy.mixnode_details.bond_information.layer.into(), - // }, - // legacy.node_performance.last_24h, - // )) - // } else { - // active_skimmed_mixnodes.push(legacy.into()); - // } - // } - // - // for nym_node in mixing_nym_nodes { - // // if this node is not an active mixnode, ignore it - // if !active_mixnodes.contains(&nym_node.node_id) { - // continue; - // } - // - // // if we have wrong version, ignore - // if let Some(semver_compat) = semver_compatibility.as_ref() { - // let version = nym_node.version(); - // if !version_checker::is_minor_version_compatible(version, semver_compat) { - // continue; - // } - // } - // - // // SAFETY: if we determined our node IS active, it MUST have a layer - // // no other thread could have updated the rewarded set as we're still holding the [read] lock on the data - // #[allow(clippy::unwrap_used)] - // let layer = rewarded_set.try_get_mix_layer(&nym_node.node_id).unwrap(); - // - // // honestly, not sure under what exact circumstances this value could be missing, - // // but in that case just use 0 performance - // let annotation = annotations - // .get(&nym_node.node_id) - // .copied() - // .unwrap_or_default(); - // - // active_skimmed_mixnodes.push( - // nym_node.to_skimmed_node(NodeRole::Mixnode { layer }, annotation.last_24h_performance), - // ); - // } - // - // // min of all caches - // let refreshed_at = [ - // rewarded_set.timestamp(), - // annotations.timestamp(), - // describe_cache.timestamp(), - // annotated_legacy_mixnodes.timestamp(), - // ] - // .into_iter() - // .min() - // .unwrap() - // .into(); - // - // Ok(Json(CachedNodesResponse { - // refreshed_at, - // nodes: active_skimmed_mixnodes, - // })) + // 3. return result + Ok(Json(CachedNodesResponse { + refreshed_at: active_mixnodes.refreshed_at, + // 2. remove pagination + nodes: active_mixnodes.0.nodes.data, + })) } /// Return all Nym Nodes and optionally legacy mixnodes/gateways (if `no-legacy` flag is not used) @@ -292,24 +82,22 @@ pub(super) async fn deprecated_mixnodes_basic( )] pub(super) async fn nodes_basic( state: State, - query_params: Query, + Query(query_params): Query, ) -> PaginatedSkimmedNodes { - if let Some(role) = query_params.0.role { + if let Some(role) = query_params.role { return match role { NodeRoleQueryParam::ActiveMixnode => { - mixnodes_basic_all(state, Query(query_params.0.into())).await + mixnodes_basic_all(state, Query(query_params.into())).await } NodeRoleQueryParam::EntryGateway => { - entry_gateways_basic_all(state, Query(query_params.0.into())).await + entry_gateways_basic_all(state, Query(query_params.into())).await } NodeRoleQueryParam::ExitGateway => { - exit_gateways_basic_all(state, Query(query_params.0.into())).await + exit_gateways_basic_all(state, Query(query_params.into())).await } }; } - let query_params = query_params.0; - // TODO: implement pagination let semver = query_params.semver_compatibility; @@ -343,6 +131,9 @@ pub(super) async fn nodes_basic_active( state: State, query_params: Query, ) -> PaginatedSkimmedNodes { + // TODO: implement it + let _ = query_params.per_page; + let _ = query_params.page; todo!() } @@ -362,9 +153,123 @@ pub(super) async fn nodes_basic_standby( state: State, query_params: Query, ) -> PaginatedSkimmedNodes { + // TODO: implement it + let _ = query_params.per_page; + let _ = query_params.page; todo!() } +async fn mixnodes_basic( + state: State, + Query(query_params): Query, + active_only: bool, +) -> PaginatedSkimmedNodes { + // TODO: implement it + let _ = query_params.per_page; + let _ = query_params.page; + let semver_compatibility = query_params.semver_compatibility; + + // 1. get the rewarded set + let rewarded_set = state.rewarded_set().await?; + + // 2. grab all annotations so that we could attach scores to the [nym] nodes + let annotations = state.node_annotations().await?; + + // 3. grab all relevant described nym-nodes + let describe_cache = state.describe_nodes_cache_data().await?; + let mixing_nym_nodes = describe_cache.mixing_nym_nodes(); + + // 4. start building the response + let mut nodes = Vec::new(); + + for nym_node in mixing_nym_nodes { + let node_id = nym_node.node_id; + + // if this node is not an active mixnode, ignore it + if active_only && !rewarded_set.is_active_mixnode(&node_id) { + continue; + } + + // if we have wrong version, ignore + if !semver(&semver_compatibility, nym_node.version()) { + continue; + } + + let role = match rewarded_set.try_get_mix_layer(&node_id) { + Some(layer) => NodeRole::Mixnode { layer }, + None => NodeRole::Inactive, + }; + + // honestly, not sure under what exact circumstances this value could be missing, + // but in that case just use 0 performance + let annotation = annotations.get(&node_id).copied().unwrap_or_default(); + + nodes.push(nym_node.to_skimmed_node(role, annotation.last_24h_performance)); + } + + // 5. if we allow legacy mixnodes, repeat the procedure for mixnodes, otherwise return just nym-nodes + if query_params.no_legacy { + // min of all caches + let refreshed_at = refreshed_at([ + rewarded_set.timestamp(), + annotations.timestamp(), + describe_cache.timestamp(), + ]); + + return Ok(Json(PaginatedCachedNodesResponse::new_full( + refreshed_at, + nodes, + ))); + } + + // 6. grab all legacy mixnodes + // due to legacy endpoints we already have fully annotated data on them + let annotated_legacy_mixnodes = state.legacy_mixnode_annotations().await?; + + for (mix_id, legacy) in annotated_legacy_mixnodes.iter() { + // if this node is not an active mixnode, ignore it + if active_only && !rewarded_set.is_active_mixnode(mix_id) { + continue; + } + + // if we have wrong version, ignore + if !semver(&semver_compatibility, legacy.version()) { + continue; + } + + let role = match rewarded_set.try_get_mix_layer(mix_id) { + Some(layer) => NodeRole::Mixnode { layer }, + None => NodeRole::Inactive, + }; + + // if we have self-described info, prefer it over contract data + if let Some(described) = describe_cache.get_node(mix_id) { + nodes.push(described.to_skimmed_node(role, legacy.node_performance.last_24h)) + } else { + match legacy.try_to_skimmed_node(role) { + Ok(node) => nodes.push(node), + Err(err) => { + let id = legacy.identity_key(); + trace!("node {id} is malformed: {err}") + } + } + } + } + + // min of all caches + let refreshed_at = refreshed_at([ + rewarded_set.timestamp(), + annotations.timestamp(), + describe_cache.timestamp(), + annotated_legacy_mixnodes.timestamp(), + ]); + + Ok(Json(PaginatedCachedNodesResponse::new_full( + refreshed_at, + nodes, + ))) +} + /// Returns Nym Nodes and optionally legacy mixnodes (if `no-legacy` flag is not used) /// that are currently bonded and support mixing role. #[utoipa::path( @@ -381,7 +286,7 @@ pub(super) async fn mixnodes_basic_all( state: State, query_params: Query, ) -> PaginatedSkimmedNodes { - todo!() + mixnodes_basic(state, query_params, false).await } /// Returns Nym Nodes and optionally legacy mixnodes (if `no-legacy` flag is not used) @@ -400,7 +305,118 @@ pub(super) async fn mixnodes_basic_active( state: State, query_params: Query, ) -> PaginatedSkimmedNodes { - todo!() + mixnodes_basic(state, query_params, true).await +} + +async fn entry_gateways_basic( + state: State, + Query(query_params): Query, + active_only: bool, +) -> PaginatedSkimmedNodes { + // TODO: implement it + let _ = query_params.per_page; + let _ = query_params.page; + let semver_compatibility = query_params.semver_compatibility; + + // 1. get the rewarded set + let rewarded_set = state.rewarded_set().await?; + + // 2. grab all annotations so that we could attach scores to the [nym] nodes + let annotations = state.node_annotations().await?; + + // 3. grab all relevant described nym-nodes + let describe_cache = state.describe_nodes_cache_data().await?; + let gateway_capable_nym_nodes = describe_cache.entry_capable_nym_nodes(); + + // 4. start building the response + let mut nodes = Vec::new(); + + for nym_node in gateway_capable_nym_nodes { + let node_id = nym_node.node_id; + + // if this node is not an active gateway, ignore it + if active_only && !rewarded_set.is_entry(&node_id) { + continue; + } + + // if we have wrong version, ignore + if !semver(&semver_compatibility, nym_node.version()) { + continue; + } + + let role = match rewarded_set.is_entry(&node_id) { + true => NodeRole::EntryGateway, + false => NodeRole::Inactive, + }; + + // honestly, not sure under what exact circumstances this value could be missing, + // but in that case just use 0 performance + let annotation = annotations.get(&node_id).copied().unwrap_or_default(); + + nodes.push(nym_node.to_skimmed_node(role, annotation.last_24h_performance)); + } + + // 5. if we allow legacy gateways, repeat the procedure for gateways, otherwise return just nym-nodes + if query_params.no_legacy { + // min of all caches + let refreshed_at = refreshed_at([ + rewarded_set.timestamp(), + annotations.timestamp(), + describe_cache.timestamp(), + ]); + + return Ok(Json(PaginatedCachedNodesResponse::new_full( + refreshed_at, + nodes, + ))); + } + + // 6. grab all legacy gateways + // due to legacy endpoints we already have fully annotated data on them + let annotated_legacy_gateways = state.legacy_gateways_annotations().await?; + + for (node_id, legacy) in annotated_legacy_gateways.iter() { + // if this node is not an active gateway, ignore it + if active_only && !rewarded_set.is_entry(&node_id) { + continue; + } + + // if we have wrong version, ignore + if !semver(&semver_compatibility, legacy.version()) { + continue; + } + + let role = match rewarded_set.is_entry(&node_id) { + true => NodeRole::EntryGateway, + false => NodeRole::Inactive, + }; + + // if we have self-described info, prefer it over contract data + if let Some(described) = describe_cache.get_node(node_id) { + nodes.push(described.to_skimmed_node(role, legacy.node_performance.last_24h)) + } else { + match legacy.try_to_skimmed_node(role) { + Ok(node) => nodes.push(node), + Err(err) => { + let id = legacy.gateway_bond.identity(); + trace!("node {id} is malformed: {err}") + } + } + } + } + + // min of all caches + let refreshed_at = refreshed_at([ + rewarded_set.timestamp(), + annotations.timestamp(), + describe_cache.timestamp(), + annotated_legacy_gateways.timestamp(), + ]); + + Ok(Json(PaginatedCachedNodesResponse::new_full( + refreshed_at, + nodes, + ))) } /// Returns Nym Nodes and optionally legacy gateways (if `no-legacy` flag is not used) @@ -419,26 +435,7 @@ pub(super) async fn entry_gateways_basic_active( state: State, query_params: Query, ) -> PaginatedSkimmedNodes { - todo!() -} - -/// Returns Nym Nodes and optionally legacy gateways (if `no-legacy` flag is not used) -/// that are currently bonded and are in the active set with the exit role. -#[utoipa::path( - tag = "Unstable Nym Nodes", - get, - params(NodesParamsWithRole), - path = "/exit-gateways/active", - context_path = "/v1/unstable/nym-nodes/skimmed", - responses( - (status = 200, body = PaginatedCachedNodesResponse) - ) -)] -pub(super) async fn exit_gateways_basic_active( - state: State, - query_params: Query, -) -> PaginatedSkimmedNodes { - todo!() + entry_gateways_basic(state, query_params, true).await } /// Returns Nym Nodes and optionally legacy gateways (if `no-legacy` flag is not used) @@ -457,7 +454,137 @@ pub(super) async fn entry_gateways_basic_all( state: State, query_params: Query, ) -> PaginatedSkimmedNodes { - todo!() + entry_gateways_basic(state, query_params, false).await +} + +async fn exit_gateways_basic( + state: State, + Query(query_params): Query, + active_only: bool, +) -> PaginatedSkimmedNodes { + // TODO: implement it + let _ = query_params.per_page; + let _ = query_params.page; + let semver_compatibility = query_params.semver_compatibility; + + // 1. get the rewarded set + let rewarded_set = state.rewarded_set().await?; + + // 2. grab all annotations so that we could attach scores to the [nym] nodes + let annotations = state.node_annotations().await?; + + // 3. grab all relevant described nym-nodes + let describe_cache = state.describe_nodes_cache_data().await?; + let gateway_capable_nym_nodes = describe_cache.exit_capable_nym_nodes(); + + // 4. start building the response + let mut nodes = Vec::new(); + + for nym_node in gateway_capable_nym_nodes { + let node_id = nym_node.node_id; + + // if this node is not an active gateway, ignore it + if active_only && !rewarded_set.is_exit(&node_id) { + continue; + } + + // if we have wrong version, ignore + if !semver(&semver_compatibility, nym_node.version()) { + continue; + } + + let role = match rewarded_set.is_exit(&node_id) { + true => NodeRole::ExitGateway, + false => NodeRole::Inactive, + }; + + // honestly, not sure under what exact circumstances this value could be missing, + // but in that case just use 0 performance + let annotation = annotations.get(&node_id).copied().unwrap_or_default(); + + nodes.push(nym_node.to_skimmed_node(role, annotation.last_24h_performance)); + } + + // 5. if we allow legacy gateways, repeat the procedure for gateways, otherwise return just nym-nodes + if query_params.no_legacy { + // min of all caches + let refreshed_at = refreshed_at([ + rewarded_set.timestamp(), + annotations.timestamp(), + describe_cache.timestamp(), + ]); + + return Ok(Json(PaginatedCachedNodesResponse::new_full( + refreshed_at, + nodes, + ))); + } + + // 6. grab all legacy gateways + // due to legacy endpoints we already have fully annotated data on them + let annotated_legacy_gateways = state.legacy_gateways_annotations().await?; + + for (node_id, legacy) in annotated_legacy_gateways.iter() { + // if this node is not an active gateway, ignore it + if active_only && !rewarded_set.is_exit(&node_id) { + continue; + } + + // if we have wrong version, ignore + if !semver(&semver_compatibility, legacy.version()) { + continue; + } + + let role = match rewarded_set.is_exit(&node_id) { + true => NodeRole::ExitGateway, + false => NodeRole::Inactive, + }; + + // if we have self-described info, prefer it over contract data + if let Some(described) = describe_cache.get_node(node_id) { + nodes.push(described.to_skimmed_node(role, legacy.node_performance.last_24h)) + } else { + match legacy.try_to_skimmed_node(role) { + Ok(node) => nodes.push(node), + Err(err) => { + let id = legacy.gateway_bond.identity(); + trace!("node {id} is malformed: {err}") + } + } + } + } + + // min of all caches + let refreshed_at = refreshed_at([ + rewarded_set.timestamp(), + annotations.timestamp(), + describe_cache.timestamp(), + annotated_legacy_gateways.timestamp(), + ]); + + Ok(Json(PaginatedCachedNodesResponse::new_full( + refreshed_at, + nodes, + ))) +} + +/// Returns Nym Nodes and optionally legacy gateways (if `no-legacy` flag is not used) +/// that are currently bonded and are in the active set with the exit role. +#[utoipa::path( + tag = "Unstable Nym Nodes", + get, + params(NodesParamsWithRole), + path = "/exit-gateways/active", + context_path = "/v1/unstable/nym-nodes/skimmed", + responses( + (status = 200, body = PaginatedCachedNodesResponse) + ) +)] +pub(super) async fn exit_gateways_basic_active( + state: State, + query_params: Query, +) -> PaginatedSkimmedNodes { + exit_gateways_basic(state, query_params, true).await } /// Returns Nym Nodes and optionally legacy gateways (if `no-legacy` flag is not used) @@ -476,5 +603,5 @@ pub(super) async fn exit_gateways_basic_all( state: State, query_params: Query, ) -> PaginatedSkimmedNodes { - todo!() + exit_gateways_basic(state, query_params, false).await } diff --git a/nym-api/src/support/http/state.rs b/nym-api/src/support/http/state.rs index a0a640333b..f0803d6677 100644 --- a/nym-api/src/support/http/state.rs +++ b/nym-api/src/support/http/state.rs @@ -5,11 +5,17 @@ use crate::circulating_supply_api::cache::CirculatingSupplyCache; use crate::network::models::NetworkDetails; use crate::node_describe_cache::DescribedNodes; use crate::node_status_api::handlers::unstable; +use crate::node_status_api::models::AxumErrorResponse; use crate::node_status_api::NodeStatusCache; -use crate::nym_contract_cache::cache::NymContractCache; +use crate::nym_contract_cache::cache::{CachedRewardedSet, NymContractCache}; use crate::support::caching::cache::SharedCache; +use crate::support::caching::Cache; use crate::support::storage; +use nym_api_requests::models::{GatewayBondAnnotated, MixNodeBondAnnotated, NodeAnnotation}; +use nym_mixnet_contract_common::NodeId; use nym_task::TaskManager; +use std::collections::HashMap; +use tokio::sync::RwLockReadGuard; use tokio::task::JoinHandle; use tokio_util::sync::CancellationToken; @@ -102,3 +108,50 @@ impl AppState { &self.node_info_cache } } + +// handler helpers to easily get data or return error response +impl AppState { + pub(crate) async fn describe_nodes_cache_data( + &self, + ) -> Result>, AxumErrorResponse> { + Ok(self.described_nodes_cache().get().await?) + } + + pub(crate) async fn rewarded_set( + &self, + ) -> Result>, AxumErrorResponse> { + self.nym_contract_cache() + .rewarded_set() + .await + .ok_or_else(AxumErrorResponse::internal) + } + + pub(crate) async fn node_annotations( + &self, + ) -> Result>>, AxumErrorResponse> { + self.node_status_cache() + .node_annotations() + .await + .ok_or_else(AxumErrorResponse::internal) + } + + pub(crate) async fn legacy_mixnode_annotations( + &self, + ) -> Result>>, AxumErrorResponse> + { + self.node_status_cache() + .annotated_legacy_mixnodes() + .await + .ok_or_else(AxumErrorResponse::internal) + } + + pub(crate) async fn legacy_gateways_annotations( + &self, + ) -> Result>>, AxumErrorResponse> + { + self.node_status_cache() + .annotated_legacy_gateways() + .await + .ok_or_else(AxumErrorResponse::internal) + } +}