diff --git a/common/client-libs/validator-client/src/client.rs b/common/client-libs/validator-client/src/client.rs index e5a9a7ea8a..20afb415a3 100644 --- a/common/client-libs/validator-client/src/client.rs +++ b/common/client-libs/validator-client/src/client.rs @@ -497,6 +497,11 @@ impl NymApiClient { Ok(nodes) } + /// retrieve basic information for all bonded nodes on the network + pub async fn retrieve_basic_nodes_batch(&self, node_ids: Vec) -> Result, ValidatorClientError> { + Ok(self.nym_api.retrieve_basic_nodes_batch(&node_ids).await?.nodes) + } + pub async fn health(&self) -> Result { Ok(self.nym_api.health().await?) } diff --git a/common/client-libs/validator-client/src/nym_api/mod.rs b/common/client-libs/validator-client/src/nym_api/mod.rs index e1bac67513..c383435bf2 100644 --- a/common/client-libs/validator-client/src/nym_api/mod.rs +++ b/common/client-libs/validator-client/src/nym_api/mod.rs @@ -253,10 +253,10 @@ pub trait NymApiClientExt: ApiClient { self.get_json( &[ routes::API_VERSION, - "unstable", + routes::UNSTABLE, routes::NYM_NODES_ROUTES, "mixnodes", - "skimmed", + routes::SKIMMED, ], NO_PARAMS, ) @@ -269,10 +269,10 @@ pub trait NymApiClientExt: ApiClient { self.get_json( &[ routes::API_VERSION, - "unstable", + routes::UNSTABLE, routes::NYM_NODES_ROUTES, "gateways", - "skimmed", + routes::SKIMMED, ], NO_PARAMS, ) @@ -318,9 +318,9 @@ pub trait NymApiClientExt: ApiClient { self.get_json( &[ routes::API_VERSION, - "unstable", + routes::UNSTABLE, routes::NYM_NODES_ROUTES, - "skimmed", + routes::SKIMMED, "entry-gateways", "all", ], @@ -355,9 +355,9 @@ pub trait NymApiClientExt: ApiClient { self.get_json( &[ routes::API_VERSION, - "unstable", + routes::UNSTABLE, routes::NYM_NODES_ROUTES, - "skimmed", + routes::SKIMMED, "mixnodes", "active", ], @@ -392,9 +392,9 @@ pub trait NymApiClientExt: ApiClient { self.get_json( &[ routes::API_VERSION, - "unstable", + routes::UNSTABLE, routes::NYM_NODES_ROUTES, - "skimmed", + routes::SKIMMED, "mixnodes", "all", ], @@ -403,6 +403,31 @@ pub trait NymApiClientExt: ApiClient { .await } + /// Send a Post request with a set of node ids. A successful response will contain descriptors + /// for all nodes associated with those node IDs available in the current full topology. + /// + /// If a provided node ID is not present there will be no descriptor for that node in the response. + /// + /// If no node IDs are provided the response will contain no descriptors. + #[instrument(level = "debug", skip(self))] + async fn retrieve_basic_nodes_batch( + &self, + node_ids: &[NodeId], + ) -> Result, NymAPIError> { + self.post_json( + &[ + routes::API_VERSION, + routes::UNSTABLE, + routes::NYM_NODES_ROUTES, + routes::SKIMMED, + routes::BATCH, + ], + NO_PARAMS, + node_ids, + ) + .await + } + #[instrument(level = "debug", skip(self))] async fn get_basic_nodes( &self, @@ -427,9 +452,9 @@ pub trait NymApiClientExt: ApiClient { self.get_json( &[ routes::API_VERSION, - "unstable", + routes::UNSTABLE, routes::NYM_NODES_ROUTES, - "skimmed", + routes::SKIMMED, ], ¶ms, ) diff --git a/common/client-libs/validator-client/src/nym_api/routes.rs b/common/client-libs/validator-client/src/nym_api/routes.rs index 767acc0b8d..1c5eae8f8f 100644 --- a/common/client-libs/validator-client/src/nym_api/routes.rs +++ b/common/client-libs/validator-client/src/nym_api/routes.rs @@ -70,3 +70,7 @@ pub const SERVICE_PROVIDERS: &str = "services"; pub const DETAILS: &str = "details"; pub const NETWORK: &str = "network"; + +pub const UNSTABLE: &str = "unstable"; +pub const SKIMMED: &str = "skimmed"; +pub const BATCH: &str = "batch"; diff --git a/nym-api/src/nym_nodes/handlers/mod.rs b/nym-api/src/nym_nodes/handlers/mod.rs index da6ef82c7b..e5989ef2cc 100644 --- a/nym-api/src/nym_nodes/handlers/mod.rs +++ b/nym-api/src/nym_nodes/handlers/mod.rs @@ -26,6 +26,7 @@ use utoipa::{IntoParams, ToSchema}; pub(crate) mod legacy; pub(crate) mod unstable; +pub(crate) mod topology; pub(crate) fn nym_node_routes() -> Router { Router::new() diff --git a/nym-api/src/nym_nodes/handlers/topology.rs b/nym-api/src/nym_nodes/handlers/topology.rs new file mode 100644 index 0000000000..00dd71d63d --- /dev/null +++ b/nym-api/src/nym_nodes/handlers/topology.rs @@ -0,0 +1,153 @@ +#![warn(missing_docs)] +#![warn(rustdoc::missing_crate_level_docs)] + +use crate::{ + node_status_api::models::{AxumErrorResponse, AxumResult}, + support::http::{helpers::NodeIdParam, topology_cache::{TopologyCache, PayloadFormat}}, +}; + +use axum::{ + extract::{Path, Query, State}, + routing::{get, post}, + Json, Router, +}; +use nym_mixnet_contract_common::{Interval, NodeId}; +use nym_api_requests::models::RewardedSetResponse; +use serde::{Deserialize, Serialize}; + +use std::sync::Arc; + +type SkimmedNodes = AxumResult>; + +#[derive(Debug, Deserialize, utoipa::IntoParams)] +#[into_params(parameter_in = Query)] +struct TopologyParams { + #[allow(dead_code)] + semver_compatibility: Option, + + // Identifier for the current epoch of the topology state. When sent by a client we can check if + // the client already knows about the latest topology state, allowing a `no-updates` response + // instead of wasting bandwidth serving an unchanged topology. + epoch_id: Option, +} + +#[allow(deprecated)] +pub(crate) fn topology_routes() -> Router> { + Router::new() + .route("layer-assignments", get(layer_assignments)) + .nest( + "/skimmed", + Router::new() + .route("/", get(nodes_basic_all)) + .route("batch", post(nodes_basic_batch)) + .route("/:node_id", get(node_basic)), + ) + // // NOT IMPLEMENTED + // .nest( + // "/semi-skimmed", + // Router::new().route("/", get(nodes_expanded_all)), + // ) + // .nest( + // "/full-fat", + // Router::new().route("/", get(nodes_detailed_all)), + // ) +} + +async fn nodes_basic_all( + State(state): State>, + Query(query_params): Query, +) -> AxumResult> { + Err(AxumErrorResponse::not_implemented()) +} + +async fn nodes_basic_batch( + State(state): State>, + Query(query_params): Query, + Json(node_ids): Json>, +) -> AxumResult> { + Err(AxumErrorResponse::not_implemented()) +} + +async fn node_basic( + Path(NodeIdParam { node_id }): Path, + State(state): State>, + Query(query_params): Query, +) -> AxumResult> { + Err(AxumErrorResponse::not_implemented()) +} + +async fn layer_assignments( + State(state): State>, + Query(query_params): Query, +) -> AxumResult> { + Err(AxumErrorResponse::not_implemented()) +} + +#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, utoipa::ToSchema)] +struct LayerAssignmentsResponse { + pub status: Option, + + pub assignments: RewardedSetResponse, + +} + +#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, utoipa::ToSchema)] +struct TopologyResponse { + pub status: Option, + + payload: Vec, + payload_format: Option, + payload_signature: Option>, + + current_topology_hash: Option>, + topology_signature: Option>, +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, schemars::JsonSchema, utoipa::ToSchema)] +#[serde(rename_all = "kebab-case")] +pub enum TopologyRequestStatus { + NoUpdates, + Fresh(Interval), +} + +#[cfg(test)] +mod test { + use std::time::Duration; + + use axum_test::TestServer; + use nym_topology::NymTopology; + use time::OffsetDateTime; + + use crate::support::http::topology_cache::Epoch; + + use super::*; + + fn build_test_topology_cache() -> TopologyCache { + + let current_epoch = Epoch { + id: 123, + current_epoch_start: OffsetDateTime::now_utc(), + epoch_length: Duration::from_secs(120), + }; + + let topology = NymTopology::default(); + + TopologyCache::new(current_epoch, topology); + todo!(); + } + + #[tokio::test] + async fn test_topology_basic() -> Result<(), Box> { + let state = Arc::new(build_test_topology_cache()); + let app = topology_routes().with_state(state); + + let server = TestServer::new(app)?; + + let response = server + .get(&"/layer_assignments") + .await; + + response.assert_text("hello!"); + Ok(()) + } +} diff --git a/nym-api/src/nym_nodes/handlers/unstable/mod.rs b/nym-api/src/nym_nodes/handlers/unstable/mod.rs index d1ee06ed1e..1ab71f74de 100644 --- a/nym-api/src/nym_nodes/handlers/unstable/mod.rs +++ b/nym-api/src/nym_nodes/handlers/unstable/mod.rs @@ -25,11 +25,11 @@ use crate::nym_nodes::handlers::unstable::semi_skimmed::nodes_expanded; use crate::nym_nodes::handlers::unstable::skimmed::{ entry_gateways_basic_active, entry_gateways_basic_all, exit_gateways_basic_active, exit_gateways_basic_all, mixnodes_basic_active, mixnodes_basic_all, nodes_basic_active, - nodes_basic_all, + nodes_basic_all, nodes_basic_batch, }; use crate::support::http::helpers::PaginationRequest; use crate::support::http::state::AppState; -use axum::routing::get; +use axum::routing::{get, post}; use axum::Router; use nym_api_requests::nym_nodes::NodeRoleQueryParam; use serde::Deserialize; @@ -47,6 +47,7 @@ pub(crate) fn nym_node_routes_unstable() -> Router { "/skimmed", Router::new() .route("/", get(nodes_basic_all)) + .route("batch", post(nodes_basic_batch)) .route("/active", get(nodes_basic_active)) .nest( "/mixnodes", diff --git a/nym-api/src/nym_nodes/handlers/unstable/skimmed.rs b/nym-api/src/nym_nodes/handlers/unstable/skimmed.rs index 8eb5388dea..2b17245397 100644 --- a/nym-api/src/nym_nodes/handlers/unstable/skimmed.rs +++ b/nym-api/src/nym_nodes/handlers/unstable/skimmed.rs @@ -25,6 +25,7 @@ use tracing::trace; use utoipa::ToSchema; pub type PaginatedSkimmedNodes = AxumResult>>; +type SkimmedNodes = AxumResult>>; /// Given all relevant caches, build part of response for JUST Nym Nodes fn build_nym_nodes_response<'a, NI>( @@ -196,7 +197,7 @@ where pub(super) async fn deprecated_gateways_basic( state: State, query_params: Query, -) -> AxumResult>> { +) -> SkimmedNodes { // 1. call '/v1/unstable/skimmed/entry-gateways/all' let all_gateways = entry_gateways_basic_all(state, query_params).await?; @@ -223,7 +224,7 @@ pub(super) async fn deprecated_gateways_basic( pub(super) async fn deprecated_mixnodes_basic( state: State, query_params: Query, -) -> AxumResult>> { +) -> SkimmedNodes { // 1. call '/v1/unstable/nym-nodes/skimmed/mixnodes/active' let active_mixnodes = mixnodes_basic_active(state, query_params).await?; @@ -239,7 +240,7 @@ async fn nodes_basic( state: State, Query(_query_params): Query, active_only: bool, -) -> PaginatedSkimmedNodes { +) -> SkimmedNodes { // unfortunately we have to build the response semi-manually here as we need to add two sources of legacy nodes // 1. grab all relevant described nym-nodes @@ -281,10 +282,10 @@ async fn nodes_basic( legacy_gateways.timestamp(), ]); - Ok(Json(PaginatedCachedNodesResponse::new_full( + Ok(Json(CachedNodesResponse { refreshed_at, nodes, - ))) + })) } #[allow(dead_code)] // not dead, used in OpenAPI docs @@ -329,6 +330,31 @@ pub(super) async fn nodes_basic_all( nodes_basic(state, Query(query_params.into()), false).await } +/// Post request handler taking a json array of NodeId (u32) values and returning descriptors for +/// the provided NodeId values. A successful response will contain descriptors for all nodes +/// associated with those node IDs available in the current full topology. +/// +/// If a provided node ID is not present in the current topology there will be no descriptor for +/// that node in the response. +/// +/// If no node IDs are provided the response will contain no descriptors. +#[utoipa::path( + tag = "Unstable Nym Nodes batch by Node ID", + get, + params(NodesParamsWithRole), + path = "batch", + context_path = "/v1/unstable/nym-nodes/skimmed", + responses( + (status = 200, body = PaginatedCachedNodesResponseSchema) + ) +)] +pub(super) async fn nodes_basic_batch( + state: State, + Query(query_params): Query, +) -> SkimmedNodes { + nodes_basic(state, Query(query_params.into()), false).await +} + /// Return Nym Nodes and optionally legacy mixnodes/gateways (if `no-legacy` flag is not used) /// that are currently bonded and are in the **active set** #[utoipa::path( diff --git a/nym-api/src/support/cli/run.rs b/nym-api/src/support/cli/run.rs index c506d32508..c0332292ac 100644 --- a/nym-api/src/support/cli/run.rs +++ b/nym-api/src/support/cli/run.rs @@ -23,6 +23,7 @@ use crate::support::config::Config; use crate::support::http::state::{ AppState, ForcedRefresh, ShutdownHandles, TASK_MANAGER_TIMEOUT_S, }; +use crate::support::http::topology_cache::{Epoch, TopologyCache}; use crate::support::http::RouterBuilder; use crate::support::nyxd; use crate::support::storage::runtime_migrations::m001_directory_services_v2_1::migrate_to_directory_services_v2_1; @@ -35,6 +36,7 @@ use anyhow::{bail, Context}; use nym_config::defaults::NymNetworkDetails; use nym_sphinx::receiver::SphinxMessageReceiver; use nym_task::TaskManager; +use nym_topology::NymTopology; use nym_validator_client::nyxd::Coin; use std::net::SocketAddr; use std::sync::Arc; @@ -133,8 +135,6 @@ async fn start_nym_api_tasks_axum(config: &Config) -> anyhow::Result anyhow::Result anyhow::Result Self { + pub(crate) fn with_default_routes(state: &AppState, network_monitor: bool) -> Self { // https://docs.rs/tower-http/0.1.1/tower_http/trace/index.html // TODO rocket use tracing instead of env_logger // https://github.com/tokio-rs/axum/blob/main/examples/tracing-aka-logging/src/main.rs @@ -64,7 +65,9 @@ impl RouterBuilder { .nest("/api-status", status::handlers::api_status_routes()) .nest("/nym-nodes", nym_node_routes()) .nest("/ecash", ecash_routes()) - .nest("/unstable", unstable_routes()), // CORS layer needs to be "outside" of routes + .nest("/unstable", unstable_routes()) + .nest("/topology", topology_routes().with_state(state.topology_cache.clone())), + // CORS layer needs to be "outside" of routes ); Self { diff --git a/nym-api/src/support/http/state.rs b/nym-api/src/support/http/state.rs index 9d55ab3bf1..347e464495 100644 --- a/nym-api/src/support/http/state.rs +++ b/nym-api/src/support/http/state.rs @@ -12,6 +12,7 @@ use crate::nym_contract_cache::cache::NymContractCache; use crate::status::ApiStatusState; use crate::support::caching::cache::SharedCache; use crate::support::caching::Cache; +use crate::support::http::topology_cache::TopologyCache; use crate::support::storage; use axum::extract::FromRef; use nym_api_requests::models::{GatewayBondAnnotated, MixNodeBondAnnotated, NodeAnnotation}; @@ -87,6 +88,7 @@ pub(crate) struct AppState { pub(crate) api_status: ApiStatusState, // todo: refactor it into inner: Arc pub(crate) ecash_state: Arc, + pub(crate) topology_cache: Arc } impl FromRef for ApiStatusState { diff --git a/nym-api/src/support/http/topology_cache.rs b/nym-api/src/support/http/topology_cache.rs new file mode 100644 index 0000000000..28986ae963 --- /dev/null +++ b/nym-api/src/support/http/topology_cache.rs @@ -0,0 +1,60 @@ +#![warn(missing_docs)] +#![warn(rustdoc::missing_crate_level_docs)] + + +use std::collections::HashMap; +use nym_topology::NymTopology; +use serde::{Deserialize, Serialize}; +use nym_mixnet_contract_common::{EpochId, Interval}; +use std::time::Duration; +use time::OffsetDateTime; + +pub struct Epoch { + pub id: EpochId, + pub current_epoch_start: OffsetDateTime, + pub epoch_length: Duration, +} + +impl From for Epoch { + fn from(value: Interval) -> Self { + Self { + id: value.current_epoch_id(), + current_epoch_start: value.current_epoch_start(), + epoch_length: value.epoch_length(), + } + } +} + +/// Format for +#[derive(Clone, Copy, Debug, Serialize, Deserialize, schemars::JsonSchema, utoipa::ToSchema)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum PayloadFormat { + Json, + BitCode, +} + +pub(crate) struct TopologyCache { + current_epoch: Epoch, + formats: HashMap, + cached: NymTopology, + hash: Option>, + signature: Option> +} + +pub(crate) struct SerializedTopology{ + bytes: Vec, + signature: Vec, +} + + +impl TopologyCache { + pub fn new(current_epoch: Epoch, initial: NymTopology) -> Self { + Self { + current_epoch, + formats: HashMap::new(), + cached: initial, + hash: None, + signature: None + } + } +} \ No newline at end of file