From 0b7a27276e22d1b2ff8afe632c4e30237a72d181 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C4=99drzej=20Stuczy=C5=84ski?= Date: Thu, 3 Jul 2025 16:26:23 +0100 Subject: [PATCH 01/10] ecash-signer-check lib for obtaining basic ecash signer information --- Cargo.lock | 14 ++ Cargo.toml | 4 +- .../validator-client/src/nym_api/mod.rs | 14 +- .../validator-client/src/nyxd/mod.rs | 14 +- common/ecash-signer-check/Cargo.toml | 27 ++++ common/ecash-signer-check/src/chain_status.rs | 20 +++ common/ecash-signer-check/src/client_check.rs | 152 ++++++++++++++++++ common/ecash-signer-check/src/error.rs | 31 ++++ common/ecash-signer-check/src/lib.rs | 100 ++++++++++++ .../ecash-signer-check/src/signing_status.rs | 19 +++ common/http-api-common/src/response/mod.rs | 6 + nym-api/nym-api-requests/Cargo.toml | 3 +- nym-api/nym-api-requests/src/ecash/models.rs | 35 +++- nym-api/nym-api-requests/src/models.rs | 10 +- nym-api/src/ecash/api_routes/handlers.rs | 3 + nym-api/src/ecash/api_routes/mod.rs | 1 + nym-api/src/ecash/api_routes/signer_status.rs | 45 ++++++ nym-api/src/ecash/state/mod.rs | 25 +-- nym-wallet/Cargo.lock | 1 + .../testnet-manager/src/manager/dkg_skip.rs | 4 +- 20 files changed, 499 insertions(+), 29 deletions(-) create mode 100644 common/ecash-signer-check/Cargo.toml create mode 100644 common/ecash-signer-check/src/chain_status.rs create mode 100644 common/ecash-signer-check/src/client_check.rs create mode 100644 common/ecash-signer-check/src/error.rs create mode 100644 common/ecash-signer-check/src/lib.rs create mode 100644 common/ecash-signer-check/src/signing_status.rs create mode 100644 nym-api/src/ecash/api_routes/signer_status.rs diff --git a/Cargo.lock b/Cargo.lock index ba482cdb2ef..b73ec8c22ac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4899,6 +4899,7 @@ dependencies = [ "getset", "hex", "humantime-serde", + "nym-coconut-dkg-common 0.1.0", "nym-compact-ecash 0.1.0", "nym-config 0.1.0", "nym-contracts-common 0.5.0", @@ -5805,6 +5806,19 @@ dependencies = [ "thiserror 2.0.12", ] +[[package]] +name = "nym-ecash-signer-check" +version = "0.1.0" +dependencies = [ + "futures", + "nym-network-defaults 0.1.0", + "nym-validator-client 0.1.0", + "semver 1.0.26", + "thiserror 2.0.12", + "tracing", + "url", +] + [[package]] name = "nym-ecash-time" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 8b3404c545e..80444ca2ab4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,7 +39,8 @@ members = [ "common/cosmwasm-smart-contracts/ecash-contract", "common/cosmwasm-smart-contracts/group-contract", "common/cosmwasm-smart-contracts/mixnet-contract", - "common/cosmwasm-smart-contracts/multisig-contract", "common/cosmwasm-smart-contracts/nym-performance-contract", + "common/cosmwasm-smart-contracts/multisig-contract", + "common/cosmwasm-smart-contracts/nym-performance-contract", "common/cosmwasm-smart-contracts/nym-pool-contract", "common/cosmwasm-smart-contracts/vesting-contract", "common/credential-storage", @@ -49,6 +50,7 @@ members = [ "common/credentials-interface", "common/crypto", "common/dkg", + "common/ecash-signer-check", "common/ecash-time", "common/execute", "common/exit-policy", 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 1136952e621..8ae2b830b07 100644 --- a/common/client-libs/validator-client/src/nym_api/mod.rs +++ b/common/client-libs/validator-client/src/nym_api/mod.rs @@ -6,10 +6,11 @@ use crate::nym_api::routes::{ecash, CORE_STATUS_COUNT, SINCE_ARG}; use async_trait::async_trait; use nym_api_requests::ecash::models::{ AggregatedCoinIndicesSignatureResponse, AggregatedExpirationDateSignatureResponse, - BatchRedeemTicketsBody, EcashBatchTicketRedemptionResponse, EcashTicketVerificationResponse, - IssuedTicketbooksChallengeCommitmentRequest, IssuedTicketbooksChallengeCommitmentResponse, - IssuedTicketbooksDataRequest, IssuedTicketbooksDataResponse, IssuedTicketbooksForCountResponse, - IssuedTicketbooksForResponse, VerifyEcashTicketBody, + BatchRedeemTicketsBody, EcashBatchTicketRedemptionResponse, EcashSignerStatusResponse, + EcashTicketVerificationResponse, IssuedTicketbooksChallengeCommitmentRequest, + IssuedTicketbooksChallengeCommitmentResponse, IssuedTicketbooksDataRequest, + IssuedTicketbooksDataResponse, IssuedTicketbooksForCountResponse, IssuedTicketbooksForResponse, + VerifyEcashTicketBody, }; use nym_api_requests::ecash::VerificationKeyResponse; use nym_api_requests::models::{ @@ -1331,6 +1332,11 @@ pub trait NymApiClientExt: ApiClient { .await } + #[instrument(level = "debug", skip(self))] + async fn get_signer_status(&self) -> Result { + self.get_json("/v1/ecash/signer-status", NO_PARAMS).await + } + #[instrument(level = "debug", skip(self))] async fn get_key_rotation_info(&self) -> Result { self.get_json( diff --git a/common/client-libs/validator-client/src/nyxd/mod.rs b/common/client-libs/validator-client/src/nyxd/mod.rs index 80dcf01fb0d..85b08ab12a0 100644 --- a/common/client-libs/validator-client/src/nyxd/mod.rs +++ b/common/client-libs/validator-client/src/nyxd/mod.rs @@ -139,13 +139,23 @@ impl NyxdClient { }) } - pub fn connect_to_default_env(endpoint: U) -> Result + pub fn connect_with_network_details( + endpoint: U, + network_details: NymNetworkDetails, + ) -> Result where U: TryInto, { - let config = Config::try_from_nym_network_details(&NymNetworkDetails::new_from_env())?; + let config = Config::try_from_nym_network_details(&network_details)?; Self::connect(config, endpoint) } + + pub fn connect_to_default_env(endpoint: U) -> Result + where + U: TryInto, + { + Self::connect_with_network_details(endpoint, NymNetworkDetails::new_from_env()) + } } impl NyxdClient { diff --git a/common/ecash-signer-check/Cargo.toml b/common/ecash-signer-check/Cargo.toml new file mode 100644 index 00000000000..722d3c09b57 --- /dev/null +++ b/common/ecash-signer-check/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "nym-ecash-signer-check" +version = "0.1.0" +authors.workspace = true +repository.workspace = true +homepage.workspace = true +documentation.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +readme.workspace = true + +[dependencies] +futures = { workspace = true } +thiserror = { workspace = true } +semver = { workspace = true } +tracing = { workspace = true } +url = { workspace = true } + + +nym-validator-client = { path = "../client-libs/validator-client" } +nym-network-defaults = { path = "../network-defaults" } + + + +[lints] +workspace = true diff --git a/common/ecash-signer-check/src/chain_status.rs b/common/ecash-signer-check/src/chain_status.rs new file mode 100644 index 00000000000..c5e75650af5 --- /dev/null +++ b/common/ecash-signer-check/src/chain_status.rs @@ -0,0 +1,20 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use nym_validator_client::models::ChainStatusResponse; + +// Dorina +pub(crate) const MINIMUM_VERSION: semver::Version = semver::Version::new(1, 1, 51); + +#[derive(Debug)] +pub enum LocalChainStatus { + /// The API, even though it reports correct version, did not response to the status query + Unreachable, + + /// The API is running an outdated version that does not expose the required endpoint + Outdated, + + /// Response to the status query + // unfortunately this response is not signed, but it's not the end of the world + Reachable { response: Box }, +} diff --git a/common/ecash-signer-check/src/client_check.rs b/common/ecash-signer-check/src/client_check.rs new file mode 100644 index 00000000000..01cded52eda --- /dev/null +++ b/common/ecash-signer-check/src/client_check.rs @@ -0,0 +1,152 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::chain_status::LocalChainStatus; +use crate::signing_status::SigningStatus; +use crate::{ + chain_status, signing_status, SignerInformation, SignerResult, SignerStatus, SignerTestResult, +}; +use nym_validator_client::client::NymApiClientExt; +use nym_validator_client::models::BinaryBuildInformationOwned; +use nym_validator_client::nyxd::contract_traits::dkg_query_client::ContractVKShare; +use nym_validator_client::EcashApiClient; +use std::time::Duration; +use tracing::{error, warn}; + +struct ClientUnderTest { + api_client: EcashApiClient, + build_info: Option, +} + +impl ClientUnderTest { + pub(crate) fn new(api_client: EcashApiClient) -> Self { + ClientUnderTest { + api_client, + build_info: None, + } + } + + pub(crate) async fn try_retrieve_build_information(&mut self) -> bool { + match tokio::time::timeout( + Duration::from_secs(5), + self.api_client.api_client.nym_api.build_information(), + ) + .await + { + Ok(Ok(build_information)) => { + self.build_info = Some(build_information); + true + } + Ok(Err(err)) => { + warn!("{}: failed to retrieve build information: {err}. the signer is most likely down", self.api_client); + false + } + Err(_timeout) => { + warn!( + "{}: timed out while attempting to retrieve build information", + self.api_client + ); + false + } + } + } + + pub(crate) fn version(&self) -> Option { + self.build_info.as_ref().and_then(|build_info| { + build_info + .build_version + .parse() + .inspect_err(|err| { + error!( + "ecash signer '{}' reports invalid version {}: {err}", + self.api_client, build_info.build_version + ) + }) + .ok() + }) + } + + pub(crate) fn supports_signing_status_query(&self) -> bool { + let Some(version) = self.version() else { + return false; + }; + version >= signing_status::MINIMUM_VERSION + } + + pub(crate) fn supports_chain_status_query(&self) -> bool { + let Some(version) = self.version() else { + return false; + }; + version >= chain_status::MINIMUM_VERSION + } + + pub(crate) async fn check_local_chain(&self) -> LocalChainStatus { + if !self.supports_chain_status_query() { + return LocalChainStatus::Outdated; + } + + match self.api_client.api_client.nym_api.get_chain_status().await { + Ok(status) => LocalChainStatus::Reachable { + response: Box::new(status), + }, + Err(err) => { + warn!( + "{}: failed to retrieve local chain status: {err}", + self.api_client + ); + LocalChainStatus::Unreachable + } + } + } + + pub(crate) async fn check_signing_status(&self) -> SigningStatus { + if !self.supports_signing_status_query() { + return SigningStatus::Outdated; + } + + match self.api_client.api_client.nym_api.get_signer_status().await { + Ok(response) => SigningStatus::Reachable { response }, + Err(err) => { + warn!( + "{}: failed to retrieve signer chain status: {err}", + self.api_client + ); + SigningStatus::Unreachable + } + } + } +} + +pub(crate) async fn check_client(raw_share: ContractVKShare) -> SignerResult { + let signer_information: SignerInformation = (&raw_share).into(); + + // 4. attempt to construct client instances out of them + // (don't use `all_ecash_api_clients` as we want to treat each error individually; + // for example during epoch advancement we still want to be able to perform monitoring + // even if some shares are unverified) + let Ok(client) = EcashApiClient::try_from(raw_share) else { + return SignerStatus::ProvidedInvalidDetails.with_signer_information(signer_information); + }; + + let mut client = ClientUnderTest::new(client); + + // 5. check basic connection status - can you retrieve build information? + if !client.try_retrieve_build_information().await { + return SignerStatus::Unreachable.with_signer_information(signer_information); + } + + // 6. check perceived chain status + let local_chain_status = client.check_local_chain().await; + + // 7. check signer status + let signing_status = client.check_signing_status().await; + + SignerStatus::Tested { + result: SignerTestResult { + reported_version: client.version().map(|v| v.to_string()).unwrap_or_default(), + signing_status, + local_chain_status, + }, + } + .with_signer_information(signer_information) +} diff --git a/common/ecash-signer-check/src/error.rs b/common/ecash-signer-check/src/error.rs new file mode 100644 index 00000000000..01d10f7a764 --- /dev/null +++ b/common/ecash-signer-check/src/error.rs @@ -0,0 +1,31 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use nym_validator_client::coconut::EcashApiError; +use nym_validator_client::nyxd::error::NyxdError; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum SignerCheckError { + #[error("failed to connect to nyxd chain due to invalid connection details: {source}")] + InvalidNyxdConnectionDetails { source: NyxdError }, + + #[error("failed to query the DKG contract: {source}")] + DKGContractQueryFailure { source: NyxdError }, + + #[error("dealer at {dealer_url} has provided invalid data: {source}")] + InvalidDkgDealer { + dealer_url: String, + source: EcashApiError, + }, +} + +impl SignerCheckError { + pub fn invalid_nyxd_connection_details(source: NyxdError) -> Self { + Self::InvalidNyxdConnectionDetails { source } + } + + pub fn dkg_contract_query_failure(source: NyxdError) -> Self { + Self::DKGContractQueryFailure { source } + } +} diff --git a/common/ecash-signer-check/src/lib.rs b/common/ecash-signer-check/src/lib.rs new file mode 100644 index 00000000000..65a63345e67 --- /dev/null +++ b/common/ecash-signer-check/src/lib.rs @@ -0,0 +1,100 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::chain_status::LocalChainStatus; +use crate::client_check::check_client; +use crate::signing_status::SigningStatus; +use futures::stream::{FuturesUnordered, StreamExt}; +use nym_network_defaults::NymNetworkDetails; +use nym_validator_client::nyxd::contract_traits::dkg_query_client::ContractVKShare; +use nym_validator_client::nyxd::contract_traits::{DkgQueryClient, PagedDkgQueryClient}; +use nym_validator_client::QueryHttpRpcNyxdClient; +use url::Url; + +pub use error::SignerCheckError; + +mod chain_status; +mod client_check; +pub mod error; +pub(crate) mod signing_status; + +#[derive(Debug)] +pub struct SignerInformation { + pub announce_address: String, + pub owner_address: String, + pub node_index: u64, +} + +impl From<&ContractVKShare> for SignerInformation { + fn from(share: &ContractVKShare) -> Self { + SignerInformation { + announce_address: share.announce_address.clone(), + owner_address: share.owner.to_string(), + node_index: share.node_index, + } + } +} + +#[derive(Debug)] +pub struct SignerResult { + pub information: SignerInformation, + pub status: SignerStatus, +} + +#[derive(Debug)] +pub enum SignerStatus { + Unreachable, + ProvidedInvalidDetails, + Tested { result: SignerTestResult }, +} + +impl SignerStatus { + pub fn with_signer_information(self, information: SignerInformation) -> SignerResult { + SignerResult { + status: self, + information, + } + } +} + +#[derive(Debug)] +pub struct SignerTestResult { + pub reported_version: String, + pub signing_status: SigningStatus, + pub local_chain_status: LocalChainStatus, +} + +pub async fn check_signers( + rpc_endpoint: Url, + // details such as denoms, prefixes, etc. + network_details: NymNetworkDetails, +) -> Result, SignerCheckError> { + // 1. create nyx client instance + let client = QueryHttpRpcNyxdClient::connect_with_network_details( + rpc_endpoint.as_str(), + network_details, + ) + .map_err(SignerCheckError::invalid_nyxd_connection_details)?; + + // 2. retrieve current dkg epoch + let dkg_epoch = client + .get_current_epoch() + .await + .map_err(SignerCheckError::dkg_contract_query_failure)?; + + // 3. retrieve list of all current dealers (signers) + let shares = client + .get_all_verification_key_shares(dkg_epoch.epoch_id) + .await + .map_err(SignerCheckError::dkg_contract_query_failure)?; + + // 4. for each share, attempt to check corresponding signer + let results = shares + .into_iter() + .map(check_client) + .collect::>() + .collect::>() + .await; + + Ok(results) +} diff --git a/common/ecash-signer-check/src/signing_status.rs b/common/ecash-signer-check/src/signing_status.rs new file mode 100644 index 00000000000..0dfbf0c8742 --- /dev/null +++ b/common/ecash-signer-check/src/signing_status.rs @@ -0,0 +1,19 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use nym_validator_client::ecash::models::EcashSignerStatusResponse; + +// Emmental +pub(crate) const MINIMUM_VERSION: semver::Version = semver::Version::new(1, 1, 62); + +#[derive(Debug)] +pub enum SigningStatus { + /// The API, even though it reports correct version, did not response to the status query + Unreachable, + + /// The API is running an outdated version that does not expose the required endpoint + Outdated, + + /// Response to the status query + Reachable { response: EcashSignerStatusResponse }, +} diff --git a/common/http-api-common/src/response/mod.rs b/common/http-api-common/src/response/mod.rs index ee1a84a7852..2753aaf8f06 100644 --- a/common/http-api-common/src/response/mod.rs +++ b/common/http-api-common/src/response/mod.rs @@ -146,6 +146,12 @@ pub struct OutputParams { pub output: Option, } +impl OutputParams { + pub fn get_output(&self) -> Output { + self.output.unwrap_or_default() + } +} + impl Output { pub fn to_response(self, data: T) -> FormattedResponse { match self { diff --git a/nym-api/nym-api-requests/Cargo.toml b/nym-api/nym-api-requests/Cargo.toml index c0eb51d4d06..a0c5fc32c83 100644 --- a/nym-api/nym-api-requests/Cargo.toml +++ b/nym-api/nym-api-requests/Cargo.toml @@ -37,8 +37,9 @@ nym-ecash-time = { path = "../../common/ecash-time" } nym-compact-ecash = { path = "../../common/nym_offline_compact_ecash" } nym-contracts-common = { path = "../../common/cosmwasm-smart-contracts/contracts-common", features = ["naive_float"] } nym-mixnet-contract-common = { path = "../../common/cosmwasm-smart-contracts/mixnet-contract", features = ["utoipa"] } +nym-coconut-dkg-common = { path = "../../common/cosmwasm-smart-contracts/coconut-dkg" } nym-node-requests = { path = "../../nym-node/nym-node-requests", default-features = false, features = ["openapi"] } -nym-noise-keys = { path = "../../common/nymnoise/keys"} +nym-noise-keys = { path = "../../common/nymnoise/keys" } nym-network-defaults = { path = "../../common/network-defaults" } nym-ticketbooks-merkle = { path = "../../common/ticketbooks-merkle" } diff --git a/nym-api/nym-api-requests/src/ecash/models.rs b/nym-api/nym-api-requests/src/ecash/models.rs index 3779d27e17d..60019256249 100644 --- a/nym-api/nym-api-requests/src/ecash/models.rs +++ b/nym-api/nym-api-requests/src/ecash/models.rs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 use cosmrs::AccountId; +use nym_coconut_dkg_common::types::EpochId; use nym_compact_ecash::scheme::coin_indices_signatures::AnnotatedCoinIndexSignature; use nym_compact_ecash::scheme::expiration_date_signatures::AnnotatedExpirationDateSignature; use nym_compact_ecash::utils::try_deserialize_g1_projective; @@ -546,11 +547,7 @@ pub struct CommitedDeposit { // make sure only our types can implement this trait (to ensure infallible serialisation) mod private { - use crate::ecash::models::{ - IssuedTicketbooksChallengeCommitmentRequestBody, - IssuedTicketbooksChallengeCommitmentResponseBody, IssuedTicketbooksDataRequestBody, - IssuedTicketbooksDataResponseBody, IssuedTicketbooksForResponseBody, - }; + use crate::ecash::models::*; pub trait Sealed {} @@ -562,6 +559,7 @@ mod private { impl Sealed for IssuedTicketbooksChallengeCommitmentResponseBody {} impl Sealed for IssuedTicketbooksForResponseBody {} impl Sealed for IssuedTicketbooksDataResponseBody {} + impl Sealed for EcashSignerStatusResponseBody {} } // the trait is not public as it's only defined on types that are guaranteed to not panic when serialised @@ -826,6 +824,33 @@ pub struct IssuedTicketbooksForCount { pub count: u32, } +pub type EcashSignerStatusResponse = SignedMessage; + +#[derive(Serialize, Deserialize, ToSchema, Clone, Debug)] +#[serde(rename_all = "camelCase")] +// includes all pre-requisites for successful (assuming valid request) `/blind-sign` +pub struct EcashSignerStatusResponseBody { + /// Current, perceived, dkg epoch id + pub dkg_ecash_epoch_id: EpochId, + + /// Flag indicating whether the operator has explicitly disabled signer functionalities in the api + pub signer_disabled: bool, + + /// Flag indicating whether this api thinks it's a valid ecash signer for the current epoch + pub is_ecash_signer: bool, + + /// Flag indicating whether this api thinks it has valid signing keys. + /// It might be a valid signer that's not disabled, but the keys might have accidentally been + /// removed due to invalid data migration. + pub has_signing_keys: bool, +} + +impl EcashSignerStatusResponseBody { + pub fn is_active(&self) -> bool { + !self.signer_disabled && self.is_ecash_signer && self.has_signing_keys + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/nym-api/nym-api-requests/src/models.rs b/nym-api/nym-api-requests/src/models.rs index 3d4447dfff6..2c8b38a7bb5 100644 --- a/nym-api/nym-api-requests/src/models.rs +++ b/nym-api/nym-api-requests/src/models.rs @@ -1669,19 +1669,19 @@ impl From for RewardedSetResponse } } -#[derive(Clone, Serialize, Deserialize, JsonSchema, ToSchema)] +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, ToSchema)] pub struct ChainStatusResponse { pub connected_nyxd: String, pub status: DetailedChainStatus, } -#[derive(Clone, Serialize, Deserialize, JsonSchema, ToSchema)] +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, ToSchema)] pub struct DetailedChainStatus { pub abci: crate::models::tendermint_types::AbciInfo, pub latest_block: BlockInfo, } -#[derive(Clone, Serialize, Deserialize, JsonSchema, ToSchema)] +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, ToSchema)] pub struct BlockInfo { pub block_id: BlockId, pub block: FullBlockInfo, @@ -1699,7 +1699,7 @@ impl From for BlockInfo { } } -#[derive(Clone, Serialize, Deserialize, JsonSchema, ToSchema)] +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, ToSchema)] pub struct FullBlockInfo { pub header: BlockHeader, } @@ -1713,7 +1713,7 @@ pub mod tendermint_types { use tendermint::{block, Hash}; use utoipa::ToSchema; - #[derive(Clone, Serialize, Deserialize, JsonSchema, ToSchema)] + #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, ToSchema)] pub struct AbciInfo { /// Some arbitrary information. pub data: String, diff --git a/nym-api/src/ecash/api_routes/handlers.rs b/nym-api/src/ecash/api_routes/handlers.rs index 9114ebba9f2..5b7edfefbeb 100644 --- a/nym-api/src/ecash/api_routes/handlers.rs +++ b/nym-api/src/ecash/api_routes/handlers.rs @@ -4,8 +4,10 @@ use crate::ecash::api_routes::aggregation::aggregation_routes; use crate::ecash::api_routes::issued::issued_routes; use crate::ecash::api_routes::partial_signing::partial_signing_routes; +use crate::ecash::api_routes::signer_status::signer_status; use crate::ecash::api_routes::spending::spending_routes; use crate::support::http::state::AppState; +use axum::routing::get; use axum::Router; pub(crate) fn ecash_routes() -> Router { @@ -14,4 +16,5 @@ pub(crate) fn ecash_routes() -> Router { .merge(issued_routes()) .merge(partial_signing_routes()) .merge(spending_routes()) + .route("/signer-status", get(signer_status)) } diff --git a/nym-api/src/ecash/api_routes/mod.rs b/nym-api/src/ecash/api_routes/mod.rs index 18bdf01d4e2..2588905654f 100644 --- a/nym-api/src/ecash/api_routes/mod.rs +++ b/nym-api/src/ecash/api_routes/mod.rs @@ -6,4 +6,5 @@ pub(crate) mod handlers; mod helpers; pub(crate) mod issued; pub(crate) mod partial_signing; +pub(crate) mod signer_status; pub(crate) mod spending; diff --git a/nym-api/src/ecash/api_routes/signer_status.rs b/nym-api/src/ecash/api_routes/signer_status.rs new file mode 100644 index 00000000000..1ec86380001 --- /dev/null +++ b/nym-api/src/ecash/api_routes/signer_status.rs @@ -0,0 +1,45 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use crate::ecash::state::EcashState; +use crate::node_status_api::models::ApiResult; +use axum::extract::{Query, State}; +use nym_api_requests::ecash::models::{ + EcashSignerStatusResponse, EcashSignerStatusResponseBody, SignableMessageBody, +}; +use nym_http_api_common::{FormattedResponse, OutputParams}; +use std::sync::Arc; + +#[utoipa::path( + tag = "Ecash", + get, + path = "/signer-status", + context_path = "/v1/ecash", + responses( + (status = 200, content( + (EcashSignerStatusResponse = "application/json"), + (EcashSignerStatusResponse = "application/yaml"), + (EcashSignerStatusResponse = "application/bincode") + )), + (status = 400, body = String, description = "this nym-api is not an ecash signer in the current epoch"), + ), + params(OutputParams) +)] +pub(crate) async fn signer_status( + Query(params): Query, + State(state): State>, +) -> ApiResult> { + let output = params.get_output(); + + let dkg_ecash_epoch_id = state.current_dkg_epoch().await?; + + Ok(output.to_response( + EcashSignerStatusResponseBody { + dkg_ecash_epoch_id, + signer_disabled: state.local.explicitly_disabled, + is_ecash_signer: state.is_dkg_signer(dkg_ecash_epoch_id).await?, + has_signing_keys: state.ecash_signing_key().await.is_ok(), + } + .sign(state.local.identity_keypair.private_key()), + )) +} diff --git a/nym-api/src/ecash/state/mod.rs b/nym-api/src/ecash/state/mod.rs index d9ac0e5dce8..644a4b32fac 100644 --- a/nym-api/src/ecash/state/mod.rs +++ b/nym-api/src/ecash/state/mod.rs @@ -50,7 +50,6 @@ use nym_validator_client::nyxd::AccountId; use nym_validator_client::EcashApiClient; use rand::{thread_rng, RngCore}; use std::collections::HashMap; -use std::ops::Deref; use time::{Date, OffsetDateTime}; use tokio::sync::{RwLockReadGuard, RwLockWriteGuard}; use tokio::task::JoinHandle; @@ -162,14 +161,11 @@ impl EcashState { } } - /// Ensures that this nym-api is one of ecash signers for the current epoch - pub(crate) async fn ensure_signer(&self) -> Result<()> { - if self.local.explicitly_disabled { - return Err(EcashError::NotASigner); - } - - let epoch_id = self.aux.current_epoch().await?; + pub(crate) async fn current_dkg_epoch(&self) -> Result { + self.aux.current_epoch().await + } + pub(crate) async fn is_dkg_signer(&self, epoch_id: EpochId) -> Result { let is_epoch_signer = self .local .active_signer @@ -183,8 +179,19 @@ impl EcashState { Ok(ecash_signers.iter().any(|c| c.cosmos_address == address)) }) .await?; + Ok(*is_epoch_signer) + } + + /// Ensures that this nym-api is one of ecash signers for the current epoch + pub(crate) async fn ensure_signer(&self) -> Result<()> { + if self.local.explicitly_disabled { + return Err(EcashError::NotASigner); + } + + let epoch_id = self.current_dkg_epoch().await?; + let is_epoch_signer = self.is_dkg_signer(epoch_id).await?; - if !is_epoch_signer.deref() { + if !is_epoch_signer { return Err(EcashError::NotASigner); } diff --git a/nym-wallet/Cargo.lock b/nym-wallet/Cargo.lock index cd657d15615..32fdd73dfc0 100644 --- a/nym-wallet/Cargo.lock +++ b/nym-wallet/Cargo.lock @@ -4023,6 +4023,7 @@ dependencies = [ "getset", "hex", "humantime-serde", + "nym-coconut-dkg-common", "nym-compact-ecash", "nym-config", "nym-contracts-common", diff --git a/tools/internal/testnet-manager/src/manager/dkg_skip.rs b/tools/internal/testnet-manager/src/manager/dkg_skip.rs index ea33af03fb3..9f87a468fa6 100644 --- a/tools/internal/testnet-manager/src/manager/dkg_skip.rs +++ b/tools/internal/testnet-manager/src/manager/dkg_skip.rs @@ -412,10 +412,10 @@ impl NetworkManager { let mut receivers = Vec::new(); for signer in &ctx.ecash_signers { - // send 101nym to the admin + // send 250nym to the admin receivers.push(( signer.data.cosmos_account.address.clone(), - admin.mix_coins(101_000000), + admin.mix_coins(250_000000), )) } From 25132f33b46ce5442044abaa8ddeb51f273a891f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C4=99drzej=20Stuczy=C5=84ski?= Date: Wed, 9 Jul 2025 10:20:08 +0100 Subject: [PATCH 02/10] refactored the code to expose bool-only methods for status --- Cargo.lock | 3 + common/ecash-signer-check/Cargo.toml | 3 + common/ecash-signer-check/src/chain_status.rs | 24 ++++++- common/ecash-signer-check/src/client_check.rs | 61 ++++++++--------- .../src/dealer_information.rs | 66 ++++++++++++++++++ common/ecash-signer-check/src/error.rs | 14 ++-- common/ecash-signer-check/src/lib.rs | 68 +++---------------- .../ecash-signer-check/src/signing_status.rs | 39 +++++++++++ common/ecash-signer-check/src/status.rs | 63 +++++++++++++++++ nym-api/nym-api-requests/src/ecash/models.rs | 6 -- 10 files changed, 246 insertions(+), 101 deletions(-) create mode 100644 common/ecash-signer-check/src/dealer_information.rs create mode 100644 common/ecash-signer-check/src/status.rs diff --git a/Cargo.lock b/Cargo.lock index b73ec8c22ac..4687f2cea53 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5811,10 +5811,13 @@ name = "nym-ecash-signer-check" version = "0.1.0" dependencies = [ "futures", + "nym-crypto 0.4.0", "nym-network-defaults 0.1.0", "nym-validator-client 0.1.0", "semver 1.0.26", "thiserror 2.0.12", + "time", + "tokio", "tracing", "url", ] diff --git a/common/ecash-signer-check/Cargo.toml b/common/ecash-signer-check/Cargo.toml index 722d3c09b57..5cea24e3e98 100644 --- a/common/ecash-signer-check/Cargo.toml +++ b/common/ecash-signer-check/Cargo.toml @@ -14,12 +14,15 @@ readme.workspace = true futures = { workspace = true } thiserror = { workspace = true } semver = { workspace = true } +time = { workspace = true } +tokio = { workspace = true, features = ["time"] } tracing = { workspace = true } url = { workspace = true } nym-validator-client = { path = "../client-libs/validator-client" } nym-network-defaults = { path = "../network-defaults" } +nym-crypto = { path = "../crypto", features = ["asymmetric"] } diff --git a/common/ecash-signer-check/src/chain_status.rs b/common/ecash-signer-check/src/chain_status.rs index c5e75650af5..bc87064bc1b 100644 --- a/common/ecash-signer-check/src/chain_status.rs +++ b/common/ecash-signer-check/src/chain_status.rs @@ -2,9 +2,12 @@ // SPDX-License-Identifier: Apache-2.0 use nym_validator_client::models::ChainStatusResponse; +use std::time::Duration; +use time::OffsetDateTime; // Dorina pub(crate) const MINIMUM_VERSION: semver::Version = semver::Version::new(1, 1, 51); +const CHAIN_STALL_THRESHOLD: Duration = Duration::from_secs(5 * 60); #[derive(Debug)] pub enum LocalChainStatus { @@ -14,7 +17,22 @@ pub enum LocalChainStatus { /// The API is running an outdated version that does not expose the required endpoint Outdated, - /// Response to the status query - // unfortunately this response is not signed, but it's not the end of the world - Reachable { response: Box }, + /// Response to the [legacy] status query + ReachableLegacy { response: Box }, + // Reachable { + // response: (), + // }, +} + +impl LocalChainStatus { + pub fn available(&self) -> bool { + let LocalChainStatus::ReachableLegacy { response } = self else { + return false; + }; + + let now = OffsetDateTime::now_utc(); + let block_time: OffsetDateTime = response.status.latest_block.block.header.time.into(); + let diff = now - block_time; + diff <= CHAIN_STALL_THRESHOLD + } } diff --git a/common/ecash-signer-check/src/client_check.rs b/common/ecash-signer-check/src/client_check.rs index 01cded52eda..8519bcc47a2 100644 --- a/common/ecash-signer-check/src/client_check.rs +++ b/common/ecash-signer-check/src/client_check.rs @@ -2,26 +2,27 @@ // SPDX-License-Identifier: Apache-2.0 use crate::chain_status::LocalChainStatus; +use crate::dealer_information::RawDealerInformation; use crate::signing_status::SigningStatus; -use crate::{ - chain_status, signing_status, SignerInformation, SignerResult, SignerStatus, SignerTestResult, -}; +use crate::status::{SignerStatus, SignerTestResult}; +use crate::{chain_status, signing_status, SignerResult}; use nym_validator_client::client::NymApiClientExt; use nym_validator_client::models::BinaryBuildInformationOwned; -use nym_validator_client::nyxd::contract_traits::dkg_query_client::ContractVKShare; -use nym_validator_client::EcashApiClient; +use nym_validator_client::nyxd::contract_traits::dkg_query_client::DealerDetails; +use nym_validator_client::NymApiClient; use std::time::Duration; use tracing::{error, warn}; +use url::Url; struct ClientUnderTest { - api_client: EcashApiClient, + api_client: NymApiClient, build_info: Option, } impl ClientUnderTest { - pub(crate) fn new(api_client: EcashApiClient) -> Self { + pub(crate) fn new(api_url: &Url) -> Self { ClientUnderTest { - api_client, + api_client: NymApiClient::new(api_url.clone()), build_info: None, } } @@ -29,7 +30,7 @@ impl ClientUnderTest { pub(crate) async fn try_retrieve_build_information(&mut self) -> bool { match tokio::time::timeout( Duration::from_secs(5), - self.api_client.api_client.nym_api.build_information(), + self.api_client.nym_api.build_information(), ) .await { @@ -38,13 +39,13 @@ impl ClientUnderTest { true } Ok(Err(err)) => { - warn!("{}: failed to retrieve build information: {err}. the signer is most likely down", self.api_client); + warn!("{}: failed to retrieve build information: {err}. the signer is most likely down", self.api_client.api_url()); false } Err(_timeout) => { warn!( "{}: timed out while attempting to retrieve build information", - self.api_client + self.api_client.api_url() ); false } @@ -59,7 +60,8 @@ impl ClientUnderTest { .inspect_err(|err| { error!( "ecash signer '{}' reports invalid version {}: {err}", - self.api_client, build_info.build_version + self.api_client.api_url(), + build_info.build_version ) }) .ok() @@ -85,14 +87,14 @@ impl ClientUnderTest { return LocalChainStatus::Outdated; } - match self.api_client.api_client.nym_api.get_chain_status().await { - Ok(status) => LocalChainStatus::Reachable { + match self.api_client.nym_api.get_chain_status().await { + Ok(status) => LocalChainStatus::ReachableLegacy { response: Box::new(status), }, Err(err) => { warn!( "{}: failed to retrieve local chain status: {err}", - self.api_client + self.api_client.api_url() ); LocalChainStatus::Unreachable } @@ -104,12 +106,12 @@ impl ClientUnderTest { return SigningStatus::Outdated; } - match self.api_client.api_client.nym_api.get_signer_status().await { + match self.api_client.nym_api.get_signer_status().await { Ok(response) => SigningStatus::Reachable { response }, Err(err) => { warn!( "{}: failed to retrieve signer chain status: {err}", - self.api_client + self.api_client.api_url() ); SigningStatus::Unreachable } @@ -117,28 +119,25 @@ impl ClientUnderTest { } } -pub(crate) async fn check_client(raw_share: ContractVKShare) -> SignerResult { - let signer_information: SignerInformation = (&raw_share).into(); +pub(crate) async fn check_client(dealer_details: DealerDetails, dkg_epoch: u64) -> SignerResult { + let dealer_information = RawDealerInformation::from(&dealer_details); - // 4. attempt to construct client instances out of them - // (don't use `all_ecash_api_clients` as we want to treat each error individually; - // for example during epoch advancement we still want to be able to perform monitoring - // even if some shares are unverified) - let Ok(client) = EcashApiClient::try_from(raw_share) else { - return SignerStatus::ProvidedInvalidDetails.with_signer_information(signer_information); + // 5. attempt to construct client instances out of them + let Ok(parsed_information) = dealer_information.parse() else { + return SignerStatus::ProvidedInvalidDetails.with_details(dealer_information, dkg_epoch); }; - let mut client = ClientUnderTest::new(client); + let mut client = ClientUnderTest::new(&parsed_information.announce_address); - // 5. check basic connection status - can you retrieve build information? + // 6. check basic connection status - can you retrieve build information? if !client.try_retrieve_build_information().await { - return SignerStatus::Unreachable.with_signer_information(signer_information); + return SignerStatus::Unreachable.with_details(dealer_information, dkg_epoch); } - // 6. check perceived chain status + // 7. check perceived chain status let local_chain_status = client.check_local_chain().await; - // 7. check signer status + // 8. check signer status let signing_status = client.check_signing_status().await; SignerStatus::Tested { @@ -148,5 +147,5 @@ pub(crate) async fn check_client(raw_share: ContractVKShare) -> SignerResult { local_chain_status, }, } - .with_signer_information(signer_information) + .with_details(dealer_information, dkg_epoch) } diff --git a/common/ecash-signer-check/src/dealer_information.rs b/common/ecash-signer-check/src/dealer_information.rs new file mode 100644 index 00000000000..f449eae4dc0 --- /dev/null +++ b/common/ecash-signer-check/src/dealer_information.rs @@ -0,0 +1,66 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::SignerCheckError; +use nym_crypto::asymmetric::ed25519; +use nym_validator_client::nyxd::contract_traits::dkg_query_client::DealerDetails; +use url::Url; + +#[derive(Debug)] +pub struct RawDealerInformation { + pub announce_address: String, + pub owner_address: String, + pub node_index: u64, + pub public_key: String, +} + +impl RawDealerInformation { + pub fn parse(&self) -> Result { + Ok(DealerInformation { + announce_address: self.announce_address.parse().map_err(|source| { + SignerCheckError::InvalidDealerAddress { + dealer_url: self.announce_address.clone(), + source, + } + })?, + owner_address: self.owner_address.clone(), + node_index: self.node_index, + public_key: self.announce_address.parse().map_err(|source| { + SignerCheckError::InvalidDealerPubkey { + dealer_url: self.announce_address.clone(), + source, + } + })?, + }) + } +} + +impl From<&DealerDetails> for RawDealerInformation { + fn from(d: &DealerDetails) -> Self { + RawDealerInformation { + announce_address: d.announce_address.clone(), + owner_address: d.address.to_string(), + node_index: d.assigned_index, + public_key: d.ed25519_identity.clone(), + } + } +} + +#[derive(Debug)] +pub struct DealerInformation { + pub announce_address: Url, + pub owner_address: String, + pub node_index: u64, + pub public_key: ed25519::PublicKey, +} + +impl From for RawDealerInformation { + fn from(d: DealerInformation) -> Self { + RawDealerInformation { + announce_address: d.announce_address.to_string(), + owner_address: d.owner_address, + node_index: d.node_index, + public_key: d.public_key.to_base58_string(), + } + } +} diff --git a/common/ecash-signer-check/src/error.rs b/common/ecash-signer-check/src/error.rs index 01d10f7a764..10f2c1568ff 100644 --- a/common/ecash-signer-check/src/error.rs +++ b/common/ecash-signer-check/src/error.rs @@ -1,7 +1,7 @@ // Copyright 2025 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 -use nym_validator_client::coconut::EcashApiError; +use nym_crypto::asymmetric::ed25519::Ed25519RecoveryError; use nym_validator_client::nyxd::error::NyxdError; use thiserror::Error; @@ -13,10 +13,16 @@ pub enum SignerCheckError { #[error("failed to query the DKG contract: {source}")] DKGContractQueryFailure { source: NyxdError }, - #[error("dealer at {dealer_url} has provided invalid data: {source}")] - InvalidDkgDealer { + #[error("dealer at {dealer_url} has provided invalid ed25519 pubkey: {source}")] + InvalidDealerPubkey { dealer_url: String, - source: EcashApiError, + source: Ed25519RecoveryError, + }, + + #[error("dealer at {dealer_url} has provided invalid announce url: {source}")] + InvalidDealerAddress { + dealer_url: String, + source: url::ParseError, }, } diff --git a/common/ecash-signer-check/src/lib.rs b/common/ecash-signer-check/src/lib.rs index 65a63345e67..66bb4005109 100644 --- a/common/ecash-signer-check/src/lib.rs +++ b/common/ecash-signer-check/src/lib.rs @@ -1,68 +1,22 @@ // Copyright 2025 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 -use crate::chain_status::LocalChainStatus; use crate::client_check::check_client; -use crate::signing_status::SigningStatus; use futures::stream::{FuturesUnordered, StreamExt}; use nym_network_defaults::NymNetworkDetails; -use nym_validator_client::nyxd::contract_traits::dkg_query_client::ContractVKShare; use nym_validator_client::nyxd::contract_traits::{DkgQueryClient, PagedDkgQueryClient}; use nym_validator_client::QueryHttpRpcNyxdClient; use url::Url; +pub use crate::status::SignerResult; pub use error::SignerCheckError; -mod chain_status; +pub mod chain_status; mod client_check; +pub mod dealer_information; pub mod error; -pub(crate) mod signing_status; - -#[derive(Debug)] -pub struct SignerInformation { - pub announce_address: String, - pub owner_address: String, - pub node_index: u64, -} - -impl From<&ContractVKShare> for SignerInformation { - fn from(share: &ContractVKShare) -> Self { - SignerInformation { - announce_address: share.announce_address.clone(), - owner_address: share.owner.to_string(), - node_index: share.node_index, - } - } -} - -#[derive(Debug)] -pub struct SignerResult { - pub information: SignerInformation, - pub status: SignerStatus, -} - -#[derive(Debug)] -pub enum SignerStatus { - Unreachable, - ProvidedInvalidDetails, - Tested { result: SignerTestResult }, -} - -impl SignerStatus { - pub fn with_signer_information(self, information: SignerInformation) -> SignerResult { - SignerResult { - status: self, - information, - } - } -} - -#[derive(Debug)] -pub struct SignerTestResult { - pub reported_version: String, - pub signing_status: SigningStatus, - pub local_chain_status: LocalChainStatus, -} +pub mod signing_status; +pub mod status; pub async fn check_signers( rpc_endpoint: Url, @@ -82,16 +36,16 @@ pub async fn check_signers( .await .map_err(SignerCheckError::dkg_contract_query_failure)?; - // 3. retrieve list of all current dealers (signers) - let shares = client - .get_all_verification_key_shares(dkg_epoch.epoch_id) + // 3. retrieve information on current DKG dealers (i.e. eligible signers) + let dealers = client + .get_all_current_dealers() .await .map_err(SignerCheckError::dkg_contract_query_failure)?; - // 4. for each share, attempt to check corresponding signer - let results = shares + // 4. for each dealer attempt to perform the checks + let results = dealers .into_iter() - .map(check_client) + .map(|d| check_client(d, dkg_epoch.epoch_id)) .collect::>() .collect::>() .await; diff --git a/common/ecash-signer-check/src/signing_status.rs b/common/ecash-signer-check/src/signing_status.rs index 0dfbf0c8742..13c32481dd9 100644 --- a/common/ecash-signer-check/src/signing_status.rs +++ b/common/ecash-signer-check/src/signing_status.rs @@ -1,7 +1,9 @@ // Copyright 2025 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 +use nym_crypto::asymmetric::ed25519; use nym_validator_client::ecash::models::EcashSignerStatusResponse; +use tracing::{debug, warn}; // Emmental pub(crate) const MINIMUM_VERSION: semver::Version = semver::Version::new(1, 1, 62); @@ -17,3 +19,40 @@ pub enum SigningStatus { /// Response to the status query Reachable { response: EcashSignerStatusResponse }, } + +impl SigningStatus { + pub fn available(&self, pub_key: ed25519::PublicKey, dkg_epoch_id: u64) -> bool { + let SigningStatus::Reachable { response } = self else { + return false; + }; + if !response.verify_signature(&pub_key) { + warn!("failed signature verification on signer status response"); + return false; + } + + if !response.body.has_signing_keys { + debug!("missing signing keys"); + return false; + } + + if response.body.signer_disabled { + debug!("signer functionalities explicitly disabled"); + return false; + } + + if !response.body.is_ecash_signer { + debug!("signer doesn't recognise it's a signer for this epoch"); + return false; + } + + if dkg_epoch_id != response.body.dkg_ecash_epoch_id { + debug!( + "mismatched dkg epoch id. current: {dkg_epoch_id}, signer's: {}", + response.body.dkg_ecash_epoch_id + ); + return false; + } + + true + } +} diff --git a/common/ecash-signer-check/src/status.rs b/common/ecash-signer-check/src/status.rs new file mode 100644 index 00000000000..438024df5a5 --- /dev/null +++ b/common/ecash-signer-check/src/status.rs @@ -0,0 +1,63 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::chain_status::LocalChainStatus; +use crate::dealer_information::RawDealerInformation; +use crate::signing_status::SigningStatus; + +#[derive(Debug)] +pub struct SignerResult { + pub dkg_epoch_id: u64, + pub information: RawDealerInformation, + pub status: SignerStatus, +} + +impl SignerResult { + pub fn chain_available(&self) -> bool { + let SignerStatus::Tested { result } = &self.status else { + return false; + }; + result.local_chain_status.available() + } + + pub fn signer_available(&self) -> bool { + let Ok(parsed_info) = self.information.parse() else { + return false; + }; + let SignerStatus::Tested { result } = &self.status else { + return false; + }; + + result + .signing_status + .available(parsed_info.public_key, self.dkg_epoch_id) + } +} + +#[derive(Debug)] +pub enum SignerStatus { + Unreachable, + ProvidedInvalidDetails, + Tested { result: SignerTestResult }, +} + +impl SignerStatus { + pub fn with_details( + self, + information: impl Into, + dkg_epoch_id: u64, + ) -> SignerResult { + SignerResult { + dkg_epoch_id, + status: self, + information: information.into(), + } + } +} + +#[derive(Debug)] +pub struct SignerTestResult { + pub reported_version: String, + pub signing_status: SigningStatus, + pub local_chain_status: LocalChainStatus, +} diff --git a/nym-api/nym-api-requests/src/ecash/models.rs b/nym-api/nym-api-requests/src/ecash/models.rs index 60019256249..db4f85d53f7 100644 --- a/nym-api/nym-api-requests/src/ecash/models.rs +++ b/nym-api/nym-api-requests/src/ecash/models.rs @@ -845,12 +845,6 @@ pub struct EcashSignerStatusResponseBody { pub has_signing_keys: bool, } -impl EcashSignerStatusResponseBody { - pub fn is_active(&self) -> bool { - !self.signer_disabled && self.is_ecash_signer && self.has_signing_keys - } -} - #[cfg(test)] mod tests { use super::*; From af272e243719330670c271017e09d1f1ce06c3f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C4=99drzej=20Stuczy=C5=84ski?= Date: Wed, 9 Jul 2025 15:09:02 +0100 Subject: [PATCH 03/10] added fallback legacy queries to get basic support idea --- .../validator-client/src/nym_api/mod.rs | 18 +++- .../nyxd/contract_traits/dkg_query_client.rs | 6 +- common/ecash-signer-check/src/chain_status.rs | 49 ++++++--- common/ecash-signer-check/src/client_check.rs | 83 +++++++++++--- .../src/dealer_information.rs | 38 ++++--- common/ecash-signer-check/src/lib.rs | 17 ++- .../ecash-signer-check/src/signing_status.rs | 102 +++++++++++++----- common/ecash-signer-check/src/status.rs | 18 +++- nym-api/nym-api-requests/src/ecash/models.rs | 73 ++----------- nym-api/nym-api-requests/src/lib.rs | 1 + nym-api/nym-api-requests/src/models.rs | 38 ++++++- nym-api/nym-api-requests/src/signable.rs | 71 ++++++++++++ nym-api/src/ecash/api_routes/issued.rs | 3 +- nym-api/src/ecash/api_routes/signer_status.rs | 8 +- nym-api/src/network/handlers.rs | 66 ++++++++++-- nym-api/src/status/handlers.rs | 14 +-- nym-api/src/support/config/mod.rs | 3 +- nym-api/src/support/http/state/mod.rs | 7 ++ .../rewarder/ticketbook_issuance/verifier.rs | 2 +- 19 files changed, 445 insertions(+), 172 deletions(-) create mode 100644 nym-api/nym-api-requests/src/signable.rs 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 8ae2b830b07..31b7014f641 100644 --- a/common/client-libs/validator-client/src/nym_api/mod.rs +++ b/common/client-libs/validator-client/src/nym_api/mod.rs @@ -14,9 +14,10 @@ use nym_api_requests::ecash::models::{ }; use nym_api_requests::ecash::VerificationKeyResponse; use nym_api_requests::models::{ - AnnotationResponse, ApiHealthResponse, BinaryBuildInformationOwned, ChainStatusResponse, - KeyRotationInfoResponse, LegacyDescribedMixNode, NodePerformanceResponse, NodeRefreshBody, - NymNodeDescription, PerformanceHistoryResponse, RewardedSetResponse, + AnnotationResponse, ApiHealthResponse, BinaryBuildInformationOwned, ChainBlocksStatusResponse, + ChainStatusResponse, KeyRotationInfoResponse, LegacyDescribedMixNode, NodePerformanceResponse, + NodeRefreshBody, NymNodeDescription, PerformanceHistoryResponse, RewardedSetResponse, + SignerInformationResponse, }; use nym_api_requests::nym_nodes::{ NodesByAddressesRequestBody, NodesByAddressesResponse, PaginatedCachedNodesResponseV1, @@ -1332,11 +1333,22 @@ pub trait NymApiClientExt: ApiClient { .await } + async fn get_chain_blocks_status(&self) -> Result { + self.get_json("/v1/network/chain-blocks-status", NO_PARAMS) + .await + } + #[instrument(level = "debug", skip(self))] async fn get_signer_status(&self) -> Result { self.get_json("/v1/ecash/signer-status", NO_PARAMS).await } + #[instrument(level = "debug", skip(self))] + async fn get_signer_information(&self) -> Result { + self.get_json("/v1/api-status/signer-information", NO_PARAMS) + .await + } + #[instrument(level = "debug", skip(self))] async fn get_key_rotation_info(&self) -> Result { self.get_json( diff --git a/common/client-libs/validator-client/src/nyxd/contract_traits/dkg_query_client.rs b/common/client-libs/validator-client/src/nyxd/contract_traits/dkg_query_client.rs index 8674d7cb0a1..ae8fec2aa07 100644 --- a/common/client-libs/validator-client/src/nyxd/contract_traits/dkg_query_client.rs +++ b/common/client-libs/validator-client/src/nyxd/contract_traits/dkg_query_client.rs @@ -8,11 +8,11 @@ use crate::nyxd::CosmWasmClient; use async_trait::async_trait; use cosmrs::AccountId; use cosmwasm_std::Addr; +use nym_coconut_dkg_common::dealer::RegisteredDealerDetails; use nym_coconut_dkg_common::types::{ChunkIndex, NodeIndex, StateAdvanceResponse}; use serde::Deserialize; use tracing::trace; -use nym_coconut_dkg_common::dealer::RegisteredDealerDetails; pub use nym_coconut_dkg_common::{ dealer::{DealerDetailsResponse, PagedDealerIndexResponse, PagedDealerResponse}, dealing::{ @@ -21,7 +21,9 @@ pub use nym_coconut_dkg_common::{ }, msg::QueryMsg as DkgQueryMsg, types::{DealerDetails, DealingIndex, Epoch, EpochId, EpochState, State}, - verification_key::{ContractVKShare, PagedVKSharesResponse, VkShareResponse}, + verification_key::{ + ContractVKShare, PagedVKSharesResponse, VerificationKeyShare, VkShareResponse, + }, }; #[cfg_attr(target_arch = "wasm32", async_trait(?Send))] diff --git a/common/ecash-signer-check/src/chain_status.rs b/common/ecash-signer-check/src/chain_status.rs index bc87064bc1b..8897d8e485b 100644 --- a/common/ecash-signer-check/src/chain_status.rs +++ b/common/ecash-signer-check/src/chain_status.rs @@ -1,12 +1,19 @@ // Copyright 2025 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 -use nym_validator_client::models::ChainStatusResponse; +use crate::status::STALE_RESPONSE_THRESHOLD; +use nym_crypto::asymmetric::ed25519; +use nym_validator_client::models::{ChainBlocksStatusResponse, ChainStatusResponse}; use std::time::Duration; use time::OffsetDateTime; +use tracing::warn; // Dorina -pub(crate) const MINIMUM_VERSION: semver::Version = semver::Version::new(1, 1, 51); +pub(crate) const MINIMUM_VERSION_LEGACY: semver::Version = semver::Version::new(1, 1, 51); + +// Emmental +pub(crate) const MINIMUM_VERSION: semver::Version = semver::Version::new(1, 1, 62); + const CHAIN_STALL_THRESHOLD: Duration = Duration::from_secs(5 * 60); #[derive(Debug)] @@ -17,22 +24,36 @@ pub enum LocalChainStatus { /// The API is running an outdated version that does not expose the required endpoint Outdated, - /// Response to the [legacy] status query + /// Response to the legacy (unsigned) status query ReachableLegacy { response: Box }, - // Reachable { - // response: (), - // }, + + /// Response to the current (signed) status query + Reachable { + response: Box, + }, } impl LocalChainStatus { - pub fn available(&self) -> bool { - let LocalChainStatus::ReachableLegacy { response } = self else { - return false; - }; - + pub fn available(&self, pub_key: ed25519::PublicKey) -> bool { let now = OffsetDateTime::now_utc(); - let block_time: OffsetDateTime = response.status.latest_block.block.header.time.into(); - let diff = now - block_time; - diff <= CHAIN_STALL_THRESHOLD + match self { + LocalChainStatus::Unreachable | LocalChainStatus::Outdated => false, + LocalChainStatus::ReachableLegacy { response } => response + .status + .stall_status(now, CHAIN_STALL_THRESHOLD) + .is_synced(), + LocalChainStatus::Reachable { response } => { + if !response.verify_signature(&pub_key) { + warn!("failed signature verification on chain status response"); + return false; + } + + // we rely on information provided from the api itself AS LONG AS it's not too outdated + if response.body.current_time + STALE_RESPONSE_THRESHOLD < now { + return false; + } + response.body.chain_status.is_synced() + } + } } } diff --git a/common/ecash-signer-check/src/client_check.rs b/common/ecash-signer-check/src/client_check.rs index 8519bcc47a2..fca79d024a2 100644 --- a/common/ecash-signer-check/src/client_check.rs +++ b/common/ecash-signer-check/src/client_check.rs @@ -8,7 +8,9 @@ use crate::status::{SignerStatus, SignerTestResult}; use crate::{chain_status, signing_status, SignerResult}; use nym_validator_client::client::NymApiClientExt; use nym_validator_client::models::BinaryBuildInformationOwned; -use nym_validator_client::nyxd::contract_traits::dkg_query_client::DealerDetails; +use nym_validator_client::nyxd::contract_traits::dkg_query_client::{ + ContractVKShare, DealerDetails, +}; use nym_validator_client::NymApiClient; use std::time::Duration; use tracing::{error, warn}; @@ -68,6 +70,13 @@ impl ClientUnderTest { }) } + pub(crate) fn supports_legacy_signing_status_query(&self) -> bool { + let Some(version) = self.version() else { + return false; + }; + version >= signing_status::MINIMUM_LEGACY_VERSION + } + pub(crate) fn supports_signing_status_query(&self) -> bool { let Some(version) = self.version() else { return false; @@ -82,18 +91,43 @@ impl ClientUnderTest { version >= chain_status::MINIMUM_VERSION } + pub(crate) fn supports_legacy_chain_status_query(&self) -> bool { + let Some(version) = self.version() else { + return false; + }; + version >= chain_status::MINIMUM_VERSION_LEGACY + } + pub(crate) async fn check_local_chain(&self) -> LocalChainStatus { - if !self.supports_chain_status_query() { + // check if it at least supports legacy query + if !self.supports_legacy_chain_status_query() { return LocalChainStatus::Outdated; } + // check if it supports the current query + if self.supports_chain_status_query() { + return match self.api_client.nym_api.get_chain_blocks_status().await { + Ok(status) => LocalChainStatus::Reachable { + response: Box::new(status), + }, + Err(err) => { + warn!( + "{}: failed to retrieve local chain status: {err}", + self.api_client.api_url() + ); + LocalChainStatus::Unreachable + } + }; + } + + // fallback to the legacy query match self.api_client.nym_api.get_chain_status().await { Ok(status) => LocalChainStatus::ReachableLegacy { response: Box::new(status), }, Err(err) => { warn!( - "{}: failed to retrieve local chain status: {err}", + "{}: failed to retrieve [legacy] local chain status: {err}", self.api_client.api_url() ); LocalChainStatus::Unreachable @@ -102,42 +136,65 @@ impl ClientUnderTest { } pub(crate) async fn check_signing_status(&self) -> SigningStatus { - if !self.supports_signing_status_query() { + // check if it at least supports legacy query + if !self.supports_legacy_signing_status_query() { return SigningStatus::Outdated; } - match self.api_client.nym_api.get_signer_status().await { - Ok(response) => SigningStatus::Reachable { response }, + // check if it supports the current query + if self.supports_signing_status_query() { + return match self.api_client.nym_api.get_signer_status().await { + Ok(response) => SigningStatus::Reachable { response }, + Err(err) => { + warn!( + "{}: failed to retrieve signer chain status: {err}", + self.api_client.api_url() + ); + SigningStatus::Unreachable + } + }; + } + + // fallback to the legacy query + match self.api_client.nym_api.get_signer_information().await { + Ok(status) => SigningStatus::ReachableLegacy { + response: Box::new(status), + }, Err(err) => { warn!( - "{}: failed to retrieve signer chain status: {err}", + "{}: failed to retrieve [legacy] signer chain status: {err}", self.api_client.api_url() ); + // NOTE: this might equally mean the signing is disabled SigningStatus::Unreachable } } } } -pub(crate) async fn check_client(dealer_details: DealerDetails, dkg_epoch: u64) -> SignerResult { - let dealer_information = RawDealerInformation::from(&dealer_details); +pub(crate) async fn check_client( + dealer_details: DealerDetails, + dkg_epoch: u64, + contract_share: Option<&ContractVKShare>, +) -> SignerResult { + let dealer_information = RawDealerInformation::new(&dealer_details, contract_share); - // 5. attempt to construct client instances out of them + // 6. attempt to construct client instances out of them let Ok(parsed_information) = dealer_information.parse() else { return SignerStatus::ProvidedInvalidDetails.with_details(dealer_information, dkg_epoch); }; let mut client = ClientUnderTest::new(&parsed_information.announce_address); - // 6. check basic connection status - can you retrieve build information? + // 7. check basic connection status - can you retrieve build information? if !client.try_retrieve_build_information().await { return SignerStatus::Unreachable.with_details(dealer_information, dkg_epoch); } - // 7. check perceived chain status + // 8. check perceived chain status let local_chain_status = client.check_local_chain().await; - // 8. check signer status + // 9. check signer status let signing_status = client.check_signing_status().await; SignerStatus::Tested { diff --git a/common/ecash-signer-check/src/dealer_information.rs b/common/ecash-signer-check/src/dealer_information.rs index f449eae4dc0..73f13cc489e 100644 --- a/common/ecash-signer-check/src/dealer_information.rs +++ b/common/ecash-signer-check/src/dealer_information.rs @@ -3,7 +3,9 @@ use crate::SignerCheckError; use nym_crypto::asymmetric::ed25519; -use nym_validator_client::nyxd::contract_traits::dkg_query_client::DealerDetails; +use nym_validator_client::nyxd::contract_traits::dkg_query_client::{ + ContractVKShare, DealerDetails, VerificationKeyShare, +}; use url::Url; #[derive(Debug)] @@ -12,9 +14,25 @@ pub struct RawDealerInformation { pub owner_address: String, pub node_index: u64, pub public_key: String, + pub verification_key_share: Option, + pub share_verified: bool, } impl RawDealerInformation { + pub fn new( + dealer_details: &DealerDetails, + contract_share: Option<&ContractVKShare>, + ) -> RawDealerInformation { + RawDealerInformation { + announce_address: dealer_details.announce_address.clone(), + owner_address: dealer_details.address.to_string(), + node_index: dealer_details.assigned_index, + public_key: dealer_details.ed25519_identity.clone(), + verification_key_share: contract_share.map(|s| s.share.clone()), + share_verified: contract_share.map(|s| s.verified).unwrap_or(false), + } + } + pub fn parse(&self) -> Result { Ok(DealerInformation { announce_address: self.announce_address.parse().map_err(|source| { @@ -31,27 +49,21 @@ impl RawDealerInformation { source, } })?, + verification_key_share: self.verification_key_share.clone(), + share_verified: self.share_verified, }) } } -impl From<&DealerDetails> for RawDealerInformation { - fn from(d: &DealerDetails) -> Self { - RawDealerInformation { - announce_address: d.announce_address.clone(), - owner_address: d.address.to_string(), - node_index: d.assigned_index, - public_key: d.ed25519_identity.clone(), - } - } -} - #[derive(Debug)] pub struct DealerInformation { pub announce_address: Url, pub owner_address: String, pub node_index: u64, pub public_key: ed25519::PublicKey, + // no need to parse it into the full type as it doesn't get us anything + pub verification_key_share: Option, + pub share_verified: bool, } impl From for RawDealerInformation { @@ -61,6 +73,8 @@ impl From for RawDealerInformation { owner_address: d.owner_address, node_index: d.node_index, public_key: d.public_key.to_base58_string(), + verification_key_share: d.verification_key_share, + share_verified: d.share_verified, } } } diff --git a/common/ecash-signer-check/src/lib.rs b/common/ecash-signer-check/src/lib.rs index 66bb4005109..bcd3b34399a 100644 --- a/common/ecash-signer-check/src/lib.rs +++ b/common/ecash-signer-check/src/lib.rs @@ -6,6 +6,7 @@ use futures::stream::{FuturesUnordered, StreamExt}; use nym_network_defaults::NymNetworkDetails; use nym_validator_client::nyxd::contract_traits::{DkgQueryClient, PagedDkgQueryClient}; use nym_validator_client::QueryHttpRpcNyxdClient; +use std::collections::HashMap; use url::Url; pub use crate::status::SignerResult; @@ -42,10 +43,22 @@ pub async fn check_signers( .await .map_err(SignerCheckError::dkg_contract_query_failure)?; - // 4. for each dealer attempt to perform the checks + // 4. retrieve their published keys (if available) + let shares: HashMap<_, _> = client + .get_all_verification_key_shares(dkg_epoch.epoch_id) + .await + .map_err(SignerCheckError::dkg_contract_query_failure)? + .into_iter() + .map(|share| (share.node_index, share)) + .collect(); + + // 5. for each dealer attempt to perform the checks let results = dealers .into_iter() - .map(|d| check_client(d, dkg_epoch.epoch_id)) + .map(|d| { + let share = shares.get(&d.assigned_index); + check_client(d, dkg_epoch.epoch_id, share) + }) .collect::>() .collect::>() .await; diff --git a/common/ecash-signer-check/src/signing_status.rs b/common/ecash-signer-check/src/signing_status.rs index 13c32481dd9..8176f8886a0 100644 --- a/common/ecash-signer-check/src/signing_status.rs +++ b/common/ecash-signer-check/src/signing_status.rs @@ -1,10 +1,17 @@ // Copyright 2025 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 +use crate::status::STALE_RESPONSE_THRESHOLD; use nym_crypto::asymmetric::ed25519; use nym_validator_client::ecash::models::EcashSignerStatusResponse; +use nym_validator_client::models::SignerInformationResponse; +use nym_validator_client::nyxd::contract_traits::dkg_query_client::VerificationKeyShare; +use time::OffsetDateTime; use tracing::{debug, warn}; +// Magura (possibly earlier) +pub(crate) const MINIMUM_LEGACY_VERSION: semver::Version = semver::Version::new(1, 1, 46); + // Emmental pub(crate) const MINIMUM_VERSION: semver::Version = semver::Version::new(1, 1, 62); @@ -16,43 +23,80 @@ pub enum SigningStatus { /// The API is running an outdated version that does not expose the required endpoint Outdated, - /// Response to the status query + /// Response to the legacy (unsigned) status query + ReachableLegacy { + response: Box, + }, + + /// Response to the current (signed) status query Reachable { response: EcashSignerStatusResponse }, } impl SigningStatus { - pub fn available(&self, pub_key: ed25519::PublicKey, dkg_epoch_id: u64) -> bool { - let SigningStatus::Reachable { response } = self else { - return false; - }; - if !response.verify_signature(&pub_key) { - warn!("failed signature verification on signer status response"); - return false; - } + pub fn available( + &self, + pub_key: ed25519::PublicKey, + dkg_epoch_id: u64, + expected_verification_key: Option, + share_verified: bool, + ) -> bool { + let now = OffsetDateTime::now_utc(); + match self { + SigningStatus::Unreachable | SigningStatus::Outdated => false, + SigningStatus::ReachableLegacy { response } => { + if response.identity != pub_key.to_base58_string() { + warn!("mismatched identity key on the legacy response"); + return false; + } - if !response.body.has_signing_keys { - debug!("missing signing keys"); - return false; - } + // the contract share hasn't been verified yet, so we're probably in the middle of DKG + // thus if there's a bit of desync in the state, it's fine + if !share_verified { + return true; + } - if response.body.signer_disabled { - debug!("signer functionalities explicitly disabled"); - return false; - } + if response.verification_key != expected_verification_key { + warn!("mismatched [ecash] verification key on the legacy response"); + return false; + } - if !response.body.is_ecash_signer { - debug!("signer doesn't recognise it's a signer for this epoch"); - return false; - } + true + } + SigningStatus::Reachable { response } => { + if !response.verify_signature(&pub_key) { + warn!("failed signature verification on signer status response"); + return false; + } - if dkg_epoch_id != response.body.dkg_ecash_epoch_id { - debug!( - "mismatched dkg epoch id. current: {dkg_epoch_id}, signer's: {}", - response.body.dkg_ecash_epoch_id - ); - return false; - } + if response.body.current_time + STALE_RESPONSE_THRESHOLD < now { + return false; + } + + if !response.body.has_signing_keys { + debug!("missing signing keys"); + return false; + } + + if response.body.signer_disabled { + debug!("signer functionalities explicitly disabled"); + return false; + } - true + if !response.body.is_ecash_signer { + debug!("signer doesn't recognise it's a signer for this epoch"); + return false; + } + + if dkg_epoch_id != response.body.dkg_ecash_epoch_id { + debug!( + "mismatched dkg epoch id. current: {dkg_epoch_id}, signer's: {}", + response.body.dkg_ecash_epoch_id + ); + return false; + } + + true + } + } } } diff --git a/common/ecash-signer-check/src/status.rs b/common/ecash-signer-check/src/status.rs index 438024df5a5..a75a86448c7 100644 --- a/common/ecash-signer-check/src/status.rs +++ b/common/ecash-signer-check/src/status.rs @@ -4,6 +4,9 @@ use crate::chain_status::LocalChainStatus; use crate::dealer_information::RawDealerInformation; use crate::signing_status::SigningStatus; +use std::time::Duration; + +pub(crate) const STALE_RESPONSE_THRESHOLD: Duration = Duration::from_secs(5 * 60); #[derive(Debug)] pub struct SignerResult { @@ -14,10 +17,14 @@ pub struct SignerResult { impl SignerResult { pub fn chain_available(&self) -> bool { + let Ok(parsed_info) = self.information.parse() else { + return false; + }; + let SignerStatus::Tested { result } = &self.status else { return false; }; - result.local_chain_status.available() + result.local_chain_status.available(parsed_info.public_key) } pub fn signer_available(&self) -> bool { @@ -28,9 +35,12 @@ impl SignerResult { return false; }; - result - .signing_status - .available(parsed_info.public_key, self.dkg_epoch_id) + result.signing_status.available( + parsed_info.public_key, + self.dkg_epoch_id, + parsed_info.verification_key_share, + parsed_info.share_verified, + ) } } diff --git a/nym-api/nym-api-requests/src/ecash/models.rs b/nym-api/nym-api-requests/src/ecash/models.rs index db4f85d53f7..a69d0c1d330 100644 --- a/nym-api/nym-api-requests/src/ecash/models.rs +++ b/nym-api/nym-api-requests/src/ecash/models.rs @@ -1,6 +1,7 @@ // Copyright 2023-2024 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 +use crate::signable::SignedMessage; use cosmrs::AccountId; use nym_coconut_dkg_common::types::EpochId; use nym_compact_ecash::scheme::coin_indices_signatures::AnnotatedCoinIndexSignature; @@ -13,14 +14,13 @@ use nym_credentials_interface::{ VerificationKeyAuth, WithdrawalRequest, }; use nym_crypto::asymmetric::ed25519; -use nym_crypto::asymmetric::ed25519::serde_helpers::bs58_ed25519_signature; use nym_ticketbooks_merkle::{IssuedTicketbook, IssuedTicketbooksFullMerkleProof}; use serde::{Deserialize, Serialize}; use sha2::Digest; use std::collections::BTreeMap; use std::ops::Deref; use thiserror::Error; -use time::Date; +use time::{Date, OffsetDateTime}; use utoipa::ToSchema; #[derive(Serialize, Deserialize, Clone, ToSchema)] @@ -542,71 +542,6 @@ pub struct CommitedDeposit { pub merkle_index: usize, } -// -// - -// make sure only our types can implement this trait (to ensure infallible serialisation) -mod private { - use crate::ecash::models::*; - - pub trait Sealed {} - - // requests - impl Sealed for IssuedTicketbooksChallengeCommitmentRequestBody {} - impl Sealed for IssuedTicketbooksDataRequestBody {} - - // responses - impl Sealed for IssuedTicketbooksChallengeCommitmentResponseBody {} - impl Sealed for IssuedTicketbooksForResponseBody {} - impl Sealed for IssuedTicketbooksDataResponseBody {} - impl Sealed for EcashSignerStatusResponseBody {} -} - -// the trait is not public as it's only defined on types that are guaranteed to not panic when serialised -pub trait SignableMessageBody: Serialize + private::Sealed { - fn sign(self, key: &ed25519::PrivateKey) -> SignedMessage - where - Self: Sized, - { - let signature = key.sign(self.plaintext()); - SignedMessage { - body: self, - signature, - } - } - - fn plaintext(&self) -> Vec { - #[allow(clippy::unwrap_used)] - // SAFETY: all types that implement this trait have valid serialisations - serde_json::to_vec(&self).unwrap() - } -} - -impl SignableMessageBody for T where T: Serialize + private::Sealed {} - -#[derive(Clone, Serialize, Deserialize, Debug, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct SignedMessage { - pub body: T, - #[schema(value_type = String)] - #[serde(with = "bs58_ed25519_signature")] - pub signature: ed25519::Signature, -} - -impl SignedMessage { - pub fn verify_signature(&self, pub_key: &ed25519::PublicKey) -> bool - where - T: SignableMessageBody, - { - let plaintext = self.body.plaintext(); - if plaintext.is_empty() { - return false; - } - - pub_key.verify(&plaintext, &self.signature).is_ok() - } -} - pub type IssuedTicketbooksDataRequest = SignedMessage; pub type IssuedTicketbooksChallengeCommitmentRequest = SignedMessage; @@ -830,6 +765,10 @@ pub type EcashSignerStatusResponse = SignedMessage bool { + matches!(self, ChainStatus::Synced) + } +} + impl ApiHealthResponse { pub fn new_healthy(uptime: Duration) -> Self { ApiHealthResponse { @@ -1669,6 +1677,21 @@ impl From for RewardedSetResponse } } +pub type ChainBlocksStatusResponse = SignedMessage; + +#[derive(Serialize, Deserialize, ToSchema, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct ChainBlocksStatusResponseBody { + #[serde(with = "time::serde::rfc3339")] + #[schema(value_type = String)] + pub current_time: OffsetDateTime, + + pub latest_cached_block: Option, + + // explicit indication of THIS signer whether it thinks the chain is stalled + pub chain_status: ChainStatus, +} + #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, ToSchema)] pub struct ChainStatusResponse { pub connected_nyxd: String, @@ -1681,6 +1704,20 @@ pub struct DetailedChainStatus { pub latest_block: BlockInfo, } +impl DetailedChainStatus { + pub fn stall_status(&self, now: OffsetDateTime, threshold: Duration) -> ChainStatus { + let block_time: OffsetDateTime = self.latest_block.block.header.time.into(); + let diff = now - block_time; + if diff > threshold { + ChainStatus::Stalled { + approximate_amount: diff.unsigned_abs(), + } + } else { + ChainStatus::Synced + } + } +} + #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, ToSchema)] pub struct BlockInfo { pub block_id: BlockId, @@ -1957,7 +1994,6 @@ pub mod tendermint_types { } } -use crate::models::tendermint_types::{BlockHeader, BlockId}; pub use config_score::*; pub mod config_score { diff --git a/nym-api/nym-api-requests/src/signable.rs b/nym-api/nym-api-requests/src/signable.rs new file mode 100644 index 00000000000..6ae2caf7d30 --- /dev/null +++ b/nym-api/nym-api-requests/src/signable.rs @@ -0,0 +1,71 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use nym_crypto::asymmetric::ed25519; +use nym_crypto::asymmetric::ed25519::serde_helpers::bs58_ed25519_signature; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +// the trait is not public as it's only defined on types that are guaranteed to not panic when serialised +pub trait SignableMessageBody: Serialize + sealed::Sealed { + fn sign(self, key: &ed25519::PrivateKey) -> SignedMessage + where + Self: Sized, + { + let signature = key.sign(self.plaintext()); + SignedMessage { + body: self, + signature, + } + } + + fn plaintext(&self) -> Vec { + #[allow(clippy::unwrap_used)] + // SAFETY: all types that implement this trait have valid serialisations + serde_json::to_vec(&self).unwrap() + } +} + +impl SignableMessageBody for T where T: Serialize + sealed::Sealed {} + +#[derive(Clone, Serialize, Deserialize, Debug, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct SignedMessage { + pub body: T, + #[schema(value_type = String)] + #[serde(with = "bs58_ed25519_signature")] + pub signature: ed25519::Signature, +} + +impl SignedMessage { + pub fn verify_signature(&self, pub_key: &ed25519::PublicKey) -> bool + where + T: SignableMessageBody, + { + let plaintext = self.body.plaintext(); + if plaintext.is_empty() { + return false; + } + + pub_key.verify(&plaintext, &self.signature).is_ok() + } +} + +// make sure only our types can implement this trait (to ensure infallible serialisation) +pub(crate) mod sealed { + use crate::ecash::models::*; + use crate::models::ChainBlocksStatusResponseBody; + + pub trait Sealed {} + + // requests + impl Sealed for IssuedTicketbooksChallengeCommitmentRequestBody {} + impl Sealed for IssuedTicketbooksDataRequestBody {} + + // responses + impl Sealed for IssuedTicketbooksChallengeCommitmentResponseBody {} + impl Sealed for IssuedTicketbooksForResponseBody {} + impl Sealed for IssuedTicketbooksDataResponseBody {} + impl Sealed for EcashSignerStatusResponseBody {} + impl Sealed for ChainBlocksStatusResponseBody {} +} diff --git a/nym-api/src/ecash/api_routes/issued.rs b/nym-api/src/ecash/api_routes/issued.rs index fd63ccdfe18..914b087b978 100644 --- a/nym-api/src/ecash/api_routes/issued.rs +++ b/nym-api/src/ecash/api_routes/issued.rs @@ -11,8 +11,9 @@ use nym_api_requests::ecash::models::{ IssuedTicketbooksChallengeCommitmentRequest, IssuedTicketbooksChallengeCommitmentResponse, IssuedTicketbooksCountResponse, IssuedTicketbooksDataRequest, IssuedTicketbooksDataResponse, IssuedTicketbooksForCountResponse, IssuedTicketbooksForResponse, - IssuedTicketbooksOnCountResponse, SignableMessageBody, + IssuedTicketbooksOnCountResponse, }; +use nym_api_requests::signable::SignableMessageBody; use nym_http_api_common::{FormattedResponse, OutputParams}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; diff --git a/nym-api/src/ecash/api_routes/signer_status.rs b/nym-api/src/ecash/api_routes/signer_status.rs index 1ec86380001..739482cd352 100644 --- a/nym-api/src/ecash/api_routes/signer_status.rs +++ b/nym-api/src/ecash/api_routes/signer_status.rs @@ -4,11 +4,11 @@ use crate::ecash::state::EcashState; use crate::node_status_api::models::ApiResult; use axum::extract::{Query, State}; -use nym_api_requests::ecash::models::{ - EcashSignerStatusResponse, EcashSignerStatusResponseBody, SignableMessageBody, -}; +use nym_api_requests::ecash::models::{EcashSignerStatusResponse, EcashSignerStatusResponseBody}; +use nym_api_requests::signable::SignableMessageBody; use nym_http_api_common::{FormattedResponse, OutputParams}; use std::sync::Arc; +use time::OffsetDateTime; #[utoipa::path( tag = "Ecash", @@ -21,7 +21,6 @@ use std::sync::Arc; (EcashSignerStatusResponse = "application/yaml"), (EcashSignerStatusResponse = "application/bincode") )), - (status = 400, body = String, description = "this nym-api is not an ecash signer in the current epoch"), ), params(OutputParams) )] @@ -35,6 +34,7 @@ pub(crate) async fn signer_status( Ok(output.to_response( EcashSignerStatusResponseBody { + current_time: OffsetDateTime::now_utc(), dkg_ecash_epoch_id, signer_disabled: state.local.explicitly_disabled, is_ecash_signer: state.is_dkg_signer(dkg_ecash_epoch_id).await?, diff --git a/nym-api/src/network/handlers.rs b/nym-api/src/network/handlers.rs index 4d6c8e01f7b..d601ce811f7 100644 --- a/nym-api/src/network/handlers.rs +++ b/nym-api/src/network/handlers.rs @@ -3,13 +3,18 @@ use crate::network::models::{ContractInformation, NetworkDetails}; use crate::node_status_api::models::AxumResult; +use crate::support::config::CHAIN_STALL_THRESHOLD; use crate::support::http::state::AppState; use axum::extract::{Query, State}; use axum::Router; -use nym_api_requests::models::ChainStatusResponse; +use nym_api_requests::models::{ + ChainBlocksStatusResponse, ChainBlocksStatusResponseBody, ChainStatus, ChainStatusResponse, +}; +use nym_api_requests::signable::SignableMessageBody; use nym_contracts_common::ContractBuildInformation; use nym_http_api_common::{FormattedResponse, OutputParams}; use std::collections::HashMap; +use time::OffsetDateTime; use tower_http::compression::CompressionLayer; use utoipa::ToSchema; @@ -17,6 +22,10 @@ pub(crate) fn nym_network_routes() -> Router { Router::new() .route("/details", axum::routing::get(network_details)) .route("/chain-status", axum::routing::get(chain_status)) + .route( + "/chain-blocks-status", + axum::routing::get(chain_blocks_status), + ) .route("/nym-contracts", axum::routing::get(nym_contracts)) .route( "/nym-contracts-detailed", @@ -28,7 +37,8 @@ pub(crate) fn nym_network_routes() -> Router { #[utoipa::path( tag = "network", get, - path = "/v1/network/details", + context_path = "/v1/network", + path = "/details", responses( (status = 200, content( (NetworkDetails = "application/json"), @@ -50,7 +60,8 @@ async fn network_details( #[utoipa::path( tag = "network", get, - path = "/v1/network/chain-status", + context_path = "/v1/network", + path = "/chain-status", responses( (status = 200, content( (ChainStatusResponse = "application/json"), @@ -79,6 +90,47 @@ async fn chain_status( })) } +#[utoipa::path( + tag = "network", + get, + context_path = "/v1/network", + path = "/chain-blocks-status", + responses( + (status = 200, content( + (ChainBlocksStatusResponse = "application/json"), + (ChainBlocksStatusResponse = "application/yaml"), + (ChainBlocksStatusResponse = "application/bincode") + )) + ), + params(OutputParams) +)] +async fn chain_blocks_status( + Query(params): Query, + State(state): State, +) -> FormattedResponse { + let output = params.get_output(); + + let current_time = OffsetDateTime::now_utc(); + let latest_cached_block = state + .chain_status_cache + .get_or_refresh(&state.nyxd_client) + .await + .ok(); + let chain_status = latest_cached_block + .as_ref() + .map(|detailed| detailed.stall_status(current_time, CHAIN_STALL_THRESHOLD)) + .unwrap_or(ChainStatus::Unknown); + + output.to_response( + ChainBlocksStatusResponseBody { + current_time, + latest_cached_block, + chain_status, + } + .sign(state.private_signing_key()), + ) +} + // it's used for schema generation so dead_code is fine #[allow(dead_code)] #[derive(ToSchema)] @@ -90,7 +142,7 @@ pub(crate) struct ContractVersionSchemaResponse { /// version is any string that this implementation knows. It may be simple counter "1", "2". /// or semantic version on release tags "v0.7.0", or some custom feature flag list. /// the only code that needs to understand the version parsing is code that knows how to - /// migrate from the given contract (and is tied to it's implementation somehow) + /// migrate from the given contract (and is tied to its implementation somehow) pub version: String, } @@ -104,7 +156,8 @@ pub struct ContractInformationContractVersion { #[utoipa::path( tag = "network", get, - path = "/v1/network/nym-contracts", + context_path = "/v1/network", + path = "/nym-contracts", responses( (status = 200, content( (HashMap = "application/json"), @@ -151,7 +204,8 @@ pub struct ContractInformationBuildInformation { #[utoipa::path( tag = "network", get, - path = "/v1/network/nym-contracts-detailed", + context_path = "/v1/network", + path = "/nym-contracts-detailed", responses( (status = 200, content( (HashMap = "application/json"), diff --git a/nym-api/src/status/handlers.rs b/nym-api/src/status/handlers.rs index 6745a7934ef..aa1d5101b2f 100644 --- a/nym-api/src/status/handlers.rs +++ b/nym-api/src/status/handlers.rs @@ -3,6 +3,7 @@ use crate::node_status_api::models::{AxumErrorResponse, AxumResult}; use crate::status::ApiStatusState; +use crate::support::config::CHAIN_STALL_THRESHOLD; use crate::support::http::state::AppState; use axum::extract::{Query, State}; use axum::Router; @@ -12,7 +13,6 @@ use nym_api_requests::models::{ use nym_bin_common::build_information::BinaryBuildInformationOwned; use nym_compact_ecash::Base58; use nym_http_api_common::{FormattedResponse, OutputParams}; -use std::time::Duration; use time::OffsetDateTime; pub(crate) fn api_status_routes() -> Router { @@ -42,8 +42,6 @@ async fn health( Query(output): Query, State(state): State, ) -> FormattedResponse { - const CHAIN_STALL_THRESHOLD: Duration = Duration::from_secs(5 * 60); - let output = output.output.unwrap_or_default(); let uptime = state.api_status.startup_time.elapsed(); @@ -54,15 +52,7 @@ async fn health( { Ok(res) => { let now = OffsetDateTime::now_utc(); - let block_time: OffsetDateTime = res.latest_block.block.header.time.into(); - let diff = now - block_time; - if diff > CHAIN_STALL_THRESHOLD { - ChainStatus::Stalled { - approximate_amount: diff.unsigned_abs(), - } - } else { - ChainStatus::Synced - } + res.stall_status(now, CHAIN_STALL_THRESHOLD) } Err(_) => ChainStatus::Unknown, }; diff --git a/nym-api/src/support/config/mod.rs b/nym-api/src/support/config/mod.rs index 8be4ee0bc04..77adbc273aa 100644 --- a/nym-api/src/support/config/mod.rs +++ b/nym-api/src/support/config/mod.rs @@ -63,7 +63,8 @@ pub(crate) const DEFAULT_NODE_DESCRIBE_CACHE_INTERVAL: Duration = Duration::from pub(crate) const DEFAULT_NODE_DESCRIBE_BATCH_SIZE: usize = 50; // TODO: make it configurable -pub(crate) const DEFAULT_CHAIN_STATUS_CACHE_TTL: Duration = Duration::from_secs(60); +pub(crate) const DEFAULT_CHAIN_STATUS_CACHE_TTL: Duration = Duration::from_secs(30); +pub(crate) const CHAIN_STALL_THRESHOLD: Duration = Duration::from_secs(5 * 60); // contract info is changed very infrequently (essentially once per release cycle) // so this default is more than enough diff --git a/nym-api/src/support/http/state/mod.rs b/nym-api/src/support/http/state/mod.rs index ec0d2ca9bb4..ac501b96eca 100644 --- a/nym-api/src/support/http/state/mod.rs +++ b/nym-api/src/support/http/state/mod.rs @@ -20,6 +20,7 @@ use crate::unstable_routes::v1::account::cache::AddressInfoCache; use crate::unstable_routes::v1::account::models::NyxAccountDetails; use axum::extract::FromRef; use nym_api_requests::models::{GatewayBondAnnotated, MixNodeBondAnnotated, NodeAnnotation}; +use nym_crypto::asymmetric::ed25519; use nym_mixnet_contract_common::NodeId; use nym_topology::CachedEpochRewardedSet; use std::collections::HashMap; @@ -101,6 +102,12 @@ impl FromRef for MixnetContractCache { } impl AppState { + pub(crate) fn private_signing_key(&self) -> &ed25519::PrivateKey { + // even though we have to go through ecash state, the key is always available + // (moving it would involve some refactoring that's not worth it now) + self.ecash_state.local.identity_keypair.private_key() + } + pub(crate) fn nym_contract_cache(&self) -> &MixnetContractCache { &self.mixnet_contract_cache } diff --git a/nym-validator-rewarder/src/rewarder/ticketbook_issuance/verifier.rs b/nym-validator-rewarder/src/rewarder/ticketbook_issuance/verifier.rs index fd04e18de0d..cb0964caf30 100644 --- a/nym-validator-rewarder/src/rewarder/ticketbook_issuance/verifier.rs +++ b/nym-validator-rewarder/src/rewarder/ticketbook_issuance/verifier.rs @@ -15,9 +15,9 @@ use nym_validator_client::ecash::models::{ CommitedDeposit, DepositId, IssuedTicketbooksChallengeCommitmentRequestBody, IssuedTicketbooksChallengeCommitmentResponse, IssuedTicketbooksDataRequestBody, IssuedTicketbooksDataResponse, IssuedTicketbooksDataResponseBody, IssuedTicketbooksForResponse, - SignableMessageBody, SignedMessage, }; use nym_validator_client::nyxd::AccountId; +use nym_validator_client::signable::{SignableMessageBody, SignedMessage}; use rand::distributions::{Distribution, WeightedIndex}; use rand::prelude::SliceRandom; use rand::thread_rng; From bcf8e749b1167940660fcbfe11c7429c0045eda7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C4=99drzej=20Stuczy=C5=84ski?= Date: Wed, 9 Jul 2025 15:32:06 +0100 Subject: [PATCH 04/10] wip: making nym api monitor network signers --- Cargo.lock | 10 +- common/ecash-signer-check/src/lib.rs | 7 + nym-api/Cargo.toml | 25 +- nym-api/src/main.rs | 5 +- nym-api/src/signers_cache/cache/data.rs | 2 + nym-api/src/signers_cache/cache/mod.rs | 11 + nym-api/src/signers_cache/cache/refresher.rs | 33 ++ nym-api/src/signers_cache/handlers.rs | 2 + nym-api/src/signers_cache/mod.rs | 13 + nym-api/src/support/cli/run.rs | 317 ++++++++++--------- nym-api/src/support/http/state/mod.rs | 5 + 11 files changed, 239 insertions(+), 191 deletions(-) create mode 100644 nym-api/src/signers_cache/cache/data.rs create mode 100644 nym-api/src/signers_cache/cache/mod.rs create mode 100644 nym-api/src/signers_cache/cache/refresher.rs create mode 100644 nym-api/src/signers_cache/handlers.rs create mode 100644 nym-api/src/signers_cache/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 4687f2cea53..8136025b76d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4807,13 +4807,11 @@ dependencies = [ "anyhow", "async-trait", "axum 0.7.9", - "axum-extra", "axum-test", "bincode", "bip39", "bs58", "cfg-if", - "chrono", "clap", "console-subscriber", "cosmwasm-std", @@ -4822,13 +4820,9 @@ dependencies = [ "cw3", "cw4", "dashmap", - "dirs", "dotenv", "futures", - "getset", "humantime-serde", - "itertools 0.14.0", - "k256", "moka", "nym-api-requests 0.1.0", "nym-bandwidth-controller", @@ -4843,12 +4837,11 @@ dependencies = [ "nym-crypto 0.4.0", "nym-dkg", "nym-ecash-contract-common 0.1.0", + "nym-ecash-signer-check", "nym-ecash-time 0.1.0", "nym-gateway-client", "nym-http-api-common 0.1.0", - "nym-inclusion-probability", "nym-mixnet-contract-common 0.6.0", - "nym-multisig-contract-common 0.1.0", "nym-node-requests 0.1.0", "nym-node-tester-utils", "nym-pemstore 0.3.0", @@ -4860,7 +4853,6 @@ dependencies = [ "nym-topology 0.1.0", "nym-types", "nym-validator-client 0.1.0", - "nym-vesting-contract-common 0.7.0", "pin-project", "rand 0.8.5", "rand_chacha 0.3.1", diff --git a/common/ecash-signer-check/src/lib.rs b/common/ecash-signer-check/src/lib.rs index bcd3b34399a..6b5e509b98e 100644 --- a/common/ecash-signer-check/src/lib.rs +++ b/common/ecash-signer-check/src/lib.rs @@ -31,6 +31,13 @@ pub async fn check_signers( ) .map_err(SignerCheckError::invalid_nyxd_connection_details)?; + check_signers_with_client(&client).await +} + +pub async fn check_signers_with_client(client: &C) -> Result, SignerCheckError> +where + C: DkgQueryClient + Sync, +{ // 2. retrieve current dkg epoch let dkg_epoch = client .get_current_epoch() diff --git a/nym-api/Cargo.toml b/nym-api/Cargo.toml index 69c9a714ac6..08378bf2971 100644 --- a/nym-api/Cargo.toml +++ b/nym-api/Cargo.toml @@ -16,17 +16,12 @@ async-trait = { workspace = true } bs58 = { workspace = true } bip39 = { workspace = true } bincode.workspace = true +console-subscriber = { workspace = true, optional = true } # nym-api needs to be built with RUSTFLAGS="--cfg tokio_unstable" cfg-if = { workspace = true } clap = { workspace = true, features = ["cargo", "derive", "env"] } -console-subscriber = { workspace = true, optional = true } # validator-api needs to be built with RUSTFLAGS="--cfg tokio_unstable" dashmap = { workspace = true } -dirs = { workspace = true } futures = { workspace = true } -itertools = { workspace = true } humantime-serde = { workspace = true } -k256 = { workspace = true, features = [ - "ecdsa-core", -] } # needed for the Verifier trait; pull whatever version is used by other dependencies moka = { workspace = true } pin-project = { workspace = true } rand = { workspace = true } @@ -51,7 +46,6 @@ tendermint = { workspace = true } ts-rs = { workspace = true, optional = true } anyhow = { workspace = true } -getset = { workspace = true } sqlx = { workspace = true, features = [ "runtime-tokio-rustls", @@ -66,30 +60,19 @@ zeroize = { workspace = true } # for axum server axum = { workspace = true, features = ["tokio"] } -axum-extra = { workspace = true, features = ["typed-header"] } tower-http = { workspace = true, features = ["cors", "trace", "compression-br", "compression-deflate", "compression-gzip", "compression-zstd"] } utoipa = { workspace = true, features = ["axum_extras", "time"] } utoipauto = { workspace = true } utoipa-swagger-ui = { workspace = true, features = ["axum"] } tracing = { workspace = true } -## ephemera-specific -#actix-web = "4" -#array-bytes = "6.0.0" -#chrono = { version = "0.4.24", default-features = false, features = ["clock"] } -#futures-util = "0.3.25" -#serde_derive = "1.0.149" -#uuid = { version = "1.3.0", features = ["serde", "v4"] } - ## internal -#ephemera = { path = "../ephemera" } nym-bandwidth-controller = { path = "../common/bandwidth-controller" } nym-ecash-contract-common = { path = "../common/cosmwasm-smart-contracts/ecash-contract" } nym-ecash-time = { path = "../common/ecash-time", features = ["expiration"] } nym-coconut-dkg-common = { path = "../common/cosmwasm-smart-contracts/coconut-dkg" } nym-compact-ecash = { path = "../common/nym_offline_compact_ecash" } nym-credentials-interface = { path = "../common/credentials-interface" } -#nym-ephemera-common = { path = "../common/cosmwasm-smart-contracts/ephemera" } nym-config = { path = "../common/config" } cosmwasm-std = { workspace = true } nym-credential-storage = { path = "../common/credential-storage", features = [ @@ -102,11 +85,8 @@ cw3 = { workspace = true } cw4 = { workspace = true } nym-dkg = { path = "../common/dkg", features = ["cw-types"] } nym-gateway-client = { path = "../common/client-libs/gateway-client" } -nym-inclusion-probability = { path = "../common/inclusion-probability" } nym-mixnet-contract-common = { path = "../common/cosmwasm-smart-contracts/mixnet-contract", features = ["utoipa"] } -nym-vesting-contract-common = { path = "../common/cosmwasm-smart-contracts/vesting-contract" } nym-contracts-common = { path = "../common/cosmwasm-smart-contracts/contracts-common", features = ["naive_float", "utoipa"] } -nym-multisig-contract-common = { path = "../common/cosmwasm-smart-contracts/multisig-contract" } nym-sphinx = { path = "../common/nymsphinx" } nym-pemstore = { path = "../common/pemstore" } nym-task = { path = "../common/task" } @@ -121,7 +101,8 @@ nym-http-api-common = { path = "../common/http-api-common", features = ["utoipa" nym-serde-helpers = { path = "../common/serde-helpers", features = ["date"] } nym-ticketbooks-merkle = { path = "../common/ticketbooks-merkle" } nym-statistics-common = { path = "../common/statistics" } -chrono.workspace = true +nym-ecash-signer-check = { path = "../common/ecash-signer-check" } + [features] no-reward = [] diff --git a/nym-api/src/main.rs b/nym-api/src/main.rs index c07a6c2e8f1..a4ce80521b4 100644 --- a/nym-api/src/main.rs +++ b/nym-api/src/main.rs @@ -23,6 +23,7 @@ pub(crate) mod node_describe_cache; mod node_performance; pub(crate) mod node_status_api; pub(crate) mod nym_nodes; +mod signers_cache; mod status; pub(crate) mod support; mod unstable_routes; @@ -32,10 +33,10 @@ async fn main() -> Result<(), anyhow::Error> { cfg_if::cfg_if! {if #[cfg(feature = "console-subscriber")] { // instrument tokio console subscriber needs RUSTFLAGS="--cfg tokio_unstable" at build time console_subscriber::init(); + } else { + setup_tracing_logger(); }} - setup_tracing_logger(); - info!("Starting nym api..."); let args = cli::Cli::parse(); diff --git a/nym-api/src/signers_cache/cache/data.rs b/nym-api/src/signers_cache/cache/data.rs new file mode 100644 index 00000000000..5111033b0b1 --- /dev/null +++ b/nym-api/src/signers_cache/cache/data.rs @@ -0,0 +1,2 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only diff --git a/nym-api/src/signers_cache/cache/mod.rs b/nym-api/src/signers_cache/cache/mod.rs new file mode 100644 index 00000000000..2e3db25f229 --- /dev/null +++ b/nym-api/src/signers_cache/cache/mod.rs @@ -0,0 +1,11 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use nym_ecash_signer_check::SignerResult; + +pub(crate) mod data; +pub(crate) mod refresher; + +pub(crate) struct SignersCacheData { + pub(crate) signer_results: Vec, +} diff --git a/nym-api/src/signers_cache/cache/refresher.rs b/nym-api/src/signers_cache/cache/refresher.rs new file mode 100644 index 00000000000..41aa63144cc --- /dev/null +++ b/nym-api/src/signers_cache/cache/refresher.rs @@ -0,0 +1,33 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use crate::signers_cache::cache::SignersCacheData; +use crate::support::caching::refresher::CacheItemProvider; +use crate::support::nyxd::Client; +use async_trait::async_trait; +use nym_ecash_signer_check::{check_signers_with_client, SignerCheckError}; + +pub(crate) struct SignersCacheDataProvider { + nyxd_client: Client, +} + +#[async_trait] +impl CacheItemProvider for SignersCacheDataProvider { + type Item = SignersCacheData; + type Error = SignerCheckError; + + async fn try_refresh(&mut self) -> Result, Self::Error> { + self.refresh().await.map(Some) + } +} + +impl SignersCacheDataProvider { + pub(crate) fn new(nyxd_client: Client) -> Self { + SignersCacheDataProvider { nyxd_client } + } + + async fn refresh(&self) -> Result { + let signer_results = check_signers_with_client(&self.nyxd_client).await?; + Ok(SignersCacheData { signer_results }) + } +} diff --git a/nym-api/src/signers_cache/handlers.rs b/nym-api/src/signers_cache/handlers.rs new file mode 100644 index 00000000000..5111033b0b1 --- /dev/null +++ b/nym-api/src/signers_cache/handlers.rs @@ -0,0 +1,2 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only diff --git a/nym-api/src/signers_cache/mod.rs b/nym-api/src/signers_cache/mod.rs new file mode 100644 index 00000000000..034ce4828e2 --- /dev/null +++ b/nym-api/src/signers_cache/mod.rs @@ -0,0 +1,13 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use crate::signers_cache::cache::SignersCacheData; +use crate::support::caching::refresher::CacheRefresher; +use nym_validator_client::nyxd::error::NyxdError; + +pub(crate) mod cache; +pub(crate) mod handlers; + +pub(crate) fn build_refresher() -> CacheRefresher { + todo!() +} diff --git a/nym-api/src/support/cli/run.rs b/nym-api/src/support/cli/run.rs index bed8c6efaf3..8a0e6e691ca 100644 --- a/nym-api/src/support/cli/run.rs +++ b/nym-api/src/support/cli/run.rs @@ -202,164 +202,165 @@ async fn start_nym_api_tasks(config: &Config) -> anyhow::Result None }; - ecash_state.spawn_background_cleaner(); - let router = router.with_state(AppState { - nyxd_client: nyxd_client.clone(), - chain_status_cache: ChainStatusCache::new(DEFAULT_CHAIN_STATUS_CACHE_TTL), - address_info_cache: AddressInfoCache::new( - config.address_cache.time_to_live, - config.address_cache.capacity, - ), - forced_refresh: ForcedRefresh::new(config.describe_cache.debug.allow_illegal_ips), - mixnet_contract_cache: mixnet_contract_cache_state.clone(), - node_status_cache: node_status_cache_state.clone(), - storage: storage.clone(), - described_nodes_cache: described_nodes_cache.clone(), - network_details: network_details.clone(), - node_info_cache, - contract_info_cache: ContractDetailsCache::new(config.contracts_info_cache.time_to_live), - api_status: ApiStatusState::new(signer_information), - ecash_state: Arc::new(ecash_state), - }); - - // start note describe cache refresher - // we should be doing the below, but can't due to our current startup structure - // let refresher = node_describe_cache::new_refresher(&config.topology_cacher); - // let cache = refresher.get_shared_cache(); - let describe_cache_refresher = node_describe_cache::provider::new_provider_with_initial_value( - &config.describe_cache, - mixnet_contract_cache_state.clone(), - described_nodes_cache.clone(), - ) - .named("node-self-described-data-refresher"); - - let describe_cache_refresh_requester = describe_cache_refresher.refresh_requester(); - - let describe_cache_watcher = describe_cache_refresher - .start_with_watcher(task_manager.subscribe_named("node-self-described-data-refresher")); - - let performance_provider = if config.performance_provider.use_performance_contract_data { - if network_details - .network - .contracts - .performance_contract_address - .is_none() - { - bail!("can't use performance contract data without setting the address of the contract") - } - - let performance_contract_cache = node_performance::contract_cache::start_cache_refresher( - &config.performance_provider, - nyxd_client.clone(), - mixnet_contract_cache_state.clone(), - &task_manager, - ) - .await?; - let provider = ContractPerformanceProvider::new( - &config.performance_provider, - performance_contract_cache, - ); - Box::new(provider) as Box - } else { - Box::new(LegacyStoragePerformanceProvider::new( - storage.clone(), - mixnet_contract_cache_state.clone(), - )) - }; - - // start all the caches first - let mixnet_contract_cache_refresher = mixnet_contract_cache::build_refresher( - &config.mixnet_contract_cache, - &mixnet_contract_cache_state.clone(), - nyxd_client.clone(), - ); - let contract_cache_watcher = - mixnet_contract_cache_refresher.start_with_watcher(task_manager.subscribe()); - - node_status_api::start_cache_refresh( - &config.node_status_api, - &mixnet_contract_cache_state, - &described_nodes_cache, - &node_status_cache_state, - performance_provider, - contract_cache_watcher.clone(), - describe_cache_watcher, - &task_manager, - ); - - // start dkg task - if config.ecash_signer.enabled { - let dkg_bte_keypair = load_bte_keypair(&config.ecash_signer)?; - - DkgController::start( - &config.ecash_signer, - nyxd_client.clone(), - ecash_keypair_wrapper, - dkg_bte_keypair, - identity_public_key, - rand::rngs::OsRng, - &task_manager, - )?; - } - - let has_performance_data = - config.network_monitor.enabled || config.performance_provider.use_performance_contract_data; - - // and then only start the uptime updater (and the monitor itself, duh) - // if the monitoring is enabled - if config.network_monitor.enabled { - network_monitor::start::( - config, - &mixnet_contract_cache_state, - described_nodes_cache.clone(), - node_status_cache_state.clone(), - &storage, - nyxd_client.clone(), - &task_manager, - ) - .await; - - HistoricalUptimeUpdater::start(storage.to_owned(), &task_manager); - } - - // start 'rewarding' if its enabled and there exists source for performance data - if config.rewarding.enabled && has_performance_data { - epoch_operations::ensure_rewarding_permission(&nyxd_client).await?; - EpochAdvancer::start( - nyxd_client, - &mixnet_contract_cache_state, - &node_status_cache_state, - described_nodes_cache.clone(), - &storage, - &task_manager, - ); - } - - // finally start a background task watching the contract changes and requesting - // self-described cache refresh upon being close to key rotation rollover - KeyRotationController::new( - describe_cache_refresh_requester, - contract_cache_watcher, - mixnet_contract_cache_state, - ) - .start(task_manager.subscribe_named("KeyRotationController")); - - let bind_address = config.base.bind_address.to_owned(); - let server = router.build_server(&bind_address).await?; - - let cancellation_token = CancellationToken::new(); - let shutdown_button = cancellation_token.clone(); - let axum_shutdown_receiver = cancellation_token.cancelled_owned(); - let server_handle = tokio::spawn(async move { - { - info!("Started Axum HTTP V2 server on {bind_address}"); - server.run(axum_shutdown_receiver).await - } - }); - - let shutdown = ShutdownHandles::new(task_manager, server_handle, shutdown_button); - - Ok(shutdown) + todo!() + // ecash_state.spawn_background_cleaner(); + // let router = router.with_state(AppState { + // nyxd_client: nyxd_client.clone(), + // chain_status_cache: ChainStatusCache::new(DEFAULT_CHAIN_STATUS_CACHE_TTL), + // address_info_cache: AddressInfoCache::new( + // config.address_cache.time_to_live, + // config.address_cache.capacity, + // ), + // forced_refresh: ForcedRefresh::new(config.describe_cache.debug.allow_illegal_ips), + // mixnet_contract_cache: mixnet_contract_cache_state.clone(), + // node_status_cache: node_status_cache_state.clone(), + // storage: storage.clone(), + // described_nodes_cache: described_nodes_cache.clone(), + // network_details: network_details.clone(), + // node_info_cache, + // contract_info_cache: ContractDetailsCache::new(config.contracts_info_cache.time_to_live), + // api_status: ApiStatusState::new(signer_information), + // ecash_state: Arc::new(ecash_state), + // }); + // + // // start note describe cache refresher + // // we should be doing the below, but can't due to our current startup structure + // // let refresher = node_describe_cache::new_refresher(&config.topology_cacher); + // // let cache = refresher.get_shared_cache(); + // let describe_cache_refresher = node_describe_cache::provider::new_provider_with_initial_value( + // &config.describe_cache, + // mixnet_contract_cache_state.clone(), + // described_nodes_cache.clone(), + // ) + // .named("node-self-described-data-refresher"); + // + // let describe_cache_refresh_requester = describe_cache_refresher.refresh_requester(); + // + // let describe_cache_watcher = describe_cache_refresher + // .start_with_watcher(task_manager.subscribe_named("node-self-described-data-refresher")); + // + // let performance_provider = if config.performance_provider.use_performance_contract_data { + // if network_details + // .network + // .contracts + // .performance_contract_address + // .is_none() + // { + // bail!("can't use performance contract data without setting the address of the contract") + // } + // + // let performance_contract_cache = node_performance::contract_cache::start_cache_refresher( + // &config.performance_provider, + // nyxd_client.clone(), + // mixnet_contract_cache_state.clone(), + // &task_manager, + // ) + // .await?; + // let provider = ContractPerformanceProvider::new( + // &config.performance_provider, + // performance_contract_cache, + // ); + // Box::new(provider) as Box + // } else { + // Box::new(LegacyStoragePerformanceProvider::new( + // storage.clone(), + // mixnet_contract_cache_state.clone(), + // )) + // }; + // + // // start all the caches first + // let mixnet_contract_cache_refresher = mixnet_contract_cache::build_refresher( + // &config.mixnet_contract_cache, + // &mixnet_contract_cache_state.clone(), + // nyxd_client.clone(), + // ); + // let contract_cache_watcher = + // mixnet_contract_cache_refresher.start_with_watcher(task_manager.subscribe()); + // + // node_status_api::start_cache_refresh( + // &config.node_status_api, + // &mixnet_contract_cache_state, + // &described_nodes_cache, + // &node_status_cache_state, + // performance_provider, + // contract_cache_watcher.clone(), + // describe_cache_watcher, + // &task_manager, + // ); + // + // // start dkg task + // if config.ecash_signer.enabled { + // let dkg_bte_keypair = load_bte_keypair(&config.ecash_signer)?; + // + // DkgController::start( + // &config.ecash_signer, + // nyxd_client.clone(), + // ecash_keypair_wrapper, + // dkg_bte_keypair, + // identity_public_key, + // rand::rngs::OsRng, + // &task_manager, + // )?; + // } + // + // let has_performance_data = + // config.network_monitor.enabled || config.performance_provider.use_performance_contract_data; + // + // // and then only start the uptime updater (and the monitor itself, duh) + // // if the monitoring is enabled + // if config.network_monitor.enabled { + // network_monitor::start::( + // config, + // &mixnet_contract_cache_state, + // described_nodes_cache.clone(), + // node_status_cache_state.clone(), + // &storage, + // nyxd_client.clone(), + // &task_manager, + // ) + // .await; + // + // HistoricalUptimeUpdater::start(storage.to_owned(), &task_manager); + // } + // + // // start 'rewarding' if its enabled and there exists source for performance data + // if config.rewarding.enabled && has_performance_data { + // epoch_operations::ensure_rewarding_permission(&nyxd_client).await?; + // EpochAdvancer::start( + // nyxd_client, + // &mixnet_contract_cache_state, + // &node_status_cache_state, + // described_nodes_cache.clone(), + // &storage, + // &task_manager, + // ); + // } + // + // // finally start a background task watching the contract changes and requesting + // // self-described cache refresh upon being close to key rotation rollover + // KeyRotationController::new( + // describe_cache_refresh_requester, + // contract_cache_watcher, + // mixnet_contract_cache_state, + // ) + // .start(task_manager.subscribe_named("KeyRotationController")); + // + // let bind_address = config.base.bind_address.to_owned(); + // let server = router.build_server(&bind_address).await?; + // + // let cancellation_token = CancellationToken::new(); + // let shutdown_button = cancellation_token.clone(); + // let axum_shutdown_receiver = cancellation_token.cancelled_owned(); + // let server_handle = tokio::spawn(async move { + // { + // info!("Started Axum HTTP V2 server on {bind_address}"); + // server.run(axum_shutdown_receiver).await + // } + // }); + // + // let shutdown = ShutdownHandles::new(task_manager, server_handle, shutdown_button); + // + // Ok(shutdown) } pub(crate) async fn execute(args: Args) -> anyhow::Result<()> { diff --git a/nym-api/src/support/http/state/mod.rs b/nym-api/src/support/http/state/mod.rs index ac501b96eca..e9114b375fa 100644 --- a/nym-api/src/support/http/state/mod.rs +++ b/nym-api/src/support/http/state/mod.rs @@ -8,6 +8,7 @@ use crate::node_describe_cache::cache::DescribedNodes; use crate::node_status_api::handlers::unstable; use crate::node_status_api::models::AxumErrorResponse; use crate::node_status_api::NodeStatusCache; +use crate::signers_cache::cache::SignersCacheData; use crate::status::ApiStatusState; use crate::support::caching::cache::SharedCache; use crate::support::caching::Cache; @@ -42,6 +43,10 @@ pub(crate) struct AppState { /// Note, it is not updated on every request. It follows the embedded ttl. pub(crate) chain_status_cache: ChainStatusCache, + /// Holds cached state of the statuses of all [ecash] signers on the network - + /// their perceived chain statuses and signing capabilities. + pub(crate) ecash_signers_cache: SharedCache, + /// Holds mapping between a nyx address and tokens/delegations it holds pub(crate) address_info_cache: AddressInfoCache, From 643a691b9f8cf8a5ed9df2f47eb913a920020b56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C4=99drzej=20Stuczy=C5=84ski?= Date: Thu, 10 Jul 2025 16:35:12 +0100 Subject: [PATCH 05/10] expose results on api endpoints --- Cargo.lock | 20 +- Cargo.toml | 1 + common/ecash-signer-check-types/Cargo.toml | 27 + .../src/dealer_information.rs | 97 + .../src/helper_traits.rs | 127 + common/ecash-signer-check-types/src/lib.rs | 6 + common/ecash-signer-check-types/src/status.rs | 303 +++ common/ecash-signer-check/Cargo.toml | 4 +- common/ecash-signer-check/src/chain_status.rs | 59 - common/ecash-signer-check/src/client_check.rs | 31 +- common/ecash-signer-check/src/error.rs | 13 - common/ecash-signer-check/src/lib.rs | 25 +- .../ecash-signer-check/src/signing_status.rs | 102 - nym-api/nym-api-requests/Cargo.toml | 3 +- nym-api/nym-api-requests/src/models.rs | 2143 ----------------- .../nym-api-requests/src/models/api_status.rs | 68 + .../src/models/circulating_supply.rs | 19 + .../nym-api-requests/src/models/described.rs | 375 +++ nym-api/nym-api-requests/src/models/legacy.rs | 364 +++ nym-api/nym-api-requests/src/models/mixnet.rs | 242 ++ nym-api/nym-api-requests/src/models/mod.rs | 62 + .../nym-api-requests/src/models/network.rs | 527 ++++ .../src/models/network_monitor.rs | 74 + .../src/models/node_status.rs | 587 +++++ .../src/models/schema_helpers.rs | 128 + nym-api/nym-api-requests/src/signable.rs | 6 +- nym-api/src/network/handlers.rs | 2 + nym-api/src/signers_cache/cache/mod.rs | 4 +- nym-api/src/signers_cache/handlers.rs | 81 + nym-api/src/signers_cache/mod.rs | 20 +- nym-api/src/support/caching/refresher.rs | 7 + nym-api/src/support/cli/run.rs | 327 +-- nym-api/src/support/config/mod.rs | 38 + nym-api/src/support/http/state/mod.rs | 6 + nym-wallet/Cargo.lock | 51 +- 35 files changed, 3410 insertions(+), 2539 deletions(-) create mode 100644 common/ecash-signer-check-types/Cargo.toml create mode 100644 common/ecash-signer-check-types/src/dealer_information.rs create mode 100644 common/ecash-signer-check-types/src/helper_traits.rs create mode 100644 common/ecash-signer-check-types/src/lib.rs create mode 100644 common/ecash-signer-check-types/src/status.rs delete mode 100644 common/ecash-signer-check/src/chain_status.rs delete mode 100644 common/ecash-signer-check/src/signing_status.rs delete mode 100644 nym-api/nym-api-requests/src/models.rs create mode 100644 nym-api/nym-api-requests/src/models/api_status.rs create mode 100644 nym-api/nym-api-requests/src/models/circulating_supply.rs create mode 100644 nym-api/nym-api-requests/src/models/described.rs create mode 100644 nym-api/nym-api-requests/src/models/legacy.rs create mode 100644 nym-api/nym-api-requests/src/models/mixnet.rs create mode 100644 nym-api/nym-api-requests/src/models/mod.rs create mode 100644 nym-api/nym-api-requests/src/models/network.rs create mode 100644 nym-api/nym-api-requests/src/models/network_monitor.rs create mode 100644 nym-api/nym-api-requests/src/models/node_status.rs create mode 100644 nym-api/nym-api-requests/src/models/schema_helpers.rs diff --git a/Cargo.lock b/Cargo.lock index 8136025b76d..ed42d546d52 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4888,7 +4888,6 @@ dependencies = [ "cosmrs", "cosmwasm-std", "ecdsa", - "getset", "hex", "humantime-serde", "nym-coconut-dkg-common 0.1.0", @@ -4897,6 +4896,7 @@ dependencies = [ "nym-contracts-common 0.5.0", "nym-credentials-interface 0.1.0", "nym-crypto 0.4.0", + "nym-ecash-signer-check-types", "nym-ecash-time 0.1.0", "nym-mixnet-contract-common 0.6.0", "nym-network-defaults 0.1.0", @@ -5803,17 +5803,31 @@ name = "nym-ecash-signer-check" version = "0.1.0" dependencies = [ "futures", - "nym-crypto 0.4.0", + "nym-ecash-signer-check-types", "nym-network-defaults 0.1.0", "nym-validator-client 0.1.0", "semver 1.0.26", "thiserror 2.0.12", - "time", "tokio", "tracing", "url", ] +[[package]] +name = "nym-ecash-signer-check-types" +version = "0.1.0" +dependencies = [ + "nym-coconut-dkg-common 0.1.0", + "nym-crypto 0.4.0", + "semver 1.0.26", + "serde", + "thiserror 2.0.12", + "time", + "tracing", + "url", + "utoipa", +] + [[package]] name = "nym-ecash-time" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 80444ca2ab4..ada68143807 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,6 +51,7 @@ members = [ "common/crypto", "common/dkg", "common/ecash-signer-check", + "common/ecash-signer-check-types", "common/ecash-time", "common/execute", "common/exit-policy", diff --git a/common/ecash-signer-check-types/Cargo.toml b/common/ecash-signer-check-types/Cargo.toml new file mode 100644 index 00000000000..0f07437c710 --- /dev/null +++ b/common/ecash-signer-check-types/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "nym-ecash-signer-check-types" +version = "0.1.0" +authors.workspace = true +repository.workspace = true +homepage.workspace = true +documentation.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +readme.workspace = true + +[dependencies] +semver = { workspace = true } +serde = { workspace = true, features = ["derive"] } +url = { workspace = true } +thiserror = { workspace = true } +time = { workspace = true } +tracing = { workspace = true } +utoipa = { workspace = true } + +nym-coconut-dkg-common = { path = "../cosmwasm-smart-contracts/coconut-dkg" } +nym-crypto = { path = "../crypto", features = ["asymmetric"] } + + +[lints] +workspace = true diff --git a/common/ecash-signer-check-types/src/dealer_information.rs b/common/ecash-signer-check-types/src/dealer_information.rs new file mode 100644 index 00000000000..ffdcd7a0a15 --- /dev/null +++ b/common/ecash-signer-check-types/src/dealer_information.rs @@ -0,0 +1,97 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use nym_coconut_dkg_common::dealer::DealerDetails; +use nym_coconut_dkg_common::verification_key::{ContractVKShare, VerificationKeyShare}; +use nym_crypto::asymmetric::ed25519; +use nym_crypto::asymmetric::ed25519::Ed25519RecoveryError; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use url::Url; +use utoipa::ToSchema; + +#[derive(Debug, Error)] +pub enum MalformedDealer { + #[error("dealer at {dealer_url} has provided invalid ed25519 pubkey: {source}")] + InvalidDealerPubkey { + dealer_url: String, + source: Ed25519RecoveryError, + }, + + #[error("dealer at {dealer_url} has provided invalid announce url: {source}")] + InvalidDealerAddress { + dealer_url: String, + source: url::ParseError, + }, +} + +#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)] +pub struct RawDealerInformation { + pub announce_address: String, + pub owner_address: String, + pub node_index: u64, + pub public_key: String, + pub verification_key_share: Option, + pub share_verified: bool, +} + +impl RawDealerInformation { + pub fn new( + dealer_details: &DealerDetails, + contract_share: Option<&ContractVKShare>, + ) -> RawDealerInformation { + RawDealerInformation { + announce_address: dealer_details.announce_address.clone(), + owner_address: dealer_details.address.to_string(), + node_index: dealer_details.assigned_index, + public_key: dealer_details.ed25519_identity.clone(), + verification_key_share: contract_share.map(|s| s.share.clone()), + share_verified: contract_share.map(|s| s.verified).unwrap_or(false), + } + } + + pub fn parse(&self) -> Result { + Ok(DealerInformation { + announce_address: self.announce_address.parse().map_err(|source| { + MalformedDealer::InvalidDealerAddress { + dealer_url: self.announce_address.clone(), + source, + } + })?, + owner_address: self.owner_address.clone(), + node_index: self.node_index, + public_key: self.announce_address.parse().map_err(|source| { + MalformedDealer::InvalidDealerPubkey { + dealer_url: self.announce_address.clone(), + source, + } + })?, + verification_key_share: self.verification_key_share.clone(), + share_verified: self.share_verified, + }) + } +} + +#[derive(Debug)] +pub struct DealerInformation { + pub announce_address: Url, + pub owner_address: String, + pub node_index: u64, + pub public_key: ed25519::PublicKey, + // no need to parse it into the full type as it doesn't get us anything + pub verification_key_share: Option, + pub share_verified: bool, +} + +impl From for RawDealerInformation { + fn from(d: DealerInformation) -> Self { + RawDealerInformation { + announce_address: d.announce_address.to_string(), + owner_address: d.owner_address, + node_index: d.node_index, + public_key: d.public_key.to_base58_string(), + verification_key_share: d.verification_key_share, + share_verified: d.share_verified, + } + } +} diff --git a/common/ecash-signer-check-types/src/helper_traits.rs b/common/ecash-signer-check-types/src/helper_traits.rs new file mode 100644 index 00000000000..837c2538aa8 --- /dev/null +++ b/common/ecash-signer-check-types/src/helper_traits.rs @@ -0,0 +1,127 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use nym_coconut_dkg_common::types::EpochId; +use nym_coconut_dkg_common::verification_key::VerificationKeyShare; +use nym_crypto::asymmetric::ed25519; +use std::time::Duration; +use time::OffsetDateTime; +use tracing::{debug, warn}; + +pub trait Verifiable { + fn verify_signature(&self, pub_key: &ed25519::PublicKey) -> bool; +} + +pub trait TimestampedResponse { + fn timestamp(&self) -> OffsetDateTime; +} + +pub trait LegacyChainResponse { + fn chain_synced(&self, now: OffsetDateTime, stall_threshold: Duration) -> bool; +} + +pub trait ChainResponse: Verifiable + TimestampedResponse { + fn chain_synced(&self) -> bool; + + fn chain_available( + &self, + pub_key: &ed25519::PublicKey, + now: OffsetDateTime, + stale_response_threshold: Duration, + ) -> bool { + if !self.verify_signature(pub_key) { + warn!("failed signature verification on chain status response"); + return false; + } + + // we rely on information provided from the api itself AS LONG AS it's not too outdated + if self.timestamp() + stale_response_threshold < now { + return false; + } + self.chain_synced() + } +} + +pub trait LegacySignerResponse { + fn signer_identity(&self) -> &str; + + fn signer_verification_key(&self) -> &Option; + + fn unprovable_signing_available( + &self, + pub_key: &ed25519::PublicKey, + expected_verification_key: Option, + share_verified: bool, + ) -> bool { + if self.signer_identity() != pub_key.to_base58_string() { + warn!("mismatched identity key on the legacy response"); + return false; + } + + // the contract share hasn't been verified yet, so we're probably in the middle of DKG + // thus if there's a bit of desync in the state, it's fine + if !share_verified { + return true; + } + + if self.signer_verification_key() != &expected_verification_key { + warn!("mismatched [ecash] verification key on the legacy response"); + return false; + } + + true + } +} + +pub trait SignerResponse: Verifiable + TimestampedResponse { + fn has_signing_keys(&self) -> bool; + + fn signer_disabled(&self) -> bool; + + fn is_ecash_signer(&self) -> bool; + + fn dkg_ecash_epoch_id(&self) -> EpochId; + + fn provable_signing_available( + &self, + pub_key: &ed25519::PublicKey, + dkg_epoch_id: EpochId, + now: OffsetDateTime, + stale_response_threshold: Duration, + ) -> bool { + if !self.verify_signature(&pub_key) { + warn!("failed signature verification on chain status response"); + return false; + } + + // we rely on information provided from the api itself AS LONG AS it's not too outdated + if self.timestamp() + stale_response_threshold < now { + return false; + } + + if !self.has_signing_keys() { + debug!("missing signing keys"); + return false; + } + + if self.signer_disabled() { + debug!("signer functionalities explicitly disabled"); + return false; + } + + if !self.is_ecash_signer() { + debug!("signer doesn't recognise it's a signer for this epoch"); + return false; + } + + if dkg_epoch_id != self.dkg_ecash_epoch_id() { + debug!( + "mismatched dkg epoch id. current: {dkg_epoch_id}, signer's: {}", + self.dkg_ecash_epoch_id() + ); + return false; + } + + true + } +} diff --git a/common/ecash-signer-check-types/src/lib.rs b/common/ecash-signer-check-types/src/lib.rs new file mode 100644 index 00000000000..60e5ccd667b --- /dev/null +++ b/common/ecash-signer-check-types/src/lib.rs @@ -0,0 +1,6 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +pub mod dealer_information; +pub mod helper_traits; +pub mod status; diff --git a/common/ecash-signer-check-types/src/status.rs b/common/ecash-signer-check-types/src/status.rs new file mode 100644 index 00000000000..2a506d6c969 --- /dev/null +++ b/common/ecash-signer-check-types/src/status.rs @@ -0,0 +1,303 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::dealer_information::RawDealerInformation; +use crate::helper_traits::{ + ChainResponse, LegacyChainResponse, LegacySignerResponse, SignerResponse, +}; +use nym_coconut_dkg_common::types::EpochId; +use nym_coconut_dkg_common::verification_key::VerificationKeyShare; +use nym_crypto::asymmetric::ed25519; +use serde::{Deserialize, Serialize}; +use std::time::Duration; +use time::OffsetDateTime; +use utoipa::ToSchema; + +pub(crate) const CHAIN_STALL_THRESHOLD: Duration = Duration::from_secs(5 * 60); +pub(crate) const STALE_RESPONSE_THRESHOLD: Duration = Duration::from_secs(5 * 60); + +// the reason for generics is not to remove duplication of code, +// but because without them, we'd be having problems with circular dependencies, +// i.e. nym-api-requests depending on ecash-signer-check-types and +// ecash-signer-check-types needing nym-api-requests +#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)] +pub enum Status { + /// The API, even though it reports correct version, did not response to the status query + Unreachable, + + /// The API is running an outdated version that does not expose the required endpoint + Outdated, + + /// Response to the legacy (unsigned) status query + ReachableLegacy { response: Box }, + + /// Response to the current (signed) status query + Reachable { response: Box }, +} + +impl Status +where + L: LegacyChainResponse, + T: ChainResponse, +{ + pub fn chain_available(&self, pub_key: ed25519::PublicKey) -> bool { + let now = OffsetDateTime::now_utc(); + + match self { + Status::Unreachable | Status::Outdated => false, + Status::ReachableLegacy { response } => { + response.chain_synced(now, CHAIN_STALL_THRESHOLD) + } + Status::Reachable { response } => { + response.chain_available(&pub_key, now, STALE_RESPONSE_THRESHOLD) + } + } + } + + pub fn chain_provably_stalled(&self, pub_key: ed25519::PublicKey) -> bool { + let now = OffsetDateTime::now_utc(); + + match self { + Status::Unreachable | Status::Outdated | Status::ReachableLegacy { .. } => false, + Status::Reachable { response } => { + !response.chain_available(&pub_key, now, STALE_RESPONSE_THRESHOLD) + } + } + } + + pub fn chain_unprovably_stalled(&self) -> bool { + let now = OffsetDateTime::now_utc(); + + match self { + Status::Unreachable | Status::Outdated | Status::Reachable { .. } => false, + Status::ReachableLegacy { response } => { + !response.chain_synced(now, CHAIN_STALL_THRESHOLD) + } + } + } +} + +impl Status +where + L: LegacySignerResponse, + T: SignerResponse, +{ + pub fn signing_available( + &self, + pub_key: ed25519::PublicKey, + dkg_epoch_id: u64, + expected_verification_key: Option, + share_verified: bool, + ) -> bool { + let now = OffsetDateTime::now_utc(); + + match self { + Status::Unreachable | Status::Outdated => false, + Status::ReachableLegacy { response } => response.unprovable_signing_available( + &pub_key, + expected_verification_key, + share_verified, + ), + Status::Reachable { response } => response.provable_signing_available( + &pub_key, + dkg_epoch_id, + now, + STALE_RESPONSE_THRESHOLD, + ), + } + } + + pub fn signing_provably_unavailable( + &self, + pub_key: ed25519::PublicKey, + dkg_epoch_id: EpochId, + ) -> bool { + let now = OffsetDateTime::now_utc(); + + match self { + Status::Unreachable | Status::Outdated | Status::ReachableLegacy { .. } => false, + Status::Reachable { response } => !response.provable_signing_available( + &pub_key, + dkg_epoch_id, + now, + STALE_RESPONSE_THRESHOLD, + ), + } + } + + pub fn signing_unprovably_unavailable( + &self, + pub_key: ed25519::PublicKey, + expected_verification_key: Option, + share_verified: bool, + ) -> bool { + match self { + Status::Unreachable | Status::Outdated | Status::Reachable { .. } => false, + Status::ReachableLegacy { response } => !response.unprovable_signing_available( + &pub_key, + expected_verification_key, + share_verified, + ), + } + } +} + +#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)] +pub struct SignerResult { + pub dkg_epoch_id: u64, + pub information: RawDealerInformation, + pub status: SignerStatus, +} + +impl SignerResult { + pub fn signer_unreachable(&self) -> bool { + matches!(self.status, SignerStatus::Unreachable) + } + + pub fn malformed_details(&self) -> bool { + self.information.parse().is_err() + } +} + +impl SignerResult +where + LC: LegacyChainResponse, + TC: ChainResponse, +{ + pub fn unknown_chain_status(&self) -> bool { + let Ok(_) = self.information.parse() else { + return true; + }; + if let SignerStatus::Tested { .. } = &self.status { + return false; + } + true + } + + pub fn chain_available(&self) -> bool { + let Ok(parsed_info) = self.information.parse() else { + return false; + }; + + let SignerStatus::Tested { result } = &self.status else { + return false; + }; + result + .local_chain_status + .chain_available(parsed_info.public_key) + } + + pub fn chain_provably_stalled(&self) -> bool { + let Ok(parsed_info) = self.information.parse() else { + return false; + }; + + let SignerStatus::Tested { result } = &self.status else { + return false; + }; + + result + .local_chain_status + .chain_provably_stalled(parsed_info.public_key) + } + + pub fn chain_unprovably_stalled(&self) -> bool { + let SignerStatus::Tested { result } = &self.status else { + return false; + }; + + result.local_chain_status.chain_unprovably_stalled() + } +} + +impl SignerResult +where + LS: LegacySignerResponse, + TS: SignerResponse, +{ + pub fn unknown_signing_status(&self) -> bool { + let Ok(_) = self.information.parse() else { + return true; + }; + if let SignerStatus::Tested { .. } = &self.status { + return false; + } + true + } + + pub fn signing_available(&self) -> bool { + let Ok(parsed_info) = self.information.parse() else { + return false; + }; + + let SignerStatus::Tested { result } = &self.status else { + return false; + }; + result.signing_status.signing_available( + parsed_info.public_key, + self.dkg_epoch_id, + parsed_info.verification_key_share, + parsed_info.share_verified, + ) + } + + pub fn signing_provably_unavailable(&self) -> bool { + let Ok(parsed_info) = self.information.parse() else { + return false; + }; + + let SignerStatus::Tested { result } = &self.status else { + return false; + }; + + result + .signing_status + .signing_provably_unavailable(parsed_info.public_key, self.dkg_epoch_id) + } + + pub fn signing_unprovably_unavailable(&self) -> bool { + let Ok(parsed_info) = self.information.parse() else { + return false; + }; + + let SignerStatus::Tested { result } = &self.status else { + return false; + }; + + result.signing_status.signing_unprovably_unavailable( + parsed_info.public_key, + parsed_info.verification_key_share, + parsed_info.share_verified, + ) + } +} + +#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)] +pub enum SignerStatus { + Unreachable, + ProvidedInvalidDetails, + Tested { + result: SignerTestResult, + }, +} + +impl SignerStatus { + pub fn with_details( + self, + information: impl Into, + dkg_epoch_id: u64, + ) -> SignerResult { + SignerResult { + dkg_epoch_id, + status: self, + information: information.into(), + } + } +} + +#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)] +pub struct SignerTestResult { + pub reported_version: String, + pub signing_status: Status, + pub local_chain_status: Status, +} diff --git a/common/ecash-signer-check/Cargo.toml b/common/ecash-signer-check/Cargo.toml index 5cea24e3e98..b521591882b 100644 --- a/common/ecash-signer-check/Cargo.toml +++ b/common/ecash-signer-check/Cargo.toml @@ -14,7 +14,6 @@ readme.workspace = true futures = { workspace = true } thiserror = { workspace = true } semver = { workspace = true } -time = { workspace = true } tokio = { workspace = true, features = ["time"] } tracing = { workspace = true } url = { workspace = true } @@ -22,8 +21,7 @@ url = { workspace = true } nym-validator-client = { path = "../client-libs/validator-client" } nym-network-defaults = { path = "../network-defaults" } -nym-crypto = { path = "../crypto", features = ["asymmetric"] } - +nym-ecash-signer-check-types = { path = "../ecash-signer-check-types" } [lints] diff --git a/common/ecash-signer-check/src/chain_status.rs b/common/ecash-signer-check/src/chain_status.rs deleted file mode 100644 index 8897d8e485b..00000000000 --- a/common/ecash-signer-check/src/chain_status.rs +++ /dev/null @@ -1,59 +0,0 @@ -// Copyright 2025 - Nym Technologies SA -// SPDX-License-Identifier: Apache-2.0 - -use crate::status::STALE_RESPONSE_THRESHOLD; -use nym_crypto::asymmetric::ed25519; -use nym_validator_client::models::{ChainBlocksStatusResponse, ChainStatusResponse}; -use std::time::Duration; -use time::OffsetDateTime; -use tracing::warn; - -// Dorina -pub(crate) const MINIMUM_VERSION_LEGACY: semver::Version = semver::Version::new(1, 1, 51); - -// Emmental -pub(crate) const MINIMUM_VERSION: semver::Version = semver::Version::new(1, 1, 62); - -const CHAIN_STALL_THRESHOLD: Duration = Duration::from_secs(5 * 60); - -#[derive(Debug)] -pub enum LocalChainStatus { - /// The API, even though it reports correct version, did not response to the status query - Unreachable, - - /// The API is running an outdated version that does not expose the required endpoint - Outdated, - - /// Response to the legacy (unsigned) status query - ReachableLegacy { response: Box }, - - /// Response to the current (signed) status query - Reachable { - response: Box, - }, -} - -impl LocalChainStatus { - pub fn available(&self, pub_key: ed25519::PublicKey) -> bool { - let now = OffsetDateTime::now_utc(); - match self { - LocalChainStatus::Unreachable | LocalChainStatus::Outdated => false, - LocalChainStatus::ReachableLegacy { response } => response - .status - .stall_status(now, CHAIN_STALL_THRESHOLD) - .is_synced(), - LocalChainStatus::Reachable { response } => { - if !response.verify_signature(&pub_key) { - warn!("failed signature verification on chain status response"); - return false; - } - - // we rely on information provided from the api itself AS LONG AS it's not too outdated - if response.body.current_time + STALE_RESPONSE_THRESHOLD < now { - return false; - } - response.body.chain_status.is_synced() - } - } - } -} diff --git a/common/ecash-signer-check/src/client_check.rs b/common/ecash-signer-check/src/client_check.rs index fca79d024a2..a932b676fd4 100644 --- a/common/ecash-signer-check/src/client_check.rs +++ b/common/ecash-signer-check/src/client_check.rs @@ -1,11 +1,9 @@ // Copyright 2025 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 -use crate::chain_status::LocalChainStatus; -use crate::dealer_information::RawDealerInformation; -use crate::signing_status::SigningStatus; -use crate::status::{SignerStatus, SignerTestResult}; -use crate::{chain_status, signing_status, SignerResult}; +use crate::{LocalChainStatus, SigningStatus, TypedSignerResult}; +use nym_ecash_signer_check_types::dealer_information::RawDealerInformation; +use nym_ecash_signer_check_types::status::{SignerStatus, SignerTestResult}; use nym_validator_client::client::NymApiClientExt; use nym_validator_client::models::BinaryBuildInformationOwned; use nym_validator_client::nyxd::contract_traits::dkg_query_client::{ @@ -16,6 +14,23 @@ use std::time::Duration; use tracing::{error, warn}; use url::Url; +pub(crate) mod chain_status { + + // Dorina + pub(crate) const MINIMUM_VERSION_LEGACY: semver::Version = semver::Version::new(1, 1, 51); + + // Emmental + pub(crate) const MINIMUM_VERSION: semver::Version = semver::Version::new(1, 1, 62); +} + +pub(crate) mod signing_status { + // Magura (possibly earlier) + pub(crate) const MINIMUM_LEGACY_VERSION: semver::Version = semver::Version::new(1, 1, 46); + + // Emmental + pub(crate) const MINIMUM_VERSION: semver::Version = semver::Version::new(1, 1, 62); +} + struct ClientUnderTest { api_client: NymApiClient, build_info: Option, @@ -144,7 +159,9 @@ impl ClientUnderTest { // check if it supports the current query if self.supports_signing_status_query() { return match self.api_client.nym_api.get_signer_status().await { - Ok(response) => SigningStatus::Reachable { response }, + Ok(response) => SigningStatus::Reachable { + response: Box::new(response), + }, Err(err) => { warn!( "{}: failed to retrieve signer chain status: {err}", @@ -176,7 +193,7 @@ pub(crate) async fn check_client( dealer_details: DealerDetails, dkg_epoch: u64, contract_share: Option<&ContractVKShare>, -) -> SignerResult { +) -> TypedSignerResult { let dealer_information = RawDealerInformation::new(&dealer_details, contract_share); // 6. attempt to construct client instances out of them diff --git a/common/ecash-signer-check/src/error.rs b/common/ecash-signer-check/src/error.rs index 10f2c1568ff..bb7c3c719bd 100644 --- a/common/ecash-signer-check/src/error.rs +++ b/common/ecash-signer-check/src/error.rs @@ -1,7 +1,6 @@ // Copyright 2025 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 -use nym_crypto::asymmetric::ed25519::Ed25519RecoveryError; use nym_validator_client::nyxd::error::NyxdError; use thiserror::Error; @@ -12,18 +11,6 @@ pub enum SignerCheckError { #[error("failed to query the DKG contract: {source}")] DKGContractQueryFailure { source: NyxdError }, - - #[error("dealer at {dealer_url} has provided invalid ed25519 pubkey: {source}")] - InvalidDealerPubkey { - dealer_url: String, - source: Ed25519RecoveryError, - }, - - #[error("dealer at {dealer_url} has provided invalid announce url: {source}")] - InvalidDealerAddress { - dealer_url: String, - source: url::ParseError, - }, } impl SignerCheckError { diff --git a/common/ecash-signer-check/src/lib.rs b/common/ecash-signer-check/src/lib.rs index 6b5e509b98e..772962a5f0f 100644 --- a/common/ecash-signer-check/src/lib.rs +++ b/common/ecash-signer-check/src/lib.rs @@ -9,21 +9,30 @@ use nym_validator_client::QueryHttpRpcNyxdClient; use std::collections::HashMap; use url::Url; -pub use crate::status::SignerResult; pub use error::SignerCheckError; +use nym_ecash_signer_check_types::status::{SignerResult, Status}; +use nym_validator_client::ecash::models::EcashSignerStatusResponse; +use nym_validator_client::models::{ + ChainBlocksStatusResponse, ChainStatusResponse, SignerInformationResponse, +}; -pub mod chain_status; mod client_check; -pub mod dealer_information; pub mod error; -pub mod signing_status; -pub mod status; + +pub type TypedSignerResult = SignerResult< + SignerInformationResponse, + EcashSignerStatusResponse, + ChainStatusResponse, + ChainBlocksStatusResponse, +>; +pub type LocalChainStatus = Status; +pub type SigningStatus = Status; pub async fn check_signers( rpc_endpoint: Url, // details such as denoms, prefixes, etc. network_details: NymNetworkDetails, -) -> Result, SignerCheckError> { +) -> Result, SignerCheckError> { // 1. create nyx client instance let client = QueryHttpRpcNyxdClient::connect_with_network_details( rpc_endpoint.as_str(), @@ -34,7 +43,9 @@ pub async fn check_signers( check_signers_with_client(&client).await } -pub async fn check_signers_with_client(client: &C) -> Result, SignerCheckError> +pub async fn check_signers_with_client( + client: &C, +) -> Result, SignerCheckError> where C: DkgQueryClient + Sync, { diff --git a/common/ecash-signer-check/src/signing_status.rs b/common/ecash-signer-check/src/signing_status.rs deleted file mode 100644 index 8176f8886a0..00000000000 --- a/common/ecash-signer-check/src/signing_status.rs +++ /dev/null @@ -1,102 +0,0 @@ -// Copyright 2025 - Nym Technologies SA -// SPDX-License-Identifier: Apache-2.0 - -use crate::status::STALE_RESPONSE_THRESHOLD; -use nym_crypto::asymmetric::ed25519; -use nym_validator_client::ecash::models::EcashSignerStatusResponse; -use nym_validator_client::models::SignerInformationResponse; -use nym_validator_client::nyxd::contract_traits::dkg_query_client::VerificationKeyShare; -use time::OffsetDateTime; -use tracing::{debug, warn}; - -// Magura (possibly earlier) -pub(crate) const MINIMUM_LEGACY_VERSION: semver::Version = semver::Version::new(1, 1, 46); - -// Emmental -pub(crate) const MINIMUM_VERSION: semver::Version = semver::Version::new(1, 1, 62); - -#[derive(Debug)] -pub enum SigningStatus { - /// The API, even though it reports correct version, did not response to the status query - Unreachable, - - /// The API is running an outdated version that does not expose the required endpoint - Outdated, - - /// Response to the legacy (unsigned) status query - ReachableLegacy { - response: Box, - }, - - /// Response to the current (signed) status query - Reachable { response: EcashSignerStatusResponse }, -} - -impl SigningStatus { - pub fn available( - &self, - pub_key: ed25519::PublicKey, - dkg_epoch_id: u64, - expected_verification_key: Option, - share_verified: bool, - ) -> bool { - let now = OffsetDateTime::now_utc(); - match self { - SigningStatus::Unreachable | SigningStatus::Outdated => false, - SigningStatus::ReachableLegacy { response } => { - if response.identity != pub_key.to_base58_string() { - warn!("mismatched identity key on the legacy response"); - return false; - } - - // the contract share hasn't been verified yet, so we're probably in the middle of DKG - // thus if there's a bit of desync in the state, it's fine - if !share_verified { - return true; - } - - if response.verification_key != expected_verification_key { - warn!("mismatched [ecash] verification key on the legacy response"); - return false; - } - - true - } - SigningStatus::Reachable { response } => { - if !response.verify_signature(&pub_key) { - warn!("failed signature verification on signer status response"); - return false; - } - - if response.body.current_time + STALE_RESPONSE_THRESHOLD < now { - return false; - } - - if !response.body.has_signing_keys { - debug!("missing signing keys"); - return false; - } - - if response.body.signer_disabled { - debug!("signer functionalities explicitly disabled"); - return false; - } - - if !response.body.is_ecash_signer { - debug!("signer doesn't recognise it's a signer for this epoch"); - return false; - } - - if dkg_epoch_id != response.body.dkg_ecash_epoch_id { - debug!( - "mismatched dkg epoch id. current: {dkg_epoch_id}, signer's: {}", - response.body.dkg_ecash_epoch_id - ); - return false; - } - - true - } - } - } -} diff --git a/nym-api/nym-api-requests/Cargo.toml b/nym-api/nym-api-requests/Cargo.toml index a0c5fc32c83..267cb731f7e 100644 --- a/nym-api/nym-api-requests/Cargo.toml +++ b/nym-api/nym-api-requests/Cargo.toml @@ -10,7 +10,6 @@ license.workspace = true bs58 = { workspace = true } cosmrs = { workspace = true } cosmwasm-std = { workspace = true } -getset = { workspace = true } schemars = { workspace = true, features = ["preserve_order"] } serde = { workspace = true, features = ["derive"] } humantime-serde = { workspace = true } @@ -42,6 +41,8 @@ nym-node-requests = { path = "../../nym-node/nym-node-requests", default-feature nym-noise-keys = { path = "../../common/nymnoise/keys" } nym-network-defaults = { path = "../../common/network-defaults" } nym-ticketbooks-merkle = { path = "../../common/ticketbooks-merkle" } +nym-ecash-signer-check-types = { path = "../../common/ecash-signer-check-types" } + [dev-dependencies] rand_chacha = { workspace = true } diff --git a/nym-api/nym-api-requests/src/models.rs b/nym-api/nym-api-requests/src/models.rs deleted file mode 100644 index c047e208a34..00000000000 --- a/nym-api/nym-api-requests/src/models.rs +++ /dev/null @@ -1,2143 +0,0 @@ -// Copyright 2022-2024 - Nym Technologies SA -// SPDX-License-Identifier: Apache-2.0 - -#![allow(deprecated)] - -use crate::helpers::unix_epoch; -use crate::helpers::PlaceholderJsonSchemaImpl; -use crate::legacy::{ - LegacyGatewayBondWithId, LegacyMixNodeBondWithLayer, LegacyMixNodeDetailsWithLayer, -}; -use crate::models::tendermint_types::{BlockHeader, BlockId}; -use crate::nym_nodes::SemiSkimmedNode; -use crate::nym_nodes::{BasicEntryInformation, NodeRole, SkimmedNode}; -use crate::pagination::PaginatedResponse; -use crate::signable::SignedMessage; -use cosmwasm_std::{Addr, Coin, Decimal, Uint128}; -use nym_contracts_common::NaiveFloat; -use nym_crypto::asymmetric::ed25519::{self, serde_helpers::bs58_ed25519_pubkey}; -use nym_crypto::asymmetric::x25519::{self, serde_helpers::bs58_x25519_pubkey}; -use nym_mixnet_contract_common::nym_node::Role; -use nym_mixnet_contract_common::reward_params::{Performance, RewardingParams}; -use nym_mixnet_contract_common::rewarding::RewardEstimate; -use nym_mixnet_contract_common::{GatewayBond, IdentityKey, Interval, MixNode, NodeId, Percent}; -use nym_network_defaults::{DEFAULT_MIX_LISTENING_PORT, DEFAULT_VERLOC_LISTENING_PORT}; -use nym_node_requests::api::v1::authenticator::models::Authenticator; -use nym_node_requests::api::v1::gateway::models::Wireguard; -use nym_node_requests::api::v1::ip_packet_router::models::IpPacketRouter; -use nym_node_requests::api::v1::node::models::{AuxiliaryDetails, NodeRoles}; -use nym_noise_keys::VersionedNoiseKey; -use schemars::gen::SchemaGenerator; -use schemars::schema::{InstanceType, Schema, SchemaObject}; -use schemars::JsonSchema; -use serde::{Deserialize, Deserializer, Serialize}; -use std::cmp::Ordering; -use std::collections::BTreeMap; -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 tracing::{error, warn}; -use utoipa::{IntoParams, ToResponse, ToSchema}; - -pub use nym_mixnet_contract_common::{EpochId, KeyRotationId, KeyRotationState}; -pub use nym_node_requests::api::v1::node::models::BinaryBuildInformationOwned; - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] -pub struct RequestError { - message: String, -} - -impl RequestError { - pub fn new>(msg: S) -> Self { - RequestError { - message: msg.into(), - } - } - - pub fn message(&self) -> &str { - &self.message - } - - pub fn empty() -> Self { - Self { - message: String::new(), - } - } -} - -impl Display for RequestError { - fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - Display::fmt(&self.message, f) - } -} - -#[derive( - Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema, ToSchema, Default, -)] -#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))] -#[cfg_attr( - feature = "generate-ts", - ts( - export, - export_to = "ts-packages/types/src/types/rust/MixnodeStatus.ts" - ) -)] -#[serde(rename_all = "snake_case")] -pub enum MixnodeStatus { - Active, // in both the active set and the rewarded set - Standby, // only in the rewarded set - Inactive, // in neither the rewarded set nor the active set, but is bonded - #[default] - NotFound, // doesn't even exist in the bonded set -} -impl MixnodeStatus { - pub fn is_active(&self) -> bool { - *self == MixnodeStatus::Active - } -} - -#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, ToSchema)] -#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))] -#[cfg_attr( - feature = "generate-ts", - ts( - export, - export_to = "ts-packages/types/src/types/rust/MixnodeCoreStatusResponse.ts" - ) -)] -pub struct MixnodeCoreStatusResponse { - pub mix_id: NodeId, - pub count: i64, -} - -#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, ToSchema)] -#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))] -#[cfg_attr( - feature = "generate-ts", - ts( - export, - export_to = "ts-packages/types/src/types/rust/GatewayCoreStatusResponse.ts" - ) -)] -pub struct GatewayCoreStatusResponse { - pub identity: String, - pub count: i64, -} - -#[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema, ToSchema)] -#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))] -#[cfg_attr( - feature = "generate-ts", - ts( - export, - export_to = "ts-packages/types/src/types/rust/MixnodeStatusResponse.ts" - ) -)] -pub struct MixnodeStatusResponse { - pub status: MixnodeStatus, -} - -#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema, ToSchema)] -pub struct NodePerformance { - #[schema(value_type = String)] - pub most_recent: Performance, - #[schema(value_type = String)] - pub last_hour: Performance, - #[schema(value_type = String)] - pub last_24h: Performance, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, Hash, JsonSchema, ToSchema)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))] -#[cfg_attr( - feature = "generate-ts", - ts(export, export_to = "ts-packages/types/src/types/rust/DisplayRole.ts") -)] -pub enum DisplayRole { - EntryGateway, - Layer1, - Layer2, - Layer3, - ExitGateway, - Standby, -} - -impl From for DisplayRole { - fn from(role: Role) -> Self { - match role { - Role::EntryGateway => DisplayRole::EntryGateway, - Role::Layer1 => DisplayRole::Layer1, - Role::Layer2 => DisplayRole::Layer2, - Role::Layer3 => DisplayRole::Layer3, - Role::ExitGateway => DisplayRole::ExitGateway, - Role::Standby => DisplayRole::Standby, - } - } -} - -impl From for Role { - fn from(role: DisplayRole) -> Self { - match role { - DisplayRole::EntryGateway => Role::EntryGateway, - DisplayRole::Layer1 => Role::Layer1, - DisplayRole::Layer2 => Role::Layer2, - DisplayRole::Layer3 => Role::Layer3, - DisplayRole::ExitGateway => Role::ExitGateway, - DisplayRole::Standby => Role::Standby, - } - } -} - -// imo for now there's no point in exposing more than that, -// nym-api shouldn't be calculating apy or stake saturation for you. -// it should just return its own metrics (performance) and then you can do with it as you wish -#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, JsonSchema, ToSchema)] -#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))] -#[cfg_attr( - feature = "generate-ts", - ts( - export, - export_to = "ts-packages/types/src/types/rust/NodeAnnotation.ts" - ) -)] -pub struct NodeAnnotation { - #[cfg_attr(feature = "generate-ts", ts(type = "string"))] - // legacy - #[schema(value_type = String)] - pub last_24h_performance: Performance, - pub current_role: Option, - - pub detailed_performance: DetailedNodePerformance, -} - -#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, JsonSchema, ToSchema)] -#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))] -#[cfg_attr( - feature = "generate-ts", - ts( - export, - export_to = "ts-packages/types/src/types/rust/DetailedNodePerformance.ts" - ) -)] -#[non_exhaustive] -pub struct DetailedNodePerformance { - /// routing_score * config_score - pub performance_score: f64, - - pub routing_score: RoutingScore, - pub config_score: ConfigScore, -} - -impl DetailedNodePerformance { - pub fn new( - performance_score: f64, - routing_score: RoutingScore, - config_score: ConfigScore, - ) -> DetailedNodePerformance { - Self { - performance_score, - routing_score, - config_score, - } - } - - pub fn to_rewarding_performance(&self) -> Performance { - Performance::naive_try_from_f64(self.performance_score).unwrap_or_default() - } -} - -#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, JsonSchema, ToSchema)] -#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))] -#[cfg_attr( - feature = "generate-ts", - ts(export, export_to = "ts-packages/types/src/types/rust/RoutingScore.ts") -)] -#[non_exhaustive] -pub struct RoutingScore { - /// Total score after taking all the criteria into consideration - pub score: f64, -} - -impl RoutingScore { - pub fn new(score: f64) -> RoutingScore { - Self { score } - } - - pub const fn zero() -> RoutingScore { - RoutingScore { score: 0.0 } - } - - pub fn legacy_performance(&self) -> Performance { - Performance::naive_try_from_f64(self.score).unwrap_or_default() - } -} - -#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, JsonSchema, ToSchema)] -#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))] -#[cfg_attr( - feature = "generate-ts", - ts(export, export_to = "ts-packages/types/src/types/rust/ConfigScore.ts") -)] -#[non_exhaustive] -pub struct ConfigScore { - /// Total score after taking all the criteria into consideration - pub score: f64, - - pub versions_behind: Option, - pub self_described_api_available: bool, - pub accepted_terms_and_conditions: bool, - pub runs_nym_node_binary: bool, -} - -impl ConfigScore { - pub fn new( - score: f64, - versions_behind: u32, - accepted_terms_and_conditions: bool, - runs_nym_node_binary: bool, - ) -> ConfigScore { - Self { - score, - versions_behind: Some(versions_behind), - self_described_api_available: true, - accepted_terms_and_conditions, - runs_nym_node_binary, - } - } - - pub fn bad_semver() -> ConfigScore { - ConfigScore { - score: 0.0, - versions_behind: None, - self_described_api_available: true, - accepted_terms_and_conditions: false, - runs_nym_node_binary: false, - } - } - - pub fn unavailable() -> ConfigScore { - ConfigScore { - score: 0.0, - versions_behind: None, - self_described_api_available: false, - accepted_terms_and_conditions: false, - runs_nym_node_binary: false, - } - } -} - -#[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema, ToSchema)] -#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))] -#[cfg_attr( - feature = "generate-ts", - ts( - export, - export_to = "ts-packages/types/src/types/rust/AnnotationResponse.ts" - ) -)] -pub struct AnnotationResponse { - #[schema(value_type = u32)] - pub node_id: NodeId, - pub annotation: Option, -} - -#[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema, ToSchema)] -#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))] -#[cfg_attr( - feature = "generate-ts", - ts( - export, - export_to = "ts-packages/types/src/types/rust/NodePerformanceResponse.ts" - ) -)] -pub struct NodePerformanceResponse { - #[schema(value_type = u32)] - pub node_id: NodeId, - pub performance: Option, -} - -#[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema, ToSchema)] -#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))] -#[cfg_attr( - feature = "generate-ts", - ts( - export, - export_to = "ts-packages/types/src/types/rust/NodeDatePerformanceResponse.ts" - ) -)] -pub struct NodeDatePerformanceResponse { - #[schema(value_type = u32)] - pub node_id: NodeId, - #[schema(value_type = String, example = "1970-01-01")] - #[schemars(with = "String")] - #[cfg_attr(feature = "generate-ts", ts(type = "string"))] - pub date: Date, - pub performance: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, ToSchema)] -#[schema(title = "LegacyMixNodeDetailsWithLayer")] -pub struct LegacyMixNodeDetailsWithLayerSchema { - /// Basic bond information of this mixnode, such as owner address, original pledge, etc. - #[schema(example = "unimplemented schema")] - pub bond_information: String, - - /// Details used for computation of rewarding related data. - #[schema(example = "unimplemented schema")] - pub rewarding_details: String, - - /// Adjustments to the mixnode that are ought to happen during future epoch transitions. - #[schema(example = "unimplemented schema")] - pub pending_changes: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, ToSchema)] -pub struct MixNodeBondAnnotated { - #[schema(value_type = LegacyMixNodeDetailsWithLayerSchema)] - pub mixnode_details: LegacyMixNodeDetailsWithLayer, - #[schema(value_type = String)] - pub stake_saturation: StakeSaturation, - #[schema(value_type = String)] - pub uncapped_stake_saturation: StakeSaturation, - // NOTE: the performance field is deprecated in favour of node_performance - #[schema(value_type = String)] - pub performance: Performance, - pub node_performance: NodePerformance, - #[schema(value_type = String)] - pub estimated_operator_apy: Decimal, - #[schema(value_type = String)] - pub estimated_delegators_apy: Decimal, - pub blacklisted: bool, - - // a rather temporary thing until we query self-described endpoints of mixnodes - #[serde(default)] - #[schema(value_type = Vec)] - 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 - } - - pub fn mix_id(&self) -> NodeId { - self.mixnode_details.mix_id() - } - - pub fn identity_key(&self) -> &str { - self.mixnode_details.bond_information.identity() - } - - pub fn owner(&self) -> &Addr { - self.mixnode_details.bond_information.owner() - } - - 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)?, - role, - supported_roles: DeclaredRoles { - mixnode: true, - entry: false, - exit_nr: false, - exit_ipr: false, - }, - entry: None, - performance: self.node_performance.last_24h, - }) - } - - pub fn try_to_semi_skimmed_node( - &self, - role: NodeRole, - ) -> Result { - let skimmed_node = self.try_to_skimmed_node(role)?; - Ok(SemiSkimmedNode { - basic: skimmed_node, - x25519_noise_versioned_key: None, // legacy node won't ever support Noise - }) - } -} - -#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, ToSchema)] -pub struct GatewayBondAnnotated { - pub gateway_bond: LegacyGatewayBondWithId, - - #[serde(default)] - pub self_described: Option, - - // NOTE: the performance field is deprecated in favour of node_performance - #[schema(value_type = String)] - pub performance: Performance, - pub node_performance: NodePerformance, - pub blacklisted: bool, - - #[serde(default)] - #[schema(value_type = Vec)] - pub ip_addresses: Vec, -} - -impl GatewayBondAnnotated { - pub fn version(&self) -> &str { - &self.gateway_bond.gateway.version - } - - pub fn identity(&self) -> &String { - self.gateway_bond.bond.identity() - } - - 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)?, - 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, - }) - } - - pub fn try_to_semi_skimmed_node( - &self, - role: NodeRole, - ) -> Result { - let skimmed_node = self.try_to_skimmed_node(role)?; - Ok(SemiSkimmedNode { - basic: skimmed_node, - x25519_noise_versioned_key: None, // legacy node won't ever support Noise - }) - } -} - -#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, ToSchema)] -pub struct GatewayDescription { - // for now only expose what we need. this struct will evolve in the future (or be incorporated into nym-node properly) -} - -#[derive(Debug, Serialize, Deserialize, JsonSchema, ToSchema, IntoParams)] -pub struct ComputeRewardEstParam { - #[schema(value_type = Option)] - #[param(value_type = Option)] - pub performance: Option, - pub active_in_rewarded_set: Option, - pub pledge_amount: Option, - pub total_delegation: Option, - #[schema(value_type = Option)] - #[param(value_type = Option)] - pub interval_operating_cost: Option, - #[schema(value_type = Option)] - #[param(value_type = Option)] - pub profit_margin_percent: Option, -} - -#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))] -#[cfg_attr( - feature = "generate-ts", - ts( - export, - export_to = "ts-packages/types/src/types/rust/RewardEstimationResponse.ts" - ) -)] -#[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema, ToSchema)] -pub struct RewardEstimationResponse { - pub estimation: RewardEstimate, - pub reward_params: RewardingParams, - pub epoch: Interval, - #[cfg_attr(feature = "generate-ts", ts(type = "number"))] - pub as_at: i64, -} - -#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, ToSchema)] -pub struct UptimeResponse { - #[schema(value_type = u32)] - pub mix_id: NodeId, - // The same as node_performance.last_24h. Legacy - pub avg_uptime: u8, - pub node_performance: NodePerformance, -} - -#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, ToSchema)] -pub struct GatewayUptimeResponse { - pub identity: String, - // The same as node_performance.last_24h. Legacy - pub avg_uptime: u8, - pub node_performance: NodePerformance, -} - -#[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema, ToSchema)] -#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))] -#[cfg_attr( - feature = "generate-ts", - ts( - export, - export_to = "ts-packages/types/src/types/rust/StakeSaturationResponse.ts" - ) -)] -pub struct StakeSaturationResponse { - #[cfg_attr(feature = "generate-ts", ts(type = "string"))] - #[schema(value_type = String)] - pub saturation: StakeSaturation, - - #[cfg_attr(feature = "generate-ts", ts(type = "string"))] - #[schema(value_type = String)] - pub uncapped_saturation: StakeSaturation, - pub as_at: i64, -} - -pub type StakeSaturation = Decimal; - -#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, ToSchema)] -#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))] -#[cfg_attr( - feature = "generate-ts", - ts( - export, - export_to = "ts-packages/types/src/types/rust/SelectionChance.ts" - ) -)] -#[deprecated] -pub enum SelectionChance { - High, - Good, - Low, -} - -impl From for SelectionChance { - fn from(p: f64) -> SelectionChance { - match p { - p if p >= 0.7 => SelectionChance::High, - p if p >= 0.3 => SelectionChance::Good, - _ => SelectionChance::Low, - } - } -} - -impl From for SelectionChance { - fn from(p: Decimal) -> Self { - match p { - p if p >= Decimal::from_ratio(70u32, 100u32) => SelectionChance::High, - p if p >= Decimal::from_ratio(30u32, 100u32) => SelectionChance::Good, - _ => SelectionChance::Low, - } - } -} - -impl fmt::Display for SelectionChance { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - SelectionChance::High => write!(f, "High"), - SelectionChance::Good => write!(f, "Good"), - SelectionChance::Low => write!(f, "Low"), - } - } -} - -#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, ToSchema)] -#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))] -#[cfg_attr( - feature = "generate-ts", - ts( - export, - export_to = "ts-packages/types/src/types/rust/InclusionProbabilityResponse.ts" - ) -)] -#[deprecated] -pub struct InclusionProbabilityResponse { - pub in_active: SelectionChance, - pub in_reserve: SelectionChance, -} - -impl fmt::Display for InclusionProbabilityResponse { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!( - f, - "in_active: {}, in_reserve: {}", - self.in_active, self.in_reserve - ) - } -} - -#[deprecated] -#[derive(Clone, Serialize, schemars::JsonSchema, ToSchema)] -pub struct AllInclusionProbabilitiesResponse { - pub inclusion_probabilities: Vec, - pub samples: u64, - pub elapsed: Duration, - pub delta_max: f64, - pub delta_l2: f64, - pub as_at: i64, -} - -#[deprecated] -#[derive(Clone, Serialize, schemars::JsonSchema, ToSchema)] -pub struct InclusionProbability { - #[schema(value_type = u32)] - pub mix_id: NodeId, - pub in_active: f64, - pub in_reserve: f64, -} - -type Uptime = u8; - -#[derive(Clone, Serialize, Deserialize, schemars::JsonSchema, ToSchema)] -pub struct MixnodeStatusReportResponse { - pub mix_id: NodeId, - pub identity: IdentityKey, - pub owner: String, - #[schema(value_type = u8)] - pub most_recent: Uptime, - #[schema(value_type = u8)] - pub last_hour: Uptime, - #[schema(value_type = u8)] - pub last_day: Uptime, -} - -#[derive(Clone, Serialize, Deserialize, schemars::JsonSchema, ToSchema)] -pub struct GatewayStatusReportResponse { - pub identity: String, - pub owner: String, - #[schema(value_type = u8)] - pub most_recent: Uptime, - #[schema(value_type = u8)] - pub last_hour: Uptime, - #[schema(value_type = u8)] - pub last_day: Uptime, -} - -#[derive(Serialize, Deserialize, schemars::JsonSchema, ToSchema)] -#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))] -#[cfg_attr( - feature = "generate-ts", - ts( - export, - export_to = "ts-packages/types/src/types/rust/PerformanceHistoryResponse.ts" - ) -)] -pub struct PerformanceHistoryResponse { - #[schema(value_type = u32)] - pub node_id: NodeId, - pub history: PaginatedResponse, -} - -#[derive(Serialize, Deserialize, schemars::JsonSchema, ToSchema)] -#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))] -#[cfg_attr( - feature = "generate-ts", - ts( - export, - export_to = "ts-packages/types/src/types/rust/UptimeHistoryResponse.ts" - ) -)] -pub struct UptimeHistoryResponse { - #[schema(value_type = u32)] - pub node_id: NodeId, - pub history: PaginatedResponse, -} - -#[derive(Clone, Serialize, Deserialize, schemars::JsonSchema, ToSchema)] -#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))] -#[cfg_attr( - feature = "generate-ts", - ts( - export, - export_to = "ts-packages/types/src/types/rust/HistoricalUptimeResponse.ts" - ) -)] -pub struct HistoricalUptimeResponse { - #[schema(value_type = String, example = "1970-01-01")] - #[schemars(with = "String")] - #[cfg_attr(feature = "generate-ts", ts(type = "string"))] - pub date: Date, - - pub uptime: Uptime, -} - -#[derive(Clone, Serialize, Deserialize, schemars::JsonSchema, ToSchema)] -#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))] -#[cfg_attr( - feature = "generate-ts", - ts( - export, - export_to = "ts-packages/types/src/types/rust/HistoricalPerformanceResponse.ts" - ) -)] -pub struct HistoricalPerformanceResponse { - #[schema(value_type = String, example = "1970-01-01")] - #[schemars(with = "String")] - #[cfg_attr(feature = "generate-ts", ts(type = "string"))] - pub date: Date, - - pub performance: f64, -} - -#[derive(Clone, Serialize, Deserialize, schemars::JsonSchema, ToSchema)] -pub struct OldHistoricalUptimeResponse { - pub date: String, - #[schema(value_type = u8)] - pub uptime: Uptime, -} - -#[derive(Clone, Serialize, Deserialize, schemars::JsonSchema, ToSchema)] -pub struct MixnodeUptimeHistoryResponse { - pub mix_id: NodeId, - pub identity: String, - pub owner: String, - pub history: Vec, -} - -#[derive(Clone, Serialize, Deserialize, schemars::JsonSchema, ToSchema)] -pub struct GatewayUptimeHistoryResponse { - pub identity: String, - pub owner: String, - pub history: Vec, -} - -#[derive(ToSchema)] -#[schema(title = "Coin")] -pub struct CoinSchema { - pub denom: String, - #[schema(value_type = String)] - pub amount: Uint128, -} - -#[derive(Clone, Serialize, Deserialize, schemars::JsonSchema, ToSchema, ToResponse)] -pub struct CirculatingSupplyResponse { - #[schema(value_type = CoinSchema)] - pub total_supply: Coin, - #[schema(value_type = CoinSchema)] - pub mixmining_reserve: Coin, - #[schema(value_type = CoinSchema)] - pub vesting_tokens: Coin, - #[schema(value_type = CoinSchema)] - pub circulating_supply: Coin, -} - -#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, ToSchema)] -pub struct HostInformation { - #[schema(value_type = Vec)] - pub ip_address: Vec, - pub hostname: Option, - pub keys: HostKeys, -} - -impl From for HostInformation { - fn from(value: nym_node_requests::api::v1::node::models::HostInformation) -> Self { - HostInformation { - ip_address: value.ip_address, - hostname: value.hostname, - keys: value.keys.into(), - } - } -} - -#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, ToSchema)] -pub struct HostKeys { - #[serde(with = "bs58_ed25519_pubkey")] - #[schemars(with = "String")] - #[schema(value_type = String)] - pub ed25519: ed25519::PublicKey, - - #[deprecated(note = "use the current_x25519_sphinx_key with explicit rotation information")] - #[serde(with = "bs58_x25519_pubkey")] - #[schemars(with = "String")] - #[schema(value_type = String)] - pub x25519: x25519::PublicKey, - - pub current_x25519_sphinx_key: SphinxKey, - - #[serde(default)] - pub pre_announced_x25519_sphinx_key: Option, - - #[serde(default)] - pub x25519_versioned_noise: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, ToSchema)] -pub struct SphinxKey { - pub rotation_id: u32, - - #[serde(with = "bs58_x25519_pubkey")] - #[schemars(with = "String")] - #[schema(value_type = String)] - pub public_key: x25519::PublicKey, -} - -impl From for SphinxKey { - fn from(value: nym_node_requests::api::v1::node::models::SphinxKey) -> Self { - SphinxKey { - rotation_id: value.rotation_id, - public_key: value.public_key, - } - } -} - -impl From for HostKeys { - fn from(value: nym_node_requests::api::v1::node::models::HostKeys) -> Self { - HostKeys { - ed25519: value.ed25519_identity, - x25519: value.x25519_sphinx, - current_x25519_sphinx_key: value.primary_x25519_sphinx_key.into(), - pre_announced_x25519_sphinx_key: value.pre_announced_x25519_sphinx_key.map(Into::into), - x25519_versioned_noise: value.x25519_versioned_noise, - } - } -} - -#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, ToSchema)] -pub struct WebSockets { - pub ws_port: u16, - - pub wss_port: Option, -} - -impl From for WebSockets { - fn from(value: nym_node_requests::api::v1::gateway::models::WebSockets) -> Self { - WebSockets { - ws_port: value.ws_port, - wss_port: value.wss_port, - } - } -} - -pub fn de_rfc3339_or_default<'de, D>(deserializer: D) -> Result -where - D: Deserializer<'de>, -{ - Ok(time::serde::rfc3339::deserialize(deserializer).unwrap_or_else(|_| unix_epoch())) -} - -// for all intents and purposes it's just OffsetDateTime, but we need JsonSchema... -#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, ToSchema)] -pub struct OffsetDateTimeJsonSchemaWrapper( - #[serde( - default = "unix_epoch", - with = "crate::helpers::overengineered_offset_date_time_serde" - )] - #[schema(inline)] - pub OffsetDateTime, -); - -impl Default for OffsetDateTimeJsonSchemaWrapper { - fn default() -> Self { - OffsetDateTimeJsonSchemaWrapper(unix_epoch()) - } -} - -impl Display for OffsetDateTimeJsonSchemaWrapper { - fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - Display::fmt(&self.0, f) - } -} - -impl From for OffsetDateTime { - fn from(value: OffsetDateTimeJsonSchemaWrapper) -> Self { - value.0 - } -} - -impl From for OffsetDateTimeJsonSchemaWrapper { - fn from(value: OffsetDateTime) -> Self { - OffsetDateTimeJsonSchemaWrapper(value) - } -} - -impl Deref for OffsetDateTimeJsonSchemaWrapper { - type Target = OffsetDateTime; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -impl DerefMut for OffsetDateTimeJsonSchemaWrapper { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.0 - } -} - -// implementation taken from: https://github.com/GREsau/schemars/pull/207 -impl JsonSchema for OffsetDateTimeJsonSchemaWrapper { - fn is_referenceable() -> bool { - false - } - - fn schema_name() -> String { - "DateTime".into() - } - - fn json_schema(_: &mut SchemaGenerator) -> Schema { - SchemaObject { - instance_type: Some(InstanceType::String.into()), - format: Some("date-time".into()), - ..Default::default() - } - .into() - } -} - -#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, ToSchema)] -pub struct NymNodeDescription { - #[schema(value_type = u32)] - pub node_id: NodeId, - pub contract_node_type: DescribedNodeType, - pub description: NymNodeData, -} - -impl NymNodeDescription { - pub fn version(&self) -> &str { - &self.description.build_information.build_version - } - - pub fn entry_information(&self) -> BasicEntryInformation { - BasicEntryInformation { - hostname: self.description.host_information.hostname.clone(), - ws_port: self.description.mixnet_websockets.ws_port, - wss_port: self.description.mixnet_websockets.wss_port, - } - } - - pub fn ed25519_identity_key(&self) -> ed25519::PublicKey { - self.description.host_information.keys.ed25519 - } - - pub fn current_sphinx_key(&self, current_rotation_id: u32) -> x25519::PublicKey { - let keys = &self.description.host_information.keys; - - if keys.current_x25519_sphinx_key.rotation_id == u32::MAX { - // legacy case (i.e. node doesn't support rotation) - return keys.current_x25519_sphinx_key.public_key; - } - - if current_rotation_id == keys.current_x25519_sphinx_key.rotation_id { - // it's the 'current' key - return keys.current_x25519_sphinx_key.public_key; - } - - if let Some(pre_announced) = &keys.pre_announced_x25519_sphinx_key { - if pre_announced.rotation_id == current_rotation_id { - return pre_announced.public_key; - } - } - - warn!( - "unexpected key rotation {current_rotation_id} for node {}", - self.node_id - ); - // this should never be reached, but just in case, return the fallback option - keys.current_x25519_sphinx_key.public_key - } - - pub fn to_skimmed_node( - &self, - current_rotation_id: u32, - role: NodeRole, - performance: Performance, - ) -> SkimmedNode { - let keys = &self.description.host_information.keys; - let entry = if self.description.declared_role.entry { - Some(self.entry_information()) - } else { - None - }; - - SkimmedNode { - node_id: self.node_id, - ed25519_identity_pubkey: keys.ed25519, - ip_addresses: self.description.host_information.ip_address.clone(), - mix_port: self.description.mix_port(), - x25519_sphinx_pubkey: self.current_sphinx_key(current_rotation_id), - // 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. - role, - supported_roles: self.description.declared_role, - entry, - performance, - } - } - - pub fn to_semi_skimmed_node( - &self, - current_rotation_id: u32, - role: NodeRole, - performance: Performance, - ) -> SemiSkimmedNode { - let skimmed_node = self.to_skimmed_node(current_rotation_id, role, performance); - - SemiSkimmedNode { - basic: skimmed_node, - x25519_noise_versioned_key: self - .description - .host_information - .keys - .x25519_versioned_noise, - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, schemars::JsonSchema, ToSchema)] -#[serde(rename_all = "snake_case")] -#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))] -#[cfg_attr( - feature = "generate-ts", - ts( - export, - export_to = "ts-packages/types/src/types/rust/DescribedNodeType.ts" - ) -)] -pub enum DescribedNodeType { - LegacyMixnode, - LegacyGateway, - NymNode, -} - -impl DescribedNodeType { - pub fn is_nym_node(&self) -> bool { - matches!(self, DescribedNodeType::NymNode) - } -} - -#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, schemars::JsonSchema, ToSchema)] -#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))] -#[cfg_attr( - feature = "generate-ts", - ts( - export, - export_to = "ts-packages/types/src/types/rust/DeclaredRoles.ts" - ) -)] -pub struct DeclaredRoles { - pub mixnode: bool, - pub entry: bool, - pub exit_nr: bool, - pub exit_ipr: bool, -} - -impl DeclaredRoles { - pub fn can_operate_exit_gateway(&self) -> bool { - self.exit_ipr && self.exit_nr - } -} - -impl From for DeclaredRoles { - fn from(value: NodeRoles) -> Self { - DeclaredRoles { - mixnode: value.mixnode_enabled, - entry: value.gateway_enabled, - exit_nr: value.gateway_enabled && value.network_requester_enabled, - exit_ipr: value.gateway_enabled && value.ip_packet_router_enabled, - } - } -} - -// this struct is getting quite bloated... -#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, ToSchema)] -pub struct NymNodeData { - #[serde(default)] - pub last_polled: OffsetDateTimeJsonSchemaWrapper, - - pub host_information: HostInformation, - - #[serde(default)] - pub declared_role: DeclaredRoles, - - #[serde(default)] - pub auxiliary_details: AuxiliaryDetails, - - // TODO: do we really care about ALL build info or just the version? - pub build_information: BinaryBuildInformationOwned, - - #[serde(default)] - pub network_requester: Option, - - #[serde(default)] - pub ip_packet_router: Option, - - #[serde(default)] - pub authenticator: Option, - - #[serde(default)] - pub wireguard: Option, - - // for now we only care about their ws/wss situation, nothing more - pub mixnet_websockets: WebSockets, -} - -impl NymNodeData { - pub fn mix_port(&self) -> u16 { - self.auxiliary_details - .announce_ports - .mix_port - .unwrap_or(DEFAULT_MIX_LISTENING_PORT) - } - - pub fn verloc_port(&self) -> u16 { - self.auxiliary_details - .announce_ports - .verloc_port - .unwrap_or(DEFAULT_VERLOC_LISTENING_PORT) - } -} - -#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, ToSchema)] -pub struct LegacyDescribedGateway { - pub bond: GatewayBond, - pub self_described: Option, -} - -impl From for LegacyDescribedGateway { - fn from(bond: LegacyGatewayBondWithId) -> Self { - LegacyDescribedGateway { - bond: bond.bond, - self_described: None, - } - } -} - -#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, ToSchema)] -pub struct LegacyDescribedMixNode { - pub bond: LegacyMixNodeBondWithLayer, - pub self_described: Option, -} - -impl From for LegacyDescribedMixNode { - fn from(bond: LegacyMixNodeBondWithLayer) -> Self { - LegacyDescribedMixNode { - bond, - self_described: None, - } - } -} - -#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, ToSchema)] -pub struct NetworkRequesterDetails { - /// address of the embedded network requester - pub address: String, - - /// flag indicating whether this network requester uses the exit policy rather than the deprecated allow list - pub uses_exit_policy: bool, -} - -#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, ToSchema)] -pub struct IpPacketRouterDetails { - /// address of the embedded ip packet router - pub address: String, -} - -// works for current simple case. -impl From for IpPacketRouterDetails { - fn from(value: IpPacketRouter) -> Self { - IpPacketRouterDetails { - address: value.address, - } - } -} - -#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, ToSchema)] -pub struct AuthenticatorDetails { - /// address of the embedded authenticator - pub address: String, -} - -// works for current simple case. -impl From for AuthenticatorDetails { - fn from(value: Authenticator) -> Self { - AuthenticatorDetails { - address: value.address, - } - } -} -#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, ToSchema)] -pub struct WireguardDetails { - pub port: u16, - pub public_key: String, -} - -// works for current simple case. -impl From for WireguardDetails { - fn from(value: Wireguard) -> Self { - WireguardDetails { - port: value.port, - public_key: value.public_key, - } - } -} - -#[derive(Clone, Copy, Debug, Serialize, Deserialize, schemars::JsonSchema, ToSchema)] -pub struct ApiHealthResponse { - pub status: ApiStatus, - #[serde(default)] - pub chain_status: ChainStatus, - pub uptime: u64, -} - -#[derive(Clone, Copy, Debug, Serialize, Deserialize, schemars::JsonSchema, ToSchema)] -#[serde(rename_all = "lowercase")] -pub enum ApiStatus { - Up, -} - -#[derive(Clone, Copy, Debug, Serialize, Deserialize, Default, schemars::JsonSchema, ToSchema)] -#[serde(rename_all = "snake_case")] -pub enum ChainStatus { - Synced, - #[default] - Unknown, - Stalled { - #[serde( - serialize_with = "humantime_serde::serialize", - deserialize_with = "humantime_serde::deserialize" - )] - approximate_amount: Duration, - }, -} - -impl ChainStatus { - pub fn is_synced(&self) -> bool { - matches!(self, ChainStatus::Synced) - } -} - -impl ApiHealthResponse { - pub fn new_healthy(uptime: Duration) -> Self { - ApiHealthResponse { - status: ApiStatus::Up, - chain_status: ChainStatus::Synced, - uptime: uptime.as_secs(), - } - } -} - -impl ApiStatus { - pub fn is_up(&self) -> bool { - matches!(self, ApiStatus::Up) - } -} - -#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, ToSchema)] -pub struct SignerInformationResponse { - pub cosmos_address: String, - - pub identity: String, - - pub announce_address: String, - - pub verification_key: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, Default, ToSchema)] -pub struct TestNode { - pub node_id: Option, - pub identity_key: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, ToSchema)] -pub struct TestRoute { - pub gateway: TestNode, - pub layer1: TestNode, - pub layer2: TestNode, - pub layer3: TestNode, -} - -#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, ToSchema)] -pub struct PartialTestResult { - pub monitor_run_id: i64, - pub timestamp: i64, - pub overall_reliability_for_all_routes_in_monitor_run: Option, - pub test_routes: TestRoute, -} - -pub type MixnodeTestResultResponse = PaginatedResponse; -pub type GatewayTestResultResponse = PaginatedResponse; - -#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, ToSchema)] -pub struct NetworkMonitorRunDetailsResponse { - pub monitor_run_id: i64, - pub network_reliability: f64, - pub total_sent: usize, - pub total_received: usize, - - // integer score to number of nodes with that score - pub mixnode_results: BTreeMap, - pub gateway_results: BTreeMap, -} - -#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, ToSchema)] -pub struct NoiseDetails { - pub key: VersionedNoiseKey, - - pub mixnet_port: u16, - - #[schema(value_type = Vec)] - pub ip_addresses: Vec, -} - -#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, ToSchema)] -pub struct NodeRefreshBody { - #[serde(with = "bs58_ed25519_pubkey")] - #[schemars(with = "String")] - #[schema(value_type = String)] - pub node_identity: ed25519::PublicKey, - - // a poor man's nonce - pub request_timestamp: i64, - - #[schemars(with = "PlaceholderJsonSchemaImpl")] - #[schema(value_type = String)] - pub signature: ed25519::Signature, -} - -impl NodeRefreshBody { - pub fn plaintext(node_identity: ed25519::PublicKey, request_timestamp: i64) -> Vec { - node_identity - .to_bytes() - .into_iter() - .chain(request_timestamp.to_be_bytes()) - .chain(b"describe-cache-refresh-request".iter().copied()) - .collect() - } - - pub fn new(private_key: &ed25519::PrivateKey) -> Self { - let node_identity = private_key.public_key(); - let request_timestamp = OffsetDateTime::now_utc().unix_timestamp(); - let signature = private_key.sign(Self::plaintext(node_identity, request_timestamp)); - NodeRefreshBody { - node_identity, - request_timestamp, - signature, - } - } - - pub fn verify_signature(&self) -> bool { - self.node_identity - .verify( - Self::plaintext(self.node_identity, self.request_timestamp), - &self.signature, - ) - .is_ok() - } - - pub fn is_stale(&self) -> bool { - let Ok(encoded) = OffsetDateTime::from_unix_timestamp(self.request_timestamp) else { - return true; - }; - let now = OffsetDateTime::now_utc(); - - if encoded > now { - return true; - } - - if (encoded + Duration::from_secs(30)) < now { - return true; - } - - false - } -} - -#[derive(Clone, Copy, Debug, Serialize, Deserialize, schemars::JsonSchema, ToSchema)] -pub struct KeyRotationInfoResponse { - #[serde(flatten)] - pub details: KeyRotationDetails, - - // helper field that holds calculated data based on the `details` field - // this is to expose the information in a format more easily accessible by humans - // without having to do any calculations - pub progress: KeyRotationProgressInfo, -} - -impl From for KeyRotationInfoResponse { - fn from(details: KeyRotationDetails) -> Self { - KeyRotationInfoResponse { - details, - progress: KeyRotationProgressInfo { - current_key_rotation_id: details.current_key_rotation_id(), - current_rotation_starting_epoch: details.current_rotation_starting_epoch_id(), - current_rotation_ending_epoch: details.current_rotation_starting_epoch_id() - + details.key_rotation_state.validity_epochs - - 1, - }, - } - } -} - -#[derive(Clone, Copy, Debug, Serialize, Deserialize, schemars::JsonSchema, ToSchema)] -pub struct KeyRotationProgressInfo { - pub current_key_rotation_id: u32, - - pub current_rotation_starting_epoch: u32, - - pub current_rotation_ending_epoch: u32, -} - -#[derive(Clone, Copy, Debug, Serialize, Deserialize, schemars::JsonSchema, ToSchema)] -pub struct KeyRotationDetails { - pub key_rotation_state: KeyRotationState, - - #[schema(value_type = u32)] - pub current_absolute_epoch_id: EpochId, - - #[serde(with = "time::serde::rfc3339")] - #[schemars(with = "String")] - #[schema(value_type = String)] - pub current_epoch_start: OffsetDateTime, - - pub epoch_duration: Duration, -} - -impl KeyRotationDetails { - pub fn current_key_rotation_id(&self) -> u32 { - self.key_rotation_state - .key_rotation_id(self.current_absolute_epoch_id) - } - - pub fn next_rotation_starting_epoch_id(&self) -> EpochId { - self.key_rotation_state - .next_rotation_starting_epoch_id(self.current_absolute_epoch_id) - } - - pub fn current_rotation_starting_epoch_id(&self) -> EpochId { - self.key_rotation_state - .current_rotation_starting_epoch_id(self.current_absolute_epoch_id) - } - - fn current_epoch_progress(&self, now: OffsetDateTime) -> f32 { - let elapsed = (now - self.current_epoch_start).as_seconds_f32(); - elapsed / self.epoch_duration.as_secs_f32() - } - - pub fn is_epoch_stuck(&self) -> bool { - let now = OffsetDateTime::now_utc(); - let progress = self.current_epoch_progress(now); - if progress > 1. { - let into_next = 1. - progress; - // if epoch hasn't progressed for more than 20% of its duration, mark is as stuck - if into_next > 0.2 { - let diff_time = - Duration::from_secs_f32(into_next * self.epoch_duration.as_secs_f32()); - let expected_epoch_end = self.current_epoch_start + self.epoch_duration; - warn!("the current epoch is expected to have been over by {expected_epoch_end}. it's already {} overdue!", humantime_serde::re::humantime::format_duration(diff_time)); - return true; - } - } - - false - } - - // based on the current **TIME**, determine what's the expected current rotation id - pub fn expected_current_rotation_id(&self) -> KeyRotationId { - let now = OffsetDateTime::now_utc(); - let current_end = now + self.epoch_duration; - if now < current_end { - return self - .key_rotation_state - .key_rotation_id(self.current_absolute_epoch_id); - } - - let diff = now - current_end; - let passed_epochs = diff / self.epoch_duration; - let expected_current_epoch = self.current_absolute_epoch_id + passed_epochs.floor() as u32; - - self.key_rotation_state - .key_rotation_id(expected_current_epoch) - } - - pub fn until_next_rotation(&self) -> Option { - let current_epoch_progress = self.current_epoch_progress(OffsetDateTime::now_utc()); - if current_epoch_progress > 1. { - return None; - } - - let next_rotation_epoch = self.next_rotation_starting_epoch_id(); - let full_remaining = - (next_rotation_epoch - self.current_absolute_epoch_id).checked_add(1)?; - - let epochs_until_next_rotation = (1. - current_epoch_progress) + full_remaining as f32; - - Some(Duration::from_secs_f32( - epochs_until_next_rotation * self.epoch_duration.as_secs_f32(), - )) - } - - pub fn epoch_start_time(&self, absolute_epoch_id: EpochId) -> OffsetDateTime { - match absolute_epoch_id.cmp(&self.current_absolute_epoch_id) { - Ordering::Less => { - let diff = self.current_absolute_epoch_id - absolute_epoch_id; - self.current_epoch_start - diff * self.epoch_duration - } - Ordering::Equal => self.current_epoch_start, - Ordering::Greater => { - let diff = absolute_epoch_id - self.current_absolute_epoch_id; - self.current_epoch_start + diff * self.epoch_duration - } - } - } -} - -#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, ToSchema)] -pub struct RewardedSetResponse { - #[serde(default)] - #[schema(value_type = u32)] - pub epoch_id: EpochId, - - pub entry_gateways: Vec, - - pub exit_gateways: Vec, - - pub layer1: Vec, - - pub layer2: Vec, - - pub layer3: Vec, - - pub standby: Vec, -} - -impl From for nym_mixnet_contract_common::EpochRewardedSet { - fn from(res: RewardedSetResponse) -> Self { - nym_mixnet_contract_common::EpochRewardedSet { - epoch_id: res.epoch_id, - assignment: nym_mixnet_contract_common::RewardedSet { - entry_gateways: res.entry_gateways, - exit_gateways: res.exit_gateways, - layer1: res.layer1, - layer2: res.layer2, - layer3: res.layer3, - standby: res.standby, - }, - } - } -} - -impl From for RewardedSetResponse { - fn from(r: nym_mixnet_contract_common::EpochRewardedSet) -> Self { - RewardedSetResponse { - epoch_id: r.epoch_id, - entry_gateways: r.assignment.entry_gateways, - exit_gateways: r.assignment.exit_gateways, - layer1: r.assignment.layer1, - layer2: r.assignment.layer2, - layer3: r.assignment.layer3, - standby: r.assignment.standby, - } - } -} - -pub type ChainBlocksStatusResponse = SignedMessage; - -#[derive(Serialize, Deserialize, ToSchema, Clone, Debug)] -#[serde(rename_all = "camelCase")] -pub struct ChainBlocksStatusResponseBody { - #[serde(with = "time::serde::rfc3339")] - #[schema(value_type = String)] - pub current_time: OffsetDateTime, - - pub latest_cached_block: Option, - - // explicit indication of THIS signer whether it thinks the chain is stalled - pub chain_status: ChainStatus, -} - -#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, ToSchema)] -pub struct ChainStatusResponse { - pub connected_nyxd: String, - pub status: DetailedChainStatus, -} - -#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, ToSchema)] -pub struct DetailedChainStatus { - pub abci: crate::models::tendermint_types::AbciInfo, - pub latest_block: BlockInfo, -} - -impl DetailedChainStatus { - pub fn stall_status(&self, now: OffsetDateTime, threshold: Duration) -> ChainStatus { - let block_time: OffsetDateTime = self.latest_block.block.header.time.into(); - let diff = now - block_time; - if diff > threshold { - ChainStatus::Stalled { - approximate_amount: diff.unsigned_abs(), - } - } else { - ChainStatus::Synced - } - } -} - -#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, ToSchema)] -pub struct BlockInfo { - pub block_id: BlockId, - pub block: FullBlockInfo, - // if necessary we might put block data here later too -} - -impl From for BlockInfo { - fn from(value: tendermint_rpc::endpoint::block::Response) -> Self { - BlockInfo { - block_id: value.block_id.into(), - block: FullBlockInfo { - header: value.block.header.into(), - }, - } - } -} - -#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, ToSchema)] -pub struct FullBlockInfo { - pub header: BlockHeader, -} - -// copy tendermint types definitions whilst deriving schema types on them and dropping unwanted fields -pub mod tendermint_types { - use schemars::JsonSchema; - use serde::{Deserialize, Serialize}; - use tendermint::abci::response::Info; - use tendermint::block::header::Version; - use tendermint::{block, Hash}; - use utoipa::ToSchema; - - #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, ToSchema)] - pub struct AbciInfo { - /// Some arbitrary information. - pub data: String, - - /// The application software semantic version. - pub version: String, - - /// The application protocol version. - pub app_version: u64, - - /// The latest block for which the app has called [`Commit`]. - pub last_block_height: u64, - - /// The latest result of [`Commit`]. - pub last_block_app_hash: String, - } - - impl From for AbciInfo { - fn from(value: Info) -> Self { - AbciInfo { - data: value.data, - version: value.version, - app_version: value.app_version, - last_block_height: value.last_block_height.value(), - last_block_app_hash: value.last_block_app_hash.to_string(), - } - } - } - - /// `Version` contains the protocol version for the blockchain and the - /// application. - /// - /// - #[derive( - Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema, ToSchema, - )] - pub struct HeaderVersion { - /// Block version - pub block: u64, - - /// App version - pub app: u64, - } - - impl From for HeaderVersion { - fn from(value: Version) -> Self { - HeaderVersion { - block: value.block, - app: value.app, - } - } - } - - /// Block identifiers which contain two distinct Merkle roots of the block, - /// as well as the number of parts in the block. - /// - /// - /// - /// Default implementation is an empty Id as defined by the Go implementation in - /// . - /// - /// If the Hash is empty in BlockId, the BlockId should be empty (encoded to None). - /// This is implemented outside of this struct. Use the Default trait to check for an empty BlockId. - /// See: - #[derive( - Serialize, - Deserialize, - Copy, - Clone, - Debug, - Default, - Hash, - Eq, - PartialEq, - PartialOrd, - Ord, - JsonSchema, - ToSchema, - )] - pub struct BlockId { - /// The block's main hash is the Merkle root of all the fields in the - /// block header. - #[schemars(with = "String")] - #[schema(value_type = String)] - pub hash: Hash, - - /// Parts header (if available) is used for secure gossipping of the block - /// during consensus. It is the Merkle root of the complete serialized block - /// cut into parts. - /// - /// PartSet is used to split a byteslice of data into parts (pieces) for - /// transmission. By splitting data into smaller parts and computing a - /// Merkle root hash on the list, you can verify that a part is - /// legitimately part of the complete data, and the part can be forwarded - /// to other peers before all the parts are known. In short, it's a fast - /// way to propagate a large file over a gossip network. - /// - /// - /// - /// PartSetHeader in protobuf is defined as never nil using the gogoproto - /// annotations. This does not translate to Rust, but we can indicate this - /// in the domain type. - pub part_set_header: PartSetHeader, - } - - impl From for BlockId { - fn from(value: block::Id) -> Self { - BlockId { - hash: value.hash, - part_set_header: value.part_set_header.into(), - } - } - } - - /// Block parts header - #[derive( - Clone, - Copy, - Debug, - Default, - Hash, - Eq, - PartialEq, - PartialOrd, - Ord, - Deserialize, - Serialize, - JsonSchema, - ToSchema, - )] - #[non_exhaustive] - pub struct PartSetHeader { - /// Number of parts in this block - pub total: u32, - - /// Hash of the parts set header, - #[schemars(with = "String")] - #[schema(value_type = String)] - pub hash: Hash, - } - - impl From for PartSetHeader { - fn from(value: block::parts::Header) -> Self { - PartSetHeader { - total: value.total, - hash: value.hash, - } - } - } - - /// Block `Header` values contain metadata about the block and about the - /// consensus, as well as commitments to the data in the current block, the - /// previous block, and the results returned by the application. - /// - /// - #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)] - pub struct BlockHeader { - /// Header version - pub version: HeaderVersion, - - /// Chain ID - pub chain_id: String, - - /// Current block height - pub height: u64, - - /// Current timestamp - #[schemars(with = "String")] - #[schema(value_type = String)] - pub time: tendermint::Time, - - /// Previous block info - pub last_block_id: Option, - - /// Commit from validators from the last block - #[schemars(with = "Option")] - #[schema(value_type = Option)] - pub last_commit_hash: Option, - - /// Merkle root of transaction hashes - #[schemars(with = "Option")] - #[schema(value_type = Option)] - pub data_hash: Option, - - /// Validators for the current block - #[schemars(with = "String")] - #[schema(value_type = String)] - pub validators_hash: Hash, - - /// Validators for the next block - #[schemars(with = "String")] - #[schema(value_type = String)] - pub next_validators_hash: Hash, - - /// Consensus params for the current block - #[schemars(with = "String")] - #[schema(value_type = String)] - pub consensus_hash: Hash, - - /// State after txs from the previous block - #[schemars(with = "String")] - #[schema(value_type = String)] - pub app_hash: Hash, - - /// Root hash of all results from the txs from the previous block - #[schemars(with = "Option")] - #[schema(value_type = Option)] - pub last_results_hash: Option, - - /// Hash of evidence included in the block - #[schemars(with = "Option")] - #[schema(value_type = Option)] - pub evidence_hash: Option, - - /// Original proposer of the block - #[serde(with = "nym_serde_helpers::hex")] - #[schemars(with = "String")] - #[schema(value_type = String)] - pub proposer_address: Vec, - } - - impl From for BlockHeader { - fn from(value: block::Header) -> Self { - BlockHeader { - version: value.version.into(), - chain_id: value.chain_id.to_string(), - height: value.height.value(), - time: value.time, - last_block_id: value.last_block_id.map(Into::into), - last_commit_hash: value.last_commit_hash, - data_hash: value.data_hash, - validators_hash: value.validators_hash, - next_validators_hash: value.next_validators_hash, - consensus_hash: value.consensus_hash, - app_hash: Hash::try_from(value.app_hash.as_bytes().to_vec()).unwrap_or_default(), - last_results_hash: value.last_results_hash, - evidence_hash: value.evidence_hash, - proposer_address: value.proposer_address.as_bytes().to_vec(), - } - } - } -} - -pub use config_score::*; - -pub mod config_score { - use nym_contracts_common::NaiveFloat; - use serde::{Deserialize, Serialize}; - use std::cmp::Ordering; - use utoipa::ToSchema; - - #[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, ToSchema)] - pub struct ConfigScoreDataResponse { - pub parameters: ConfigScoreParams, - pub version_history: Vec, - } - - #[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, ToSchema, PartialEq)] - pub struct HistoricalNymNodeVersionEntry { - /// The unique, ordered, id of this particular entry - pub id: u32, - - /// Data associated with this particular version - pub version_information: HistoricalNymNodeVersion, - } - - impl PartialOrd for HistoricalNymNodeVersionEntry { - fn partial_cmp(&self, other: &Self) -> Option { - // we only care about id for the purposes of ordering as they should have unique data - self.id.partial_cmp(&other.id) - } - } - - impl From - for HistoricalNymNodeVersionEntry - { - fn from(value: nym_mixnet_contract_common::HistoricalNymNodeVersionEntry) -> Self { - HistoricalNymNodeVersionEntry { - id: value.id, - version_information: value.version_information.into(), - } - } - } - - #[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, ToSchema, PartialEq)] - pub struct HistoricalNymNodeVersion { - /// Version of the nym node that is going to be used for determining the version score of a node. - /// note: value stored here is pre-validated `semver::Version` - pub semver: String, - - /// Block height of when this version has been added to the contract - pub introduced_at_height: u64, - // for now ignore that field. it will give nothing useful to the users - // pub difference_since_genesis: TotalVersionDifference, - } - - impl From for HistoricalNymNodeVersion { - fn from(value: nym_mixnet_contract_common::HistoricalNymNodeVersion) -> Self { - HistoricalNymNodeVersion { - semver: value.semver, - introduced_at_height: value.introduced_at_height, - } - } - } - - #[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, ToSchema)] - pub struct ConfigScoreParams { - /// Defines weights for calculating numbers of versions behind the current release. - pub version_weights: OutdatedVersionWeights, - - /// Defines the parameters of the formula for calculating the version score - pub version_score_formula_params: VersionScoreFormulaParams, - } - - /// Defines weights for calculating numbers of versions behind the current release. - #[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, ToSchema)] - pub struct OutdatedVersionWeights { - pub major: u32, - pub minor: u32, - pub patch: u32, - pub prerelease: u32, - } - - /// Given the formula of version_score = penalty ^ (versions_behind_factor ^ penalty_scaling) - /// define the relevant parameters - #[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, ToSchema)] - pub struct VersionScoreFormulaParams { - pub penalty: f64, - pub penalty_scaling: f64, - } - - impl From for ConfigScoreParams { - fn from(value: nym_mixnet_contract_common::ConfigScoreParams) -> Self { - ConfigScoreParams { - version_weights: value.version_weights.into(), - version_score_formula_params: value.version_score_formula_params.into(), - } - } - } - - impl From for OutdatedVersionWeights { - fn from(value: nym_mixnet_contract_common::OutdatedVersionWeights) -> Self { - OutdatedVersionWeights { - major: value.major, - minor: value.minor, - patch: value.patch, - prerelease: value.prerelease, - } - } - } - - impl From for VersionScoreFormulaParams { - fn from(value: nym_mixnet_contract_common::VersionScoreFormulaParams) -> Self { - VersionScoreFormulaParams { - penalty: value.penalty.naive_to_f64(), - penalty_scaling: value.penalty_scaling.naive_to_f64(), - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn offset_date_time_json_schema_wrapper_serde_backwards_compat() { - let mut dummy = OffsetDateTimeJsonSchemaWrapper::default(); - dummy.0 += Duration::from_millis(1); - let ser = serde_json::to_string(&dummy).unwrap(); - - assert_eq!("\"1970-01-01 00:00:00.001 +00:00:00\"", ser); - - let human_readable = "\"2024-05-23 07:41:02.756283766 +00:00:00\""; - let rfc3339 = "\"2002-10-02T15:00:00Z\""; - let rfc3339_offset = "\"2002-10-02T10:00:00-05:00\""; - - let de = serde_json::from_str::(human_readable).unwrap(); - assert_eq!(de.0.unix_timestamp(), 1716450062); - - let de = serde_json::from_str::(rfc3339).unwrap(); - assert_eq!(de.0.unix_timestamp(), 1033570800); - - let de = serde_json::from_str::(rfc3339_offset).unwrap(); - assert_eq!(de.0.unix_timestamp(), 1033570800); - - let de = serde_json::from_str::("\"nonsense\"").unwrap(); - assert_eq!(de.0.unix_timestamp(), 0); - } -} diff --git a/nym-api/nym-api-requests/src/models/api_status.rs b/nym-api/nym-api-requests/src/models/api_status.rs new file mode 100644 index 00000000000..c10b9e3bae1 --- /dev/null +++ b/nym-api/nym-api-requests/src/models/api_status.rs @@ -0,0 +1,68 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use serde::{Deserialize, Serialize}; +use std::time::Duration; +use utoipa::ToSchema; + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, schemars::JsonSchema, ToSchema)] +pub struct ApiHealthResponse { + pub status: ApiStatus, + #[serde(default)] + pub chain_status: ChainStatus, + pub uptime: u64, +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, schemars::JsonSchema, ToSchema)] +#[serde(rename_all = "lowercase")] +pub enum ApiStatus { + Up, +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, Default, schemars::JsonSchema, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum ChainStatus { + Synced, + #[default] + Unknown, + Stalled { + #[serde( + serialize_with = "humantime_serde::serialize", + deserialize_with = "humantime_serde::deserialize" + )] + approximate_amount: Duration, + }, +} + +impl ChainStatus { + pub fn is_synced(&self) -> bool { + matches!(self, ChainStatus::Synced) + } +} + +impl ApiHealthResponse { + pub fn new_healthy(uptime: Duration) -> Self { + ApiHealthResponse { + status: ApiStatus::Up, + chain_status: ChainStatus::Synced, + uptime: uptime.as_secs(), + } + } +} + +impl ApiStatus { + pub fn is_up(&self) -> bool { + matches!(self, ApiStatus::Up) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, ToSchema)] +pub struct SignerInformationResponse { + pub cosmos_address: String, + + pub identity: String, + + pub announce_address: String, + + pub verification_key: Option, +} diff --git a/nym-api/nym-api-requests/src/models/circulating_supply.rs b/nym-api/nym-api-requests/src/models/circulating_supply.rs new file mode 100644 index 00000000000..f191f0ccd20 --- /dev/null +++ b/nym-api/nym-api-requests/src/models/circulating_supply.rs @@ -0,0 +1,19 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use super::CoinSchema; +use cosmwasm_std::Coin; +use serde::{Deserialize, Serialize}; +use utoipa::{ToResponse, ToSchema}; + +#[derive(Clone, Serialize, Deserialize, schemars::JsonSchema, ToSchema, ToResponse)] +pub struct CirculatingSupplyResponse { + #[schema(value_type = CoinSchema)] + pub total_supply: Coin, + #[schema(value_type = CoinSchema)] + pub mixmining_reserve: Coin, + #[schema(value_type = CoinSchema)] + pub vesting_tokens: Coin, + #[schema(value_type = CoinSchema)] + pub circulating_supply: Coin, +} diff --git a/nym-api/nym-api-requests/src/models/described.rs b/nym-api/nym-api-requests/src/models/described.rs new file mode 100644 index 00000000000..666cca19966 --- /dev/null +++ b/nym-api/nym-api-requests/src/models/described.rs @@ -0,0 +1,375 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::models::{BinaryBuildInformationOwned, OffsetDateTimeJsonSchemaWrapper}; +use crate::nym_nodes::{BasicEntryInformation, NodeRole, SemiSkimmedNode, SkimmedNode}; +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}; +use nym_mixnet_contract_common::reward_params::Performance; +use nym_mixnet_contract_common::NodeId; +use nym_network_defaults::{DEFAULT_MIX_LISTENING_PORT, DEFAULT_VERLOC_LISTENING_PORT}; +use nym_node_requests::api::v1::authenticator::models::Authenticator; +use nym_node_requests::api::v1::gateway::models::Wireguard; +use nym_node_requests::api::v1::ip_packet_router::models::IpPacketRouter; +use nym_node_requests::api::v1::node::models::{AuxiliaryDetails, NodeRoles}; +use nym_noise_keys::VersionedNoiseKey; +use serde::{Deserialize, Serialize}; +use std::net::IpAddr; +use tracing::warn; +use utoipa::ToSchema; + +#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, ToSchema)] +pub struct HostInformation { + #[schema(value_type = Vec)] + pub ip_address: Vec, + pub hostname: Option, + pub keys: HostKeys, +} + +impl From for HostInformation { + fn from(value: nym_node_requests::api::v1::node::models::HostInformation) -> Self { + HostInformation { + ip_address: value.ip_address, + hostname: value.hostname, + keys: value.keys.into(), + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, ToSchema)] +pub struct HostKeys { + #[serde(with = "bs58_ed25519_pubkey")] + #[schemars(with = "String")] + #[schema(value_type = String)] + pub ed25519: ed25519::PublicKey, + + #[deprecated(note = "use the current_x25519_sphinx_key with explicit rotation information")] + #[serde(with = "bs58_x25519_pubkey")] + #[schemars(with = "String")] + #[schema(value_type = String)] + pub x25519: x25519::PublicKey, + + pub current_x25519_sphinx_key: SphinxKey, + + #[serde(default)] + pub pre_announced_x25519_sphinx_key: Option, + + #[serde(default)] + pub x25519_versioned_noise: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, ToSchema)] +pub struct SphinxKey { + pub rotation_id: u32, + + #[serde(with = "bs58_x25519_pubkey")] + #[schemars(with = "String")] + #[schema(value_type = String)] + pub public_key: x25519::PublicKey, +} + +impl From for SphinxKey { + fn from(value: nym_node_requests::api::v1::node::models::SphinxKey) -> Self { + SphinxKey { + rotation_id: value.rotation_id, + public_key: value.public_key, + } + } +} + +impl From for HostKeys { + fn from(value: nym_node_requests::api::v1::node::models::HostKeys) -> Self { + HostKeys { + ed25519: value.ed25519_identity, + x25519: value.x25519_sphinx, + current_x25519_sphinx_key: value.primary_x25519_sphinx_key.into(), + pre_announced_x25519_sphinx_key: value.pre_announced_x25519_sphinx_key.map(Into::into), + x25519_versioned_noise: value.x25519_versioned_noise, + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, ToSchema)] +pub struct WebSockets { + pub ws_port: u16, + + pub wss_port: Option, +} + +impl From for WebSockets { + fn from(value: nym_node_requests::api::v1::gateway::models::WebSockets) -> Self { + WebSockets { + ws_port: value.ws_port, + wss_port: value.wss_port, + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, ToSchema)] +pub struct NoiseDetails { + pub key: VersionedNoiseKey, + + pub mixnet_port: u16, + + #[schema(value_type = Vec)] + pub ip_addresses: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, ToSchema)] +pub struct NymNodeDescription { + #[schema(value_type = u32)] + pub node_id: NodeId, + pub contract_node_type: DescribedNodeType, + pub description: NymNodeData, +} + +impl NymNodeDescription { + pub fn version(&self) -> &str { + &self.description.build_information.build_version + } + + pub fn entry_information(&self) -> BasicEntryInformation { + BasicEntryInformation { + hostname: self.description.host_information.hostname.clone(), + ws_port: self.description.mixnet_websockets.ws_port, + wss_port: self.description.mixnet_websockets.wss_port, + } + } + + pub fn ed25519_identity_key(&self) -> ed25519::PublicKey { + self.description.host_information.keys.ed25519 + } + + pub fn current_sphinx_key(&self, current_rotation_id: u32) -> x25519::PublicKey { + let keys = &self.description.host_information.keys; + + if keys.current_x25519_sphinx_key.rotation_id == u32::MAX { + // legacy case (i.e. node doesn't support rotation) + return keys.current_x25519_sphinx_key.public_key; + } + + if current_rotation_id == keys.current_x25519_sphinx_key.rotation_id { + // it's the 'current' key + return keys.current_x25519_sphinx_key.public_key; + } + + if let Some(pre_announced) = &keys.pre_announced_x25519_sphinx_key { + if pre_announced.rotation_id == current_rotation_id { + return pre_announced.public_key; + } + } + + warn!( + "unexpected key rotation {current_rotation_id} for node {}", + self.node_id + ); + // this should never be reached, but just in case, return the fallback option + keys.current_x25519_sphinx_key.public_key + } + + pub fn to_skimmed_node( + &self, + current_rotation_id: u32, + role: NodeRole, + performance: Performance, + ) -> SkimmedNode { + let keys = &self.description.host_information.keys; + let entry = if self.description.declared_role.entry { + Some(self.entry_information()) + } else { + None + }; + + SkimmedNode { + node_id: self.node_id, + ed25519_identity_pubkey: keys.ed25519, + ip_addresses: self.description.host_information.ip_address.clone(), + mix_port: self.description.mix_port(), + x25519_sphinx_pubkey: self.current_sphinx_key(current_rotation_id), + // 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. + role, + supported_roles: self.description.declared_role, + entry, + performance, + } + } + + pub fn to_semi_skimmed_node( + &self, + current_rotation_id: u32, + role: NodeRole, + performance: Performance, + ) -> SemiSkimmedNode { + let skimmed_node = self.to_skimmed_node(current_rotation_id, role, performance); + + SemiSkimmedNode { + basic: skimmed_node, + x25519_noise_versioned_key: self + .description + .host_information + .keys + .x25519_versioned_noise, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, schemars::JsonSchema, ToSchema)] +#[serde(rename_all = "snake_case")] +#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))] +#[cfg_attr( + feature = "generate-ts", + ts( + export, + export_to = "ts-packages/types/src/types/rust/DescribedNodeType.ts" + ) +)] +pub enum DescribedNodeType { + LegacyMixnode, + LegacyGateway, + NymNode, +} + +impl DescribedNodeType { + pub fn is_nym_node(&self) -> bool { + matches!(self, DescribedNodeType::NymNode) + } +} + +#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, schemars::JsonSchema, ToSchema)] +#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))] +#[cfg_attr( + feature = "generate-ts", + ts( + export, + export_to = "ts-packages/types/src/types/rust/DeclaredRoles.ts" + ) +)] +pub struct DeclaredRoles { + pub mixnode: bool, + pub entry: bool, + pub exit_nr: bool, + pub exit_ipr: bool, +} + +impl DeclaredRoles { + pub fn can_operate_exit_gateway(&self) -> bool { + self.exit_ipr && self.exit_nr + } +} + +impl From for DeclaredRoles { + fn from(value: NodeRoles) -> Self { + DeclaredRoles { + mixnode: value.mixnode_enabled, + entry: value.gateway_enabled, + exit_nr: value.gateway_enabled && value.network_requester_enabled, + exit_ipr: value.gateway_enabled && value.ip_packet_router_enabled, + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, ToSchema)] +pub struct NetworkRequesterDetails { + /// address of the embedded network requester + pub address: String, + + /// flag indicating whether this network requester uses the exit policy rather than the deprecated allow list + pub uses_exit_policy: bool, +} + +#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, ToSchema)] +pub struct IpPacketRouterDetails { + /// address of the embedded ip packet router + pub address: String, +} + +// works for current simple case. +impl From for IpPacketRouterDetails { + fn from(value: IpPacketRouter) -> Self { + IpPacketRouterDetails { + address: value.address, + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, ToSchema)] +pub struct AuthenticatorDetails { + /// address of the embedded authenticator + pub address: String, +} + +// works for current simple case. +impl From for AuthenticatorDetails { + fn from(value: Authenticator) -> Self { + AuthenticatorDetails { + address: value.address, + } + } +} +#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, ToSchema)] +pub struct WireguardDetails { + pub port: u16, + pub public_key: String, +} + +// works for current simple case. +impl From for WireguardDetails { + fn from(value: Wireguard) -> Self { + WireguardDetails { + port: value.port, + public_key: value.public_key, + } + } +} + +// this struct is getting quite bloated... +#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, ToSchema)] +pub struct NymNodeData { + #[serde(default)] + pub last_polled: OffsetDateTimeJsonSchemaWrapper, + + pub host_information: HostInformation, + + #[serde(default)] + pub declared_role: DeclaredRoles, + + #[serde(default)] + pub auxiliary_details: AuxiliaryDetails, + + // TODO: do we really care about ALL build info or just the version? + pub build_information: BinaryBuildInformationOwned, + + #[serde(default)] + pub network_requester: Option, + + #[serde(default)] + pub ip_packet_router: Option, + + #[serde(default)] + pub authenticator: Option, + + #[serde(default)] + pub wireguard: Option, + + // for now we only care about their ws/wss situation, nothing more + pub mixnet_websockets: WebSockets, +} + +impl NymNodeData { + pub fn mix_port(&self) -> u16 { + self.auxiliary_details + .announce_ports + .mix_port + .unwrap_or(DEFAULT_MIX_LISTENING_PORT) + } + + pub fn verloc_port(&self) -> u16 { + self.auxiliary_details + .announce_ports + .verloc_port + .unwrap_or(DEFAULT_VERLOC_LISTENING_PORT) + } +} diff --git a/nym-api/nym-api-requests/src/models/legacy.rs b/nym-api/nym-api-requests/src/models/legacy.rs new file mode 100644 index 00000000000..c6cb6b2a78b --- /dev/null +++ b/nym-api/nym-api-requests/src/models/legacy.rs @@ -0,0 +1,364 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use super::{CoinSchema, DeclaredRoles}; +use crate::legacy::{ + LegacyGatewayBondWithId, LegacyMixNodeBondWithLayer, LegacyMixNodeDetailsWithLayer, +}; +use crate::models::{NodePerformance, NymNodeData, StakeSaturation}; +use crate::nym_nodes::{BasicEntryInformation, NodeRole, SemiSkimmedNode, SkimmedNode}; +use cosmwasm_std::{Addr, Coin, Decimal}; +use nym_contracts_common::Percent; +use nym_mixnet_contract_common::reward_params::Performance; +use nym_mixnet_contract_common::rewarding::RewardEstimate; +use nym_mixnet_contract_common::{GatewayBond, Interval, MixNode, NodeId, RewardingParams}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::fmt; +use std::net::IpAddr; +use std::time::Duration; +use thiserror::Error; +use utoipa::{IntoParams, ToSchema}; + +#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, ToSchema)] +pub struct LegacyDescribedGateway { + pub bond: GatewayBond, + pub self_described: Option, +} + +impl From for LegacyDescribedGateway { + fn from(bond: LegacyGatewayBondWithId) -> Self { + LegacyDescribedGateway { + bond: bond.bond, + self_described: None, + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, ToSchema)] +pub struct LegacyDescribedMixNode { + pub bond: LegacyMixNodeBondWithLayer, + pub self_described: Option, +} + +impl From for LegacyDescribedMixNode { + fn from(bond: LegacyMixNodeBondWithLayer) -> Self { + LegacyDescribedMixNode { + bond, + self_described: None, + } + } +} + +#[deprecated] +#[derive(Clone, Serialize, schemars::JsonSchema, ToSchema)] +pub struct InclusionProbability { + #[schema(value_type = u32)] + pub mix_id: NodeId, + pub in_active: f64, + pub in_reserve: f64, +} + +#[deprecated] +#[derive(Clone, Serialize, schemars::JsonSchema, ToSchema)] +pub struct AllInclusionProbabilitiesResponse { + pub inclusion_probabilities: Vec, + pub samples: u64, + pub elapsed: Duration, + pub delta_max: f64, + pub delta_l2: f64, + pub as_at: i64, +} + +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, ToSchema)] +#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))] +#[cfg_attr( + feature = "generate-ts", + ts( + export, + export_to = "ts-packages/types/src/types/rust/SelectionChance.ts" + ) +)] +#[deprecated] +pub enum SelectionChance { + High, + Good, + Low, +} + +impl From for SelectionChance { + fn from(p: f64) -> SelectionChance { + match p { + p if p >= 0.7 => SelectionChance::High, + p if p >= 0.3 => SelectionChance::Good, + _ => SelectionChance::Low, + } + } +} + +impl From for SelectionChance { + fn from(p: Decimal) -> Self { + match p { + p if p >= Decimal::from_ratio(70u32, 100u32) => SelectionChance::High, + p if p >= Decimal::from_ratio(30u32, 100u32) => SelectionChance::Good, + _ => SelectionChance::Low, + } + } +} + +impl fmt::Display for SelectionChance { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + SelectionChance::High => write!(f, "High"), + SelectionChance::Good => write!(f, "Good"), + SelectionChance::Low => write!(f, "Low"), + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, ToSchema)] +#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))] +#[cfg_attr( + feature = "generate-ts", + ts( + export, + export_to = "ts-packages/types/src/types/rust/InclusionProbabilityResponse.ts" + ) +)] +#[deprecated] +pub struct InclusionProbabilityResponse { + pub in_active: SelectionChance, + pub in_reserve: SelectionChance, +} + +impl fmt::Display for InclusionProbabilityResponse { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "in_active: {}, in_reserve: {}", + self.in_active, self.in_reserve + ) + } +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema, ToSchema, IntoParams)] +pub struct ComputeRewardEstParam { + #[schema(value_type = Option)] + #[param(value_type = Option)] + pub performance: Option, + pub active_in_rewarded_set: Option, + pub pledge_amount: Option, + pub total_delegation: Option, + #[schema(value_type = Option)] + #[param(value_type = Option)] + pub interval_operating_cost: Option, + #[schema(value_type = Option)] + #[param(value_type = Option)] + pub profit_margin_percent: Option, +} + +#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))] +#[cfg_attr( + feature = "generate-ts", + ts( + export, + export_to = "ts-packages/types/src/types/rust/RewardEstimationResponse.ts" + ) +)] +#[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema, ToSchema)] +pub struct RewardEstimationResponse { + pub estimation: RewardEstimate, + pub reward_params: RewardingParams, + pub epoch: Interval, + #[cfg_attr(feature = "generate-ts", ts(type = "number"))] + pub as_at: i64, +} + +impl MixNodeBondAnnotated { + pub fn mix_node(&self) -> &MixNode { + &self.mixnode_details.bond_information.mix_node + } + + pub fn mix_id(&self) -> NodeId { + self.mixnode_details.mix_id() + } + + pub fn identity_key(&self) -> &str { + self.mixnode_details.bond_information.identity() + } + + pub fn owner(&self) -> &Addr { + self.mixnode_details.bond_information.owner() + } + + 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)?, + role, + supported_roles: DeclaredRoles { + mixnode: true, + entry: false, + exit_nr: false, + exit_ipr: false, + }, + entry: None, + performance: self.node_performance.last_24h, + }) + } + + pub fn try_to_semi_skimmed_node( + &self, + role: NodeRole, + ) -> Result { + let skimmed_node = self.try_to_skimmed_node(role)?; + Ok(SemiSkimmedNode { + basic: skimmed_node, + x25519_noise_versioned_key: None, // legacy node won't ever support Noise + }) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, ToSchema)] +pub struct GatewayBondAnnotated { + pub gateway_bond: LegacyGatewayBondWithId, + + #[serde(default)] + pub self_described: Option, + + // NOTE: the performance field is deprecated in favour of node_performance + #[schema(value_type = String)] + pub performance: Performance, + pub node_performance: NodePerformance, + pub blacklisted: bool, + + #[serde(default)] + #[schema(value_type = Vec)] + pub ip_addresses: Vec, +} + +impl GatewayBondAnnotated { + pub fn version(&self) -> &str { + &self.gateway_bond.gateway.version + } + + pub fn identity(&self) -> &String { + self.gateway_bond.bond.identity() + } + + 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)?, + 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, + }) + } + + pub fn try_to_semi_skimmed_node( + &self, + role: NodeRole, + ) -> Result { + let skimmed_node = self.try_to_skimmed_node(role)?; + Ok(SemiSkimmedNode { + basic: skimmed_node, + x25519_noise_versioned_key: None, // legacy node won't ever support Noise + }) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, ToSchema)] +pub struct GatewayDescription { + // for now only expose what we need. this struct will evolve in the future (or be incorporated into nym-node properly) +} + +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, ToSchema)] +#[schema(title = "LegacyMixNodeDetailsWithLayer")] +pub struct LegacyMixNodeDetailsWithLayerSchema { + /// Basic bond information of this mixnode, such as owner address, original pledge, etc. + #[schema(example = "unimplemented schema")] + pub bond_information: String, + + /// Details used for computation of rewarding related data. + #[schema(example = "unimplemented schema")] + pub rewarding_details: String, + + /// Adjustments to the mixnode that are ought to happen during future epoch transitions. + #[schema(example = "unimplemented schema")] + pub pending_changes: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, ToSchema)] +pub struct MixNodeBondAnnotated { + #[schema(value_type = LegacyMixNodeDetailsWithLayerSchema)] + pub mixnode_details: LegacyMixNodeDetailsWithLayer, + #[schema(value_type = String)] + pub stake_saturation: StakeSaturation, + #[schema(value_type = String)] + pub uncapped_stake_saturation: StakeSaturation, + // NOTE: the performance field is deprecated in favour of node_performance + #[schema(value_type = String)] + pub performance: Performance, + pub node_performance: NodePerformance, + #[schema(value_type = String)] + pub estimated_operator_apy: Decimal, + #[schema(value_type = String)] + pub estimated_delegators_apy: Decimal, + pub blacklisted: bool, + + // a rather temporary thing until we query self-described endpoints of mixnodes + #[serde(default)] + #[schema(value_type = Vec)] + 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, +} diff --git a/nym-api/nym-api-requests/src/models/mixnet.rs b/nym-api/nym-api-requests/src/models/mixnet.rs new file mode 100644 index 00000000000..42f8bf1a72a --- /dev/null +++ b/nym-api/nym-api-requests/src/models/mixnet.rs @@ -0,0 +1,242 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use nym_mixnet_contract_common::nym_node::Role; +use nym_mixnet_contract_common::{EpochId, KeyRotationId, KeyRotationState, NodeId}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::cmp::Ordering; +use std::time::Duration; +use time::OffsetDateTime; +use tracing::warn; +use utoipa::ToSchema; + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, schemars::JsonSchema, ToSchema)] +pub struct KeyRotationInfoResponse { + #[serde(flatten)] + pub details: KeyRotationDetails, + + // helper field that holds calculated data based on the `details` field + // this is to expose the information in a format more easily accessible by humans + // without having to do any calculations + pub progress: KeyRotationProgressInfo, +} + +impl From for KeyRotationInfoResponse { + fn from(details: KeyRotationDetails) -> Self { + KeyRotationInfoResponse { + details, + progress: KeyRotationProgressInfo { + current_key_rotation_id: details.current_key_rotation_id(), + current_rotation_starting_epoch: details.current_rotation_starting_epoch_id(), + current_rotation_ending_epoch: details.current_rotation_starting_epoch_id() + + details.key_rotation_state.validity_epochs + - 1, + }, + } + } +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, schemars::JsonSchema, ToSchema)] +pub struct KeyRotationProgressInfo { + pub current_key_rotation_id: u32, + + pub current_rotation_starting_epoch: u32, + + pub current_rotation_ending_epoch: u32, +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, schemars::JsonSchema, ToSchema)] +pub struct KeyRotationDetails { + pub key_rotation_state: KeyRotationState, + + #[schema(value_type = u32)] + pub current_absolute_epoch_id: EpochId, + + #[serde(with = "time::serde::rfc3339")] + #[schemars(with = "String")] + #[schema(value_type = String)] + pub current_epoch_start: OffsetDateTime, + + pub epoch_duration: Duration, +} + +impl KeyRotationDetails { + pub fn current_key_rotation_id(&self) -> u32 { + self.key_rotation_state + .key_rotation_id(self.current_absolute_epoch_id) + } + + pub fn next_rotation_starting_epoch_id(&self) -> EpochId { + self.key_rotation_state + .next_rotation_starting_epoch_id(self.current_absolute_epoch_id) + } + + pub fn current_rotation_starting_epoch_id(&self) -> EpochId { + self.key_rotation_state + .current_rotation_starting_epoch_id(self.current_absolute_epoch_id) + } + + fn current_epoch_progress(&self, now: OffsetDateTime) -> f32 { + let elapsed = (now - self.current_epoch_start).as_seconds_f32(); + elapsed / self.epoch_duration.as_secs_f32() + } + + pub fn is_epoch_stuck(&self) -> bool { + let now = OffsetDateTime::now_utc(); + let progress = self.current_epoch_progress(now); + if progress > 1. { + let into_next = 1. - progress; + // if epoch hasn't progressed for more than 20% of its duration, mark is as stuck + if into_next > 0.2 { + let diff_time = + Duration::from_secs_f32(into_next * self.epoch_duration.as_secs_f32()); + let expected_epoch_end = self.current_epoch_start + self.epoch_duration; + warn!("the current epoch is expected to have been over by {expected_epoch_end}. it's already {} overdue!", humantime_serde::re::humantime::format_duration(diff_time)); + return true; + } + } + + false + } + + // based on the current **TIME**, determine what's the expected current rotation id + pub fn expected_current_rotation_id(&self) -> KeyRotationId { + let now = OffsetDateTime::now_utc(); + let current_end = now + self.epoch_duration; + if now < current_end { + return self + .key_rotation_state + .key_rotation_id(self.current_absolute_epoch_id); + } + + let diff = now - current_end; + let passed_epochs = diff / self.epoch_duration; + let expected_current_epoch = self.current_absolute_epoch_id + passed_epochs.floor() as u32; + + self.key_rotation_state + .key_rotation_id(expected_current_epoch) + } + + pub fn until_next_rotation(&self) -> Option { + let current_epoch_progress = self.current_epoch_progress(OffsetDateTime::now_utc()); + if current_epoch_progress > 1. { + return None; + } + + let next_rotation_epoch = self.next_rotation_starting_epoch_id(); + let full_remaining = + (next_rotation_epoch - self.current_absolute_epoch_id).checked_add(1)?; + + let epochs_until_next_rotation = (1. - current_epoch_progress) + full_remaining as f32; + + Some(Duration::from_secs_f32( + epochs_until_next_rotation * self.epoch_duration.as_secs_f32(), + )) + } + + pub fn epoch_start_time(&self, absolute_epoch_id: EpochId) -> OffsetDateTime { + match absolute_epoch_id.cmp(&self.current_absolute_epoch_id) { + Ordering::Less => { + let diff = self.current_absolute_epoch_id - absolute_epoch_id; + self.current_epoch_start - diff * self.epoch_duration + } + Ordering::Equal => self.current_epoch_start, + Ordering::Greater => { + let diff = absolute_epoch_id - self.current_absolute_epoch_id; + self.current_epoch_start + diff * self.epoch_duration + } + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, ToSchema)] +pub struct RewardedSetResponse { + #[serde(default)] + #[schema(value_type = u32)] + pub epoch_id: EpochId, + + pub entry_gateways: Vec, + + pub exit_gateways: Vec, + + pub layer1: Vec, + + pub layer2: Vec, + + pub layer3: Vec, + + pub standby: Vec, +} + +impl From for nym_mixnet_contract_common::EpochRewardedSet { + fn from(res: RewardedSetResponse) -> Self { + nym_mixnet_contract_common::EpochRewardedSet { + epoch_id: res.epoch_id, + assignment: nym_mixnet_contract_common::RewardedSet { + entry_gateways: res.entry_gateways, + exit_gateways: res.exit_gateways, + layer1: res.layer1, + layer2: res.layer2, + layer3: res.layer3, + standby: res.standby, + }, + } + } +} + +impl From for RewardedSetResponse { + fn from(r: nym_mixnet_contract_common::EpochRewardedSet) -> Self { + RewardedSetResponse { + epoch_id: r.epoch_id, + entry_gateways: r.assignment.entry_gateways, + exit_gateways: r.assignment.exit_gateways, + layer1: r.assignment.layer1, + layer2: r.assignment.layer2, + layer3: r.assignment.layer3, + standby: r.assignment.standby, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, Hash, JsonSchema, ToSchema)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))] +#[cfg_attr( + feature = "generate-ts", + ts(export, export_to = "ts-packages/types/src/types/rust/DisplayRole.ts") +)] +pub enum DisplayRole { + EntryGateway, + Layer1, + Layer2, + Layer3, + ExitGateway, + Standby, +} + +impl From for DisplayRole { + fn from(role: Role) -> Self { + match role { + Role::EntryGateway => DisplayRole::EntryGateway, + Role::Layer1 => DisplayRole::Layer1, + Role::Layer2 => DisplayRole::Layer2, + Role::Layer3 => DisplayRole::Layer3, + Role::ExitGateway => DisplayRole::ExitGateway, + Role::Standby => DisplayRole::Standby, + } + } +} + +impl From for Role { + fn from(role: DisplayRole) -> Self { + match role { + DisplayRole::EntryGateway => Role::EntryGateway, + DisplayRole::Layer1 => Role::Layer1, + DisplayRole::Layer2 => Role::Layer2, + DisplayRole::Layer3 => Role::Layer3, + DisplayRole::ExitGateway => Role::ExitGateway, + DisplayRole::Standby => Role::Standby, + } + } +} diff --git a/nym-api/nym-api-requests/src/models/mod.rs b/nym-api/nym-api-requests/src/models/mod.rs new file mode 100644 index 00000000000..76074be659c --- /dev/null +++ b/nym-api/nym-api-requests/src/models/mod.rs @@ -0,0 +1,62 @@ +// Copyright 2022-2024 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +#![allow(deprecated)] + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::fmt; +use std::fmt::{Display, Formatter}; + +pub mod api_status; +pub mod circulating_supply; +pub mod described; +pub mod legacy; +pub mod mixnet; +pub mod network; +pub mod network_monitor; +pub mod node_status; +pub mod schema_helpers; + +// don't break existing imports +pub use api_status::*; +pub use circulating_supply::*; +pub use described::*; +pub use legacy::*; +pub use mixnet::*; +pub use network::*; +pub use network_monitor::*; +pub use node_status::*; +pub use schema_helpers::*; + +pub use nym_mixnet_contract_common::{EpochId, KeyRotationId, KeyRotationState}; +pub use nym_node_requests::api::v1::node::models::BinaryBuildInformationOwned; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +pub struct RequestError { + message: String, +} + +impl RequestError { + pub fn new>(msg: S) -> Self { + RequestError { + message: msg.into(), + } + } + + pub fn message(&self) -> &str { + &self.message + } + + pub fn empty() -> Self { + Self { + message: String::new(), + } + } +} + +impl Display for RequestError { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + Display::fmt(&self.message, f) + } +} diff --git a/nym-api/nym-api-requests/src/models/network.rs b/nym-api/nym-api-requests/src/models/network.rs new file mode 100644 index 00000000000..333639fa124 --- /dev/null +++ b/nym-api/nym-api-requests/src/models/network.rs @@ -0,0 +1,527 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::ecash::models::EcashSignerStatusResponse; +use crate::models::tendermint_types::{BlockHeader, BlockId}; +use crate::models::{ChainStatus, SignerInformationResponse}; +use crate::signable::SignedMessage; +use nym_coconut_dkg_common::types::EpochId; +use nym_crypto::asymmetric::ed25519::PublicKey; +use nym_ecash_signer_check_types::helper_traits::{ + ChainResponse, LegacyChainResponse, LegacySignerResponse, SignerResponse, TimestampedResponse, + Verifiable, +}; +use nym_ecash_signer_check_types::status::SignerResult; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::time::Duration; +use time::OffsetDateTime; +use utoipa::ToSchema; + +pub type ChainBlocksStatusResponse = SignedMessage; +pub type SignersStatusResponse = SignedMessage; +pub type DetailedSignersStatusResponse = SignedMessage; + +#[derive(Serialize, Deserialize, ToSchema, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct SignersStatusResponseBody { + #[serde(with = "time::serde::rfc3339")] + #[schema(value_type = String)] + pub as_at: OffsetDateTime, + + pub overview: SignersStatusOverview, +} + +pub type TypedSignerResult = SignerResult< + SignerInformationResponse, + EcashSignerStatusResponse, + ChainStatusResponse, + ChainBlocksStatusResponse, +>; + +#[derive(Serialize, Deserialize, ToSchema, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct DetailedSignersStatusResponseBody { + #[serde(with = "time::serde::rfc3339")] + #[schema(value_type = String)] + pub as_at: OffsetDateTime, + + pub overview: SignersStatusOverview, + + pub details: Vec, +} + +#[derive(Serialize, Deserialize, ToSchema, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct SignersStatusOverview { + #[schema(value_type = Option)] + pub epoch_id: Option, + + pub total_signers: usize, + pub unreachable_signers: usize, + pub malformed_signers: usize, + + // unreachable or outdated + pub unknown_local_chain_status: usize, + pub working_local_chain: usize, + + // i.e. provided signature + pub provably_stalled_local_chain: usize, + pub unprovably_stalled_local_chain: usize, + + // unreachable or outdated + pub unknown_credential_issuance_status: usize, + pub working_credential_issuance: usize, + + // i.e. provided signature + pub provably_unavailable_credential_issuance: usize, + pub unprovably_unavailable_credential_issuance: usize, +} + +impl SignersStatusOverview { + pub fn new(results: &[TypedSignerResult]) -> Self { + let epoch_id = results.first().map(|r| r.dkg_epoch_id); + + let mut unreachable_signers = 0; + let mut malformed_signers = 0; + let mut unknown_local_chain_status = 0; + let mut working_local_chain = 0; + let mut provably_stalled_local_chain = 0; + let mut unprovably_stalled_local_chain = 0; + let mut unknown_credential_issuance_status = 0; + let mut working_credential_issuance = 0; + let mut provably_unavailable_credential_issuance = 0; + let mut unprovably_unavailable_credential_issuance = 0; + + for result in results { + if result.signer_unreachable() { + unreachable_signers += 1; + } + if result.malformed_details() { + malformed_signers += 1; + } + + if result.unknown_chain_status() { + unknown_local_chain_status += 1; + } + if result.chain_available() { + working_local_chain += 1; + } + if result.chain_provably_stalled() { + provably_stalled_local_chain += 1; + } + if result.chain_unprovably_stalled() { + unprovably_stalled_local_chain += 1; + } + + if result.unknown_signing_status() { + unknown_credential_issuance_status += 1; + } + if result.signing_available() { + working_credential_issuance += 1; + } + if result.signing_provably_unavailable() { + provably_unavailable_credential_issuance += 1; + } + if result.signing_unprovably_unavailable() { + unprovably_unavailable_credential_issuance += 1; + } + } + + SignersStatusOverview { + epoch_id, + total_signers: results.len(), + unreachable_signers, + malformed_signers, + unknown_local_chain_status, + working_local_chain, + provably_stalled_local_chain, + unprovably_stalled_local_chain, + unknown_credential_issuance_status, + working_credential_issuance, + provably_unavailable_credential_issuance, + unprovably_unavailable_credential_issuance, + } + } +} + +#[derive(Serialize, Deserialize, ToSchema, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct ChainBlocksStatusResponseBody { + #[serde(with = "time::serde::rfc3339")] + #[schema(value_type = String)] + pub current_time: OffsetDateTime, + + pub latest_cached_block: Option, + + // explicit indication of THIS signer whether it thinks the chain is stalled + pub chain_status: ChainStatus, +} + +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, ToSchema)] +pub struct ChainStatusResponse { + pub connected_nyxd: String, + pub status: DetailedChainStatus, +} + +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, ToSchema)] +pub struct DetailedChainStatus { + pub abci: crate::models::tendermint_types::AbciInfo, + pub latest_block: BlockInfo, +} + +impl DetailedChainStatus { + pub fn stall_status(&self, now: OffsetDateTime, threshold: Duration) -> ChainStatus { + let block_time: OffsetDateTime = self.latest_block.block.header.time.into(); + let diff = now - block_time; + if diff > threshold { + ChainStatus::Stalled { + approximate_amount: diff.unsigned_abs(), + } + } else { + ChainStatus::Synced + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, ToSchema)] +pub struct BlockInfo { + pub block_id: BlockId, + pub block: FullBlockInfo, + // if necessary we might put block data here later too +} + +impl From for BlockInfo { + fn from(value: tendermint_rpc::endpoint::block::Response) -> Self { + BlockInfo { + block_id: value.block_id.into(), + block: FullBlockInfo { + header: value.block.header.into(), + }, + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, ToSchema)] +pub struct FullBlockInfo { + pub header: BlockHeader, +} + +// copy tendermint types definitions whilst deriving schema types on them and dropping unwanted fields +pub mod tendermint_types { + use schemars::JsonSchema; + use serde::{Deserialize, Serialize}; + use tendermint::abci::response::Info; + use tendermint::block::header::Version; + use tendermint::{block, Hash}; + use utoipa::ToSchema; + + #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, ToSchema)] + pub struct AbciInfo { + /// Some arbitrary information. + pub data: String, + + /// The application software semantic version. + pub version: String, + + /// The application protocol version. + pub app_version: u64, + + /// The latest block for which the app has called [`Commit`]. + pub last_block_height: u64, + + /// The latest result of [`Commit`]. + pub last_block_app_hash: String, + } + + impl From for AbciInfo { + fn from(value: Info) -> Self { + AbciInfo { + data: value.data, + version: value.version, + app_version: value.app_version, + last_block_height: value.last_block_height.value(), + last_block_app_hash: value.last_block_app_hash.to_string(), + } + } + } + + /// `Version` contains the protocol version for the blockchain and the + /// application. + /// + /// + #[derive( + Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema, ToSchema, + )] + pub struct HeaderVersion { + /// Block version + pub block: u64, + + /// App version + pub app: u64, + } + + impl From for HeaderVersion { + fn from(value: Version) -> Self { + HeaderVersion { + block: value.block, + app: value.app, + } + } + } + + /// Block identifiers which contain two distinct Merkle roots of the block, + /// as well as the number of parts in the block. + /// + /// + /// + /// Default implementation is an empty Id as defined by the Go implementation in + /// . + /// + /// If the Hash is empty in BlockId, the BlockId should be empty (encoded to None). + /// This is implemented outside of this struct. Use the Default trait to check for an empty BlockId. + /// See: + #[derive( + Serialize, + Deserialize, + Copy, + Clone, + Debug, + Default, + Hash, + Eq, + PartialEq, + PartialOrd, + Ord, + JsonSchema, + ToSchema, + )] + pub struct BlockId { + /// The block's main hash is the Merkle root of all the fields in the + /// block header. + #[schemars(with = "String")] + #[schema(value_type = String)] + pub hash: Hash, + + /// Parts header (if available) is used for secure gossipping of the block + /// during consensus. It is the Merkle root of the complete serialized block + /// cut into parts. + /// + /// PartSet is used to split a byteslice of data into parts (pieces) for + /// transmission. By splitting data into smaller parts and computing a + /// Merkle root hash on the list, you can verify that a part is + /// legitimately part of the complete data, and the part can be forwarded + /// to other peers before all the parts are known. In short, it's a fast + /// way to propagate a large file over a gossip network. + /// + /// + /// + /// PartSetHeader in protobuf is defined as never nil using the gogoproto + /// annotations. This does not translate to Rust, but we can indicate this + /// in the domain type. + pub part_set_header: PartSetHeader, + } + + impl From for BlockId { + fn from(value: block::Id) -> Self { + BlockId { + hash: value.hash, + part_set_header: value.part_set_header.into(), + } + } + } + + /// Block parts header + #[derive( + Clone, + Copy, + Debug, + Default, + Hash, + Eq, + PartialEq, + PartialOrd, + Ord, + Deserialize, + Serialize, + JsonSchema, + ToSchema, + )] + #[non_exhaustive] + pub struct PartSetHeader { + /// Number of parts in this block + pub total: u32, + + /// Hash of the parts set header, + #[schemars(with = "String")] + #[schema(value_type = String)] + pub hash: Hash, + } + + impl From for PartSetHeader { + fn from(value: block::parts::Header) -> Self { + PartSetHeader { + total: value.total, + hash: value.hash, + } + } + } + + /// Block `Header` values contain metadata about the block and about the + /// consensus, as well as commitments to the data in the current block, the + /// previous block, and the results returned by the application. + /// + /// + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)] + pub struct BlockHeader { + /// Header version + pub version: HeaderVersion, + + /// Chain ID + pub chain_id: String, + + /// Current block height + pub height: u64, + + /// Current timestamp + #[schemars(with = "String")] + #[schema(value_type = String)] + pub time: tendermint::Time, + + /// Previous block info + pub last_block_id: Option, + + /// Commit from validators from the last block + #[schemars(with = "Option")] + #[schema(value_type = Option)] + pub last_commit_hash: Option, + + /// Merkle root of transaction hashes + #[schemars(with = "Option")] + #[schema(value_type = Option)] + pub data_hash: Option, + + /// Validators for the current block + #[schemars(with = "String")] + #[schema(value_type = String)] + pub validators_hash: Hash, + + /// Validators for the next block + #[schemars(with = "String")] + #[schema(value_type = String)] + pub next_validators_hash: Hash, + + /// Consensus params for the current block + #[schemars(with = "String")] + #[schema(value_type = String)] + pub consensus_hash: Hash, + + /// State after txs from the previous block + #[schemars(with = "String")] + #[schema(value_type = String)] + pub app_hash: Hash, + + /// Root hash of all results from the txs from the previous block + #[schemars(with = "Option")] + #[schema(value_type = Option)] + pub last_results_hash: Option, + + /// Hash of evidence included in the block + #[schemars(with = "Option")] + #[schema(value_type = Option)] + pub evidence_hash: Option, + + /// Original proposer of the block + #[serde(with = "nym_serde_helpers::hex")] + #[schemars(with = "String")] + #[schema(value_type = String)] + pub proposer_address: Vec, + } + + impl From for BlockHeader { + fn from(value: block::Header) -> Self { + BlockHeader { + version: value.version.into(), + chain_id: value.chain_id.to_string(), + height: value.height.value(), + time: value.time, + last_block_id: value.last_block_id.map(Into::into), + last_commit_hash: value.last_commit_hash, + data_hash: value.data_hash, + validators_hash: value.validators_hash, + next_validators_hash: value.next_validators_hash, + consensus_hash: value.consensus_hash, + app_hash: Hash::try_from(value.app_hash.as_bytes().to_vec()).unwrap_or_default(), + last_results_hash: value.last_results_hash, + evidence_hash: value.evidence_hash, + proposer_address: value.proposer_address.as_bytes().to_vec(), + } + } + } +} + +// implement required traits for the signer responses + +impl LegacyChainResponse for ChainStatusResponse { + fn chain_synced(&self, now: OffsetDateTime, stall_threshold: Duration) -> bool { + self.status.stall_status(now, stall_threshold).is_synced() + } +} + +impl Verifiable for ChainBlocksStatusResponse { + fn verify_signature(&self, pub_key: &PublicKey) -> bool { + self.verify_signature(pub_key) + } +} + +impl TimestampedResponse for ChainBlocksStatusResponse { + fn timestamp(&self) -> OffsetDateTime { + self.body.current_time + } +} + +impl ChainResponse for ChainBlocksStatusResponse { + fn chain_synced(&self) -> bool { + self.body.chain_status.is_synced() + } +} + +impl LegacySignerResponse for SignerInformationResponse { + fn signer_identity(&self) -> &str { + &self.identity + } + + fn signer_verification_key(&self) -> &Option { + &self.verification_key + } +} + +impl Verifiable for EcashSignerStatusResponse { + fn verify_signature(&self, pub_key: &PublicKey) -> bool { + self.verify_signature(pub_key) + } +} + +impl TimestampedResponse for EcashSignerStatusResponse { + fn timestamp(&self) -> OffsetDateTime { + self.body.current_time + } +} + +impl SignerResponse for EcashSignerStatusResponse { + fn has_signing_keys(&self) -> bool { + self.body.has_signing_keys + } + + fn signer_disabled(&self) -> bool { + self.body.signer_disabled + } + + fn is_ecash_signer(&self) -> bool { + self.body.is_ecash_signer + } + + fn dkg_ecash_epoch_id(&self) -> EpochId { + self.body.dkg_ecash_epoch_id + } +} diff --git a/nym-api/nym-api-requests/src/models/network_monitor.rs b/nym-api/nym-api-requests/src/models/network_monitor.rs new file mode 100644 index 00000000000..c253bd28cae --- /dev/null +++ b/nym-api/nym-api-requests/src/models/network_monitor.rs @@ -0,0 +1,74 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::pagination::PaginatedResponse; +use nym_mixnet_contract_common::NodeId; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use utoipa::ToSchema; + +#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, Default, ToSchema)] +pub struct TestNode { + pub node_id: Option, + pub identity_key: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, ToSchema)] +pub struct TestRoute { + pub gateway: TestNode, + pub layer1: TestNode, + pub layer2: TestNode, + pub layer3: TestNode, +} + +#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, ToSchema)] +pub struct PartialTestResult { + pub monitor_run_id: i64, + pub timestamp: i64, + pub overall_reliability_for_all_routes_in_monitor_run: Option, + pub test_routes: TestRoute, +} + +pub type MixnodeTestResultResponse = PaginatedResponse; +pub type GatewayTestResultResponse = PaginatedResponse; + +#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, ToSchema)] +pub struct NetworkMonitorRunDetailsResponse { + pub monitor_run_id: i64, + pub network_reliability: f64, + pub total_sent: usize, + pub total_received: usize, + + // integer score to number of nodes with that score + pub mixnode_results: BTreeMap, + pub gateway_results: BTreeMap, +} + +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, ToSchema)] +#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))] +#[cfg_attr( + feature = "generate-ts", + ts( + export, + export_to = "ts-packages/types/src/types/rust/MixnodeCoreStatusResponse.ts" + ) +)] +pub struct MixnodeCoreStatusResponse { + pub mix_id: NodeId, + pub count: i64, +} + +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, ToSchema)] +#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))] +#[cfg_attr( + feature = "generate-ts", + ts( + export, + export_to = "ts-packages/types/src/types/rust/GatewayCoreStatusResponse.ts" + ) +)] +pub struct GatewayCoreStatusResponse { + pub identity: String, + pub count: i64, +} diff --git a/nym-api/nym-api-requests/src/models/node_status.rs b/nym-api/nym-api-requests/src/models/node_status.rs new file mode 100644 index 00000000000..03bd0ce2d8e --- /dev/null +++ b/nym-api/nym-api-requests/src/models/node_status.rs @@ -0,0 +1,587 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::helpers::PlaceholderJsonSchemaImpl; +use crate::pagination::PaginatedResponse; +use cosmwasm_std::Decimal; +use nym_contracts_common::{IdentityKey, NaiveFloat}; +use nym_crypto::asymmetric::ed25519; +use nym_crypto::asymmetric::ed25519::serde_helpers::bs58_ed25519_pubkey; +use nym_mixnet_contract_common::reward_params::Performance; +use nym_mixnet_contract_common::NodeId; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::time::Duration; +use time::{Date, OffsetDateTime}; +use utoipa::ToSchema; + +use crate::models::DisplayRole; +pub use config_score::*; + +pub type StakeSaturation = Decimal; + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema, ToSchema)] +#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))] +#[cfg_attr( + feature = "generate-ts", + ts( + export, + export_to = "ts-packages/types/src/types/rust/StakeSaturationResponse.ts" + ) +)] +pub struct StakeSaturationResponse { + #[cfg_attr(feature = "generate-ts", ts(type = "string"))] + #[schema(value_type = String)] + pub saturation: StakeSaturation, + + #[cfg_attr(feature = "generate-ts", ts(type = "string"))] + #[schema(value_type = String)] + pub uncapped_saturation: StakeSaturation, + pub as_at: i64, +} + +pub mod config_score { + use nym_contracts_common::NaiveFloat; + use serde::{Deserialize, Serialize}; + use std::cmp::Ordering; + use utoipa::ToSchema; + + #[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, ToSchema)] + pub struct ConfigScoreDataResponse { + pub parameters: ConfigScoreParams, + pub version_history: Vec, + } + + #[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, ToSchema, PartialEq)] + pub struct HistoricalNymNodeVersionEntry { + /// The unique, ordered, id of this particular entry + pub id: u32, + + /// Data associated with this particular version + pub version_information: HistoricalNymNodeVersion, + } + + impl PartialOrd for HistoricalNymNodeVersionEntry { + fn partial_cmp(&self, other: &Self) -> Option { + // we only care about id for the purposes of ordering as they should have unique data + self.id.partial_cmp(&other.id) + } + } + + impl From + for HistoricalNymNodeVersionEntry + { + fn from(value: nym_mixnet_contract_common::HistoricalNymNodeVersionEntry) -> Self { + HistoricalNymNodeVersionEntry { + id: value.id, + version_information: value.version_information.into(), + } + } + } + + #[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, ToSchema, PartialEq)] + pub struct HistoricalNymNodeVersion { + /// Version of the nym node that is going to be used for determining the version score of a node. + /// note: value stored here is pre-validated `semver::Version` + pub semver: String, + + /// Block height of when this version has been added to the contract + pub introduced_at_height: u64, + // for now ignore that field. it will give nothing useful to the users + // pub difference_since_genesis: TotalVersionDifference, + } + + impl From for HistoricalNymNodeVersion { + fn from(value: nym_mixnet_contract_common::HistoricalNymNodeVersion) -> Self { + HistoricalNymNodeVersion { + semver: value.semver, + introduced_at_height: value.introduced_at_height, + } + } + } + + #[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, ToSchema)] + pub struct ConfigScoreParams { + /// Defines weights for calculating numbers of versions behind the current release. + pub version_weights: OutdatedVersionWeights, + + /// Defines the parameters of the formula for calculating the version score + pub version_score_formula_params: VersionScoreFormulaParams, + } + + /// Defines weights for calculating numbers of versions behind the current release. + #[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, ToSchema)] + pub struct OutdatedVersionWeights { + pub major: u32, + pub minor: u32, + pub patch: u32, + pub prerelease: u32, + } + + /// Given the formula of version_score = penalty ^ (versions_behind_factor ^ penalty_scaling) + /// define the relevant parameters + #[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, ToSchema)] + pub struct VersionScoreFormulaParams { + pub penalty: f64, + pub penalty_scaling: f64, + } + + impl From for ConfigScoreParams { + fn from(value: nym_mixnet_contract_common::ConfigScoreParams) -> Self { + ConfigScoreParams { + version_weights: value.version_weights.into(), + version_score_formula_params: value.version_score_formula_params.into(), + } + } + } + + impl From for OutdatedVersionWeights { + fn from(value: nym_mixnet_contract_common::OutdatedVersionWeights) -> Self { + OutdatedVersionWeights { + major: value.major, + minor: value.minor, + patch: value.patch, + prerelease: value.prerelease, + } + } + } + + impl From for VersionScoreFormulaParams { + fn from(value: nym_mixnet_contract_common::VersionScoreFormulaParams) -> Self { + VersionScoreFormulaParams { + penalty: value.penalty.naive_to_f64(), + penalty_scaling: value.penalty_scaling.naive_to_f64(), + } + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, ToSchema)] +pub struct NodeRefreshBody { + #[serde(with = "bs58_ed25519_pubkey")] + #[schemars(with = "String")] + #[schema(value_type = String)] + pub node_identity: ed25519::PublicKey, + + // a poor man's nonce + pub request_timestamp: i64, + + #[schemars(with = "PlaceholderJsonSchemaImpl")] + #[schema(value_type = String)] + pub signature: ed25519::Signature, +} + +impl NodeRefreshBody { + pub fn plaintext(node_identity: ed25519::PublicKey, request_timestamp: i64) -> Vec { + node_identity + .to_bytes() + .into_iter() + .chain(request_timestamp.to_be_bytes()) + .chain(b"describe-cache-refresh-request".iter().copied()) + .collect() + } + + pub fn new(private_key: &ed25519::PrivateKey) -> Self { + let node_identity = private_key.public_key(); + let request_timestamp = OffsetDateTime::now_utc().unix_timestamp(); + let signature = private_key.sign(Self::plaintext(node_identity, request_timestamp)); + NodeRefreshBody { + node_identity, + request_timestamp, + signature, + } + } + + pub fn verify_signature(&self) -> bool { + self.node_identity + .verify( + Self::plaintext(self.node_identity, self.request_timestamp), + &self.signature, + ) + .is_ok() + } + + pub fn is_stale(&self) -> bool { + let Ok(encoded) = OffsetDateTime::from_unix_timestamp(self.request_timestamp) else { + return true; + }; + let now = OffsetDateTime::now_utc(); + + if encoded > now { + return true; + } + + if (encoded + Duration::from_secs(30)) < now { + return true; + } + + false + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, ToSchema)] +pub struct UptimeResponse { + #[schema(value_type = u32)] + pub mix_id: NodeId, + // The same as node_performance.last_24h. Legacy + pub avg_uptime: u8, + pub node_performance: NodePerformance, +} + +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, ToSchema)] +pub struct GatewayUptimeResponse { + pub identity: String, + // The same as node_performance.last_24h. Legacy + pub avg_uptime: u8, + pub node_performance: NodePerformance, +} + +type Uptime = u8; + +#[derive(Clone, Serialize, Deserialize, schemars::JsonSchema, ToSchema)] +pub struct MixnodeStatusReportResponse { + pub mix_id: NodeId, + pub identity: IdentityKey, + pub owner: String, + #[schema(value_type = u8)] + pub most_recent: Uptime, + #[schema(value_type = u8)] + pub last_hour: Uptime, + #[schema(value_type = u8)] + pub last_day: Uptime, +} + +#[derive(Clone, Serialize, Deserialize, schemars::JsonSchema, ToSchema)] +pub struct GatewayStatusReportResponse { + pub identity: String, + pub owner: String, + #[schema(value_type = u8)] + pub most_recent: Uptime, + #[schema(value_type = u8)] + pub last_hour: Uptime, + #[schema(value_type = u8)] + pub last_day: Uptime, +} + +#[derive(Serialize, Deserialize, schemars::JsonSchema, ToSchema)] +#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))] +#[cfg_attr( + feature = "generate-ts", + ts( + export, + export_to = "ts-packages/types/src/types/rust/PerformanceHistoryResponse.ts" + ) +)] +pub struct PerformanceHistoryResponse { + #[schema(value_type = u32)] + pub node_id: NodeId, + pub history: PaginatedResponse, +} + +#[derive(Serialize, Deserialize, schemars::JsonSchema, ToSchema)] +#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))] +#[cfg_attr( + feature = "generate-ts", + ts( + export, + export_to = "ts-packages/types/src/types/rust/UptimeHistoryResponse.ts" + ) +)] +pub struct UptimeHistoryResponse { + #[schema(value_type = u32)] + pub node_id: NodeId, + pub history: PaginatedResponse, +} + +#[derive(Clone, Serialize, Deserialize, schemars::JsonSchema, ToSchema)] +#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))] +#[cfg_attr( + feature = "generate-ts", + ts( + export, + export_to = "ts-packages/types/src/types/rust/HistoricalUptimeResponse.ts" + ) +)] +pub struct HistoricalUptimeResponse { + #[schema(value_type = String, example = "1970-01-01")] + #[schemars(with = "String")] + #[cfg_attr(feature = "generate-ts", ts(type = "string"))] + pub date: Date, + + pub uptime: Uptime, +} + +#[derive(Clone, Serialize, Deserialize, schemars::JsonSchema, ToSchema)] +#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))] +#[cfg_attr( + feature = "generate-ts", + ts( + export, + export_to = "ts-packages/types/src/types/rust/HistoricalPerformanceResponse.ts" + ) +)] +pub struct HistoricalPerformanceResponse { + #[schema(value_type = String, example = "1970-01-01")] + #[schemars(with = "String")] + #[cfg_attr(feature = "generate-ts", ts(type = "string"))] + pub date: Date, + + pub performance: f64, +} + +#[derive(Clone, Serialize, Deserialize, schemars::JsonSchema, ToSchema)] +pub struct OldHistoricalUptimeResponse { + pub date: String, + #[schema(value_type = u8)] + pub uptime: Uptime, +} + +#[derive(Clone, Serialize, Deserialize, schemars::JsonSchema, ToSchema)] +pub struct MixnodeUptimeHistoryResponse { + pub mix_id: NodeId, + pub identity: String, + pub owner: String, + pub history: Vec, +} + +#[derive(Clone, Serialize, Deserialize, schemars::JsonSchema, ToSchema)] +pub struct GatewayUptimeHistoryResponse { + pub identity: String, + pub owner: String, + pub history: Vec, +} + +#[derive( + Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema, ToSchema, Default, +)] +#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))] +#[cfg_attr( + feature = "generate-ts", + ts( + export, + export_to = "ts-packages/types/src/types/rust/MixnodeStatus.ts" + ) +)] +#[serde(rename_all = "snake_case")] +pub enum MixnodeStatus { + Active, // in both the active set and the rewarded set + Standby, // only in the rewarded set + Inactive, // in neither the rewarded set nor the active set, but is bonded + #[default] + NotFound, // doesn't even exist in the bonded set +} +impl MixnodeStatus { + pub fn is_active(&self) -> bool { + *self == MixnodeStatus::Active + } +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema, ToSchema)] +#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))] +#[cfg_attr( + feature = "generate-ts", + ts( + export, + export_to = "ts-packages/types/src/types/rust/MixnodeStatusResponse.ts" + ) +)] +pub struct MixnodeStatusResponse { + pub status: MixnodeStatus, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema, ToSchema)] +pub struct NodePerformance { + #[schema(value_type = String)] + pub most_recent: Performance, + #[schema(value_type = String)] + pub last_hour: Performance, + #[schema(value_type = String)] + pub last_24h: Performance, +} + +// imo for now there's no point in exposing more than that, +// nym-api shouldn't be calculating apy or stake saturation for you. +// it should just return its own metrics (performance) and then you can do with it as you wish +#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, JsonSchema, ToSchema)] +#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))] +#[cfg_attr( + feature = "generate-ts", + ts( + export, + export_to = "ts-packages/types/src/types/rust/NodeAnnotation.ts" + ) +)] +pub struct NodeAnnotation { + #[cfg_attr(feature = "generate-ts", ts(type = "string"))] + // legacy + #[schema(value_type = String)] + pub last_24h_performance: Performance, + pub current_role: Option, + + pub detailed_performance: DetailedNodePerformance, +} + +#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, JsonSchema, ToSchema)] +#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))] +#[cfg_attr( + feature = "generate-ts", + ts( + export, + export_to = "ts-packages/types/src/types/rust/DetailedNodePerformance.ts" + ) +)] +#[non_exhaustive] +pub struct DetailedNodePerformance { + /// routing_score * config_score + pub performance_score: f64, + + pub routing_score: RoutingScore, + pub config_score: ConfigScore, +} + +impl DetailedNodePerformance { + pub fn new( + performance_score: f64, + routing_score: RoutingScore, + config_score: ConfigScore, + ) -> DetailedNodePerformance { + Self { + performance_score, + routing_score, + config_score, + } + } + + pub fn to_rewarding_performance(&self) -> Performance { + Performance::naive_try_from_f64(self.performance_score).unwrap_or_default() + } +} + +#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, JsonSchema, ToSchema)] +#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))] +#[cfg_attr( + feature = "generate-ts", + ts(export, export_to = "ts-packages/types/src/types/rust/RoutingScore.ts") +)] +#[non_exhaustive] +pub struct RoutingScore { + /// Total score after taking all the criteria into consideration + pub score: f64, +} + +impl RoutingScore { + pub fn new(score: f64) -> RoutingScore { + Self { score } + } + + pub const fn zero() -> RoutingScore { + RoutingScore { score: 0.0 } + } + + pub fn legacy_performance(&self) -> Performance { + Performance::naive_try_from_f64(self.score).unwrap_or_default() + } +} + +#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, JsonSchema, ToSchema)] +#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))] +#[cfg_attr( + feature = "generate-ts", + ts(export, export_to = "ts-packages/types/src/types/rust/ConfigScore.ts") +)] +#[non_exhaustive] +pub struct ConfigScore { + /// Total score after taking all the criteria into consideration + pub score: f64, + + pub versions_behind: Option, + pub self_described_api_available: bool, + pub accepted_terms_and_conditions: bool, + pub runs_nym_node_binary: bool, +} + +impl ConfigScore { + pub fn new( + score: f64, + versions_behind: u32, + accepted_terms_and_conditions: bool, + runs_nym_node_binary: bool, + ) -> ConfigScore { + Self { + score, + versions_behind: Some(versions_behind), + self_described_api_available: true, + accepted_terms_and_conditions, + runs_nym_node_binary, + } + } + + pub fn bad_semver() -> ConfigScore { + ConfigScore { + score: 0.0, + versions_behind: None, + self_described_api_available: true, + accepted_terms_and_conditions: false, + runs_nym_node_binary: false, + } + } + + pub fn unavailable() -> ConfigScore { + ConfigScore { + score: 0.0, + versions_behind: None, + self_described_api_available: false, + accepted_terms_and_conditions: false, + runs_nym_node_binary: false, + } + } +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema, ToSchema)] +#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))] +#[cfg_attr( + feature = "generate-ts", + ts( + export, + export_to = "ts-packages/types/src/types/rust/AnnotationResponse.ts" + ) +)] +pub struct AnnotationResponse { + #[schema(value_type = u32)] + pub node_id: NodeId, + pub annotation: Option, +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema, ToSchema)] +#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))] +#[cfg_attr( + feature = "generate-ts", + ts( + export, + export_to = "ts-packages/types/src/types/rust/NodePerformanceResponse.ts" + ) +)] +pub struct NodePerformanceResponse { + #[schema(value_type = u32)] + pub node_id: NodeId, + pub performance: Option, +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema, ToSchema)] +#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))] +#[cfg_attr( + feature = "generate-ts", + ts( + export, + export_to = "ts-packages/types/src/types/rust/NodeDatePerformanceResponse.ts" + ) +)] +pub struct NodeDatePerformanceResponse { + #[schema(value_type = u32)] + pub node_id: NodeId, + #[schema(value_type = String, example = "1970-01-01")] + #[schemars(with = "String")] + #[cfg_attr(feature = "generate-ts", ts(type = "string"))] + pub date: Date, + pub performance: Option, +} diff --git a/nym-api/nym-api-requests/src/models/schema_helpers.rs b/nym-api/nym-api-requests/src/models/schema_helpers.rs new file mode 100644 index 00000000000..d42c18fc442 --- /dev/null +++ b/nym-api/nym-api-requests/src/models/schema_helpers.rs @@ -0,0 +1,128 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::helpers::unix_epoch; +use cosmwasm_std::Uint128; +use schemars::schema::{InstanceType, Schema, SchemaObject}; +use schemars::{JsonSchema, SchemaGenerator}; +use serde::{Deserialize, Deserializer, Serialize}; +use std::fmt; +use std::fmt::{Display, Formatter}; +use std::ops::{Deref, DerefMut}; +use time::OffsetDateTime; +use utoipa::ToSchema; + +#[derive(ToSchema)] +#[schema(title = "Coin")] +pub struct CoinSchema { + pub denom: String, + #[schema(value_type = String)] + pub amount: Uint128, +} + +pub fn de_rfc3339_or_default<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + Ok(time::serde::rfc3339::deserialize(deserializer).unwrap_or_else(|_| unix_epoch())) +} + +// for all intents and purposes it's just OffsetDateTime, but we need JsonSchema... +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, ToSchema)] +pub struct OffsetDateTimeJsonSchemaWrapper( + #[serde( + default = "unix_epoch", + with = "crate::helpers::overengineered_offset_date_time_serde" + )] + #[schema(inline)] + pub OffsetDateTime, +); + +impl Default for OffsetDateTimeJsonSchemaWrapper { + fn default() -> Self { + OffsetDateTimeJsonSchemaWrapper(unix_epoch()) + } +} + +impl Display for OffsetDateTimeJsonSchemaWrapper { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + Display::fmt(&self.0, f) + } +} + +impl From for OffsetDateTime { + fn from(value: OffsetDateTimeJsonSchemaWrapper) -> Self { + value.0 + } +} + +impl From for OffsetDateTimeJsonSchemaWrapper { + fn from(value: OffsetDateTime) -> Self { + OffsetDateTimeJsonSchemaWrapper(value) + } +} + +impl Deref for OffsetDateTimeJsonSchemaWrapper { + type Target = OffsetDateTime; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for OffsetDateTimeJsonSchemaWrapper { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +// implementation taken from: https://github.com/GREsau/schemars/pull/207 +impl JsonSchema for OffsetDateTimeJsonSchemaWrapper { + fn is_referenceable() -> bool { + false + } + + fn schema_name() -> String { + "DateTime".into() + } + + fn json_schema(_: &mut SchemaGenerator) -> Schema { + SchemaObject { + instance_type: Some(InstanceType::String.into()), + format: Some("date-time".into()), + ..Default::default() + } + .into() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + #[test] + fn offset_date_time_json_schema_wrapper_serde_backwards_compat() { + let mut dummy = OffsetDateTimeJsonSchemaWrapper::default(); + dummy.0 += Duration::from_millis(1); + let ser = serde_json::to_string(&dummy).unwrap(); + + assert_eq!("\"1970-01-01 00:00:00.001 +00:00:00\"", ser); + + let human_readable = "\"2024-05-23 07:41:02.756283766 +00:00:00\""; + let rfc3339 = "\"2002-10-02T15:00:00Z\""; + let rfc3339_offset = "\"2002-10-02T10:00:00-05:00\""; + + let de = serde_json::from_str::(human_readable).unwrap(); + assert_eq!(de.0.unix_timestamp(), 1716450062); + + let de = serde_json::from_str::(rfc3339).unwrap(); + assert_eq!(de.0.unix_timestamp(), 1033570800); + + let de = serde_json::from_str::(rfc3339_offset).unwrap(); + assert_eq!(de.0.unix_timestamp(), 1033570800); + + let de = serde_json::from_str::("\"nonsense\"").unwrap(); + assert_eq!(de.0.unix_timestamp(), 0); + } +} diff --git a/nym-api/nym-api-requests/src/signable.rs b/nym-api/nym-api-requests/src/signable.rs index 6ae2caf7d30..5b1bb90584b 100644 --- a/nym-api/nym-api-requests/src/signable.rs +++ b/nym-api/nym-api-requests/src/signable.rs @@ -54,7 +54,9 @@ impl SignedMessage { // make sure only our types can implement this trait (to ensure infallible serialisation) pub(crate) mod sealed { use crate::ecash::models::*; - use crate::models::ChainBlocksStatusResponseBody; + use crate::models::{ + ChainBlocksStatusResponseBody, DetailedSignersStatusResponseBody, SignersStatusResponseBody, + }; pub trait Sealed {} @@ -68,4 +70,6 @@ pub(crate) mod sealed { impl Sealed for IssuedTicketbooksDataResponseBody {} impl Sealed for EcashSignerStatusResponseBody {} impl Sealed for ChainBlocksStatusResponseBody {} + impl Sealed for SignersStatusResponseBody {} + impl Sealed for DetailedSignersStatusResponseBody {} } diff --git a/nym-api/src/network/handlers.rs b/nym-api/src/network/handlers.rs index d601ce811f7..ef9396beff7 100644 --- a/nym-api/src/network/handlers.rs +++ b/nym-api/src/network/handlers.rs @@ -3,6 +3,7 @@ use crate::network::models::{ContractInformation, NetworkDetails}; use crate::node_status_api::models::AxumResult; +use crate::signers_cache::handlers::signers_routes; use crate::support::config::CHAIN_STALL_THRESHOLD; use crate::support::http::state::AppState; use axum::extract::{Query, State}; @@ -31,6 +32,7 @@ pub(crate) fn nym_network_routes() -> Router { "/nym-contracts-detailed", axum::routing::get(nym_contracts_detailed), ) + .nest("/signers", signers_routes()) .layer(CompressionLayer::new()) } diff --git a/nym-api/src/signers_cache/cache/mod.rs b/nym-api/src/signers_cache/cache/mod.rs index 2e3db25f229..ed900f2a0d8 100644 --- a/nym-api/src/signers_cache/cache/mod.rs +++ b/nym-api/src/signers_cache/cache/mod.rs @@ -1,11 +1,11 @@ // Copyright 2025 - Nym Technologies SA // SPDX-License-Identifier: GPL-3.0-only -use nym_ecash_signer_check::SignerResult; +use nym_ecash_signer_check::TypedSignerResult; pub(crate) mod data; pub(crate) mod refresher; pub(crate) struct SignersCacheData { - pub(crate) signer_results: Vec, + pub(crate) signer_results: Vec, } diff --git a/nym-api/src/signers_cache/handlers.rs b/nym-api/src/signers_cache/handlers.rs index 5111033b0b1..a1ab99592e3 100644 --- a/nym-api/src/signers_cache/handlers.rs +++ b/nym-api/src/signers_cache/handlers.rs @@ -1,2 +1,83 @@ // Copyright 2025 - Nym Technologies SA // SPDX-License-Identifier: GPL-3.0-only + +use crate::node_status_api::models::ApiResult; +use crate::support::http::state::AppState; +use axum::extract::{Query, State}; +use axum::routing::get; +use axum::Router; +use nym_api_requests::models::{ + DetailedSignersStatusResponse, DetailedSignersStatusResponseBody, SignersStatusOverview, + SignersStatusResponse, SignersStatusResponseBody, +}; +use nym_api_requests::signable::SignableMessageBody; +use nym_http_api_common::{FormattedResponse, OutputParams}; + +pub(crate) fn signers_routes() -> Router { + Router::new() + .route("/status", get(signers_status)) + .route("/status-detailed", get(signers_status_detailed)) +} + +#[utoipa::path( + tag = "network", + get, + context_path = "/v1/network/signers", + path = "/status", + responses( + (status = 200, content( + (SignersStatusResponse = "application/json"), + (SignersStatusResponse = "application/yaml"), + (SignersStatusResponse = "application/bincode") + )) + ), + params(OutputParams) +)] +pub(crate) async fn signers_status( + Query(params): Query, + State(state): State, +) -> ApiResult> { + let output = params.get_output(); + + let cached = state.ecash_signers_cache.get().await?; + let as_at = cached.timestamp(); + Ok(output.to_response( + SignersStatusResponseBody { + as_at, + overview: SignersStatusOverview::new(&cached.signer_results), + } + .sign(state.private_signing_key()), + )) +} + +#[utoipa::path( + tag = "network", + get, + context_path = "/v1/network/signers", + path = "/status-detailed", + responses( + (status = 200, content( + (DetailedSignersStatusResponse = "application/json"), + (DetailedSignersStatusResponse = "application/yaml"), + (DetailedSignersStatusResponse = "application/bincode") + )) + ), + params(OutputParams) +)] +pub(crate) async fn signers_status_detailed( + Query(params): Query, + State(state): State, +) -> ApiResult> { + let output = params.get_output(); + + let cached = state.ecash_signers_cache.get().await?; + let as_at = cached.timestamp(); + Ok(output.to_response( + DetailedSignersStatusResponseBody { + as_at, + overview: SignersStatusOverview::new(&cached.signer_results), + details: cached.signer_results.clone(), + } + .sign(state.private_signing_key()), + )) +} diff --git a/nym-api/src/signers_cache/mod.rs b/nym-api/src/signers_cache/mod.rs index 034ce4828e2..c43a27ed5b9 100644 --- a/nym-api/src/signers_cache/mod.rs +++ b/nym-api/src/signers_cache/mod.rs @@ -1,13 +1,27 @@ // Copyright 2025 - Nym Technologies SA // SPDX-License-Identifier: GPL-3.0-only +use crate::signers_cache::cache::refresher::SignersCacheDataProvider; use crate::signers_cache::cache::SignersCacheData; +use crate::support::caching::cache::SharedCache; use crate::support::caching::refresher::CacheRefresher; -use nym_validator_client::nyxd::error::NyxdError; +use crate::support::{config, nyxd}; +use nym_task::TaskManager; pub(crate) mod cache; pub(crate) mod handlers; -pub(crate) fn build_refresher() -> CacheRefresher { - todo!() +pub(crate) fn start_refresher( + config: &config::SignersCache, + nyxd_client: nyxd::Client, + task_manager: &TaskManager, +) -> SharedCache { + let refresher = CacheRefresher::new( + SignersCacheDataProvider::new(nyxd_client), + config.debug.refresh_interval, + ) + .named("signers-cache-refresher"); + let shared_cache = refresher.get_shared_cache(); + refresher.start(task_manager.subscribe_named("signers-cache-refresher")); + shared_cache } diff --git a/nym-api/src/support/caching/refresher.rs b/nym-api/src/support/caching/refresher.rs index b26a918f736..616aa00d008 100644 --- a/nym-api/src/support/caching/refresher.rs +++ b/nym-api/src/support/caching/refresher.rs @@ -60,6 +60,11 @@ pub(crate) trait CacheItemProvider { async fn try_refresh(&mut self) -> Result, Self::Error>; } +// Generics explanation: +// T: the actual type held in the cache +// E: Error type associated with refresh failure +// S: data type retrieved during update operation. it must be convertible into T +// (so that initial state could be established or when no `custom_fn` is set) impl CacheRefresher where E: std::error::Error, @@ -107,6 +112,8 @@ where } } + /// Rather than performing default behaviour of overwriting all existing values in the cache, + /// provide a custom update function that will define the update behaviour. #[must_use] pub(crate) fn with_update_fn( mut self, diff --git a/nym-api/src/support/cli/run.rs b/nym-api/src/support/cli/run.rs index 8a0e6e691ca..eb8f609ee46 100644 --- a/nym-api/src/support/cli/run.rs +++ b/nym-api/src/support/cli/run.rs @@ -34,7 +34,7 @@ use crate::support::storage::NymApiStorage; use crate::unstable_routes::v1::account::cache::AddressInfoCache; use crate::{ ecash, epoch_operations, mixnet_contract_cache, network_monitor, node_describe_cache, - node_performance, node_status_api, + node_performance, node_status_api, signers_cache, }; use anyhow::{bail, Context}; use nym_config::defaults::NymNetworkDetails; @@ -202,165 +202,172 @@ async fn start_nym_api_tasks(config: &Config) -> anyhow::Result None }; - todo!() - // ecash_state.spawn_background_cleaner(); - // let router = router.with_state(AppState { - // nyxd_client: nyxd_client.clone(), - // chain_status_cache: ChainStatusCache::new(DEFAULT_CHAIN_STATUS_CACHE_TTL), - // address_info_cache: AddressInfoCache::new( - // config.address_cache.time_to_live, - // config.address_cache.capacity, - // ), - // forced_refresh: ForcedRefresh::new(config.describe_cache.debug.allow_illegal_ips), - // mixnet_contract_cache: mixnet_contract_cache_state.clone(), - // node_status_cache: node_status_cache_state.clone(), - // storage: storage.clone(), - // described_nodes_cache: described_nodes_cache.clone(), - // network_details: network_details.clone(), - // node_info_cache, - // contract_info_cache: ContractDetailsCache::new(config.contracts_info_cache.time_to_live), - // api_status: ApiStatusState::new(signer_information), - // ecash_state: Arc::new(ecash_state), - // }); - // - // // start note describe cache refresher - // // we should be doing the below, but can't due to our current startup structure - // // let refresher = node_describe_cache::new_refresher(&config.topology_cacher); - // // let cache = refresher.get_shared_cache(); - // let describe_cache_refresher = node_describe_cache::provider::new_provider_with_initial_value( - // &config.describe_cache, - // mixnet_contract_cache_state.clone(), - // described_nodes_cache.clone(), - // ) - // .named("node-self-described-data-refresher"); - // - // let describe_cache_refresh_requester = describe_cache_refresher.refresh_requester(); - // - // let describe_cache_watcher = describe_cache_refresher - // .start_with_watcher(task_manager.subscribe_named("node-self-described-data-refresher")); - // - // let performance_provider = if config.performance_provider.use_performance_contract_data { - // if network_details - // .network - // .contracts - // .performance_contract_address - // .is_none() - // { - // bail!("can't use performance contract data without setting the address of the contract") - // } - // - // let performance_contract_cache = node_performance::contract_cache::start_cache_refresher( - // &config.performance_provider, - // nyxd_client.clone(), - // mixnet_contract_cache_state.clone(), - // &task_manager, - // ) - // .await?; - // let provider = ContractPerformanceProvider::new( - // &config.performance_provider, - // performance_contract_cache, - // ); - // Box::new(provider) as Box - // } else { - // Box::new(LegacyStoragePerformanceProvider::new( - // storage.clone(), - // mixnet_contract_cache_state.clone(), - // )) - // }; - // - // // start all the caches first - // let mixnet_contract_cache_refresher = mixnet_contract_cache::build_refresher( - // &config.mixnet_contract_cache, - // &mixnet_contract_cache_state.clone(), - // nyxd_client.clone(), - // ); - // let contract_cache_watcher = - // mixnet_contract_cache_refresher.start_with_watcher(task_manager.subscribe()); - // - // node_status_api::start_cache_refresh( - // &config.node_status_api, - // &mixnet_contract_cache_state, - // &described_nodes_cache, - // &node_status_cache_state, - // performance_provider, - // contract_cache_watcher.clone(), - // describe_cache_watcher, - // &task_manager, - // ); - // - // // start dkg task - // if config.ecash_signer.enabled { - // let dkg_bte_keypair = load_bte_keypair(&config.ecash_signer)?; - // - // DkgController::start( - // &config.ecash_signer, - // nyxd_client.clone(), - // ecash_keypair_wrapper, - // dkg_bte_keypair, - // identity_public_key, - // rand::rngs::OsRng, - // &task_manager, - // )?; - // } - // - // let has_performance_data = - // config.network_monitor.enabled || config.performance_provider.use_performance_contract_data; - // - // // and then only start the uptime updater (and the monitor itself, duh) - // // if the monitoring is enabled - // if config.network_monitor.enabled { - // network_monitor::start::( - // config, - // &mixnet_contract_cache_state, - // described_nodes_cache.clone(), - // node_status_cache_state.clone(), - // &storage, - // nyxd_client.clone(), - // &task_manager, - // ) - // .await; - // - // HistoricalUptimeUpdater::start(storage.to_owned(), &task_manager); - // } - // - // // start 'rewarding' if its enabled and there exists source for performance data - // if config.rewarding.enabled && has_performance_data { - // epoch_operations::ensure_rewarding_permission(&nyxd_client).await?; - // EpochAdvancer::start( - // nyxd_client, - // &mixnet_contract_cache_state, - // &node_status_cache_state, - // described_nodes_cache.clone(), - // &storage, - // &task_manager, - // ); - // } - // - // // finally start a background task watching the contract changes and requesting - // // self-described cache refresh upon being close to key rotation rollover - // KeyRotationController::new( - // describe_cache_refresh_requester, - // contract_cache_watcher, - // mixnet_contract_cache_state, - // ) - // .start(task_manager.subscribe_named("KeyRotationController")); - // - // let bind_address = config.base.bind_address.to_owned(); - // let server = router.build_server(&bind_address).await?; - // - // let cancellation_token = CancellationToken::new(); - // let shutdown_button = cancellation_token.clone(); - // let axum_shutdown_receiver = cancellation_token.cancelled_owned(); - // let server_handle = tokio::spawn(async move { - // { - // info!("Started Axum HTTP V2 server on {bind_address}"); - // server.run(axum_shutdown_receiver).await - // } - // }); - // - // let shutdown = ShutdownHandles::new(task_manager, server_handle, shutdown_button); - // - // Ok(shutdown) + // check if signers cache is enabled, and if so, start the refresher + let ecash_signers_cache = if config.signers_cache.enabled { + signers_cache::start_refresher(&config.signers_cache, nyxd_client.clone(), &task_manager) + } else { + SharedCache::new() + }; + + ecash_state.spawn_background_cleaner(); + let router = router.with_state(AppState { + nyxd_client: nyxd_client.clone(), + chain_status_cache: ChainStatusCache::new(DEFAULT_CHAIN_STATUS_CACHE_TTL), + ecash_signers_cache, + address_info_cache: AddressInfoCache::new( + config.address_cache.time_to_live, + config.address_cache.capacity, + ), + forced_refresh: ForcedRefresh::new(config.describe_cache.debug.allow_illegal_ips), + mixnet_contract_cache: mixnet_contract_cache_state.clone(), + node_status_cache: node_status_cache_state.clone(), + storage: storage.clone(), + described_nodes_cache: described_nodes_cache.clone(), + network_details: network_details.clone(), + node_info_cache, + contract_info_cache: ContractDetailsCache::new(config.contracts_info_cache.time_to_live), + api_status: ApiStatusState::new(signer_information), + ecash_state: Arc::new(ecash_state), + }); + + // start note describe cache refresher + // we should be doing the below, but can't due to our current startup structure + // let refresher = node_describe_cache::new_refresher(&config.topology_cacher); + // let cache = refresher.get_shared_cache(); + let describe_cache_refresher = node_describe_cache::provider::new_provider_with_initial_value( + &config.describe_cache, + mixnet_contract_cache_state.clone(), + described_nodes_cache.clone(), + ) + .named("node-self-described-data-refresher"); + + let describe_cache_refresh_requester = describe_cache_refresher.refresh_requester(); + + let describe_cache_watcher = describe_cache_refresher + .start_with_watcher(task_manager.subscribe_named("node-self-described-data-refresher")); + + let performance_provider = if config.performance_provider.use_performance_contract_data { + if network_details + .network + .contracts + .performance_contract_address + .is_none() + { + bail!("can't use performance contract data without setting the address of the contract") + } + + let performance_contract_cache = node_performance::contract_cache::start_cache_refresher( + &config.performance_provider, + nyxd_client.clone(), + mixnet_contract_cache_state.clone(), + &task_manager, + ) + .await?; + let provider = ContractPerformanceProvider::new( + &config.performance_provider, + performance_contract_cache, + ); + Box::new(provider) as Box + } else { + Box::new(LegacyStoragePerformanceProvider::new( + storage.clone(), + mixnet_contract_cache_state.clone(), + )) + }; + + // start all the caches first + let mixnet_contract_cache_refresher = mixnet_contract_cache::build_refresher( + &config.mixnet_contract_cache, + &mixnet_contract_cache_state.clone(), + nyxd_client.clone(), + ); + let contract_cache_watcher = + mixnet_contract_cache_refresher.start_with_watcher(task_manager.subscribe()); + + node_status_api::start_cache_refresh( + &config.node_status_api, + &mixnet_contract_cache_state, + &described_nodes_cache, + &node_status_cache_state, + performance_provider, + contract_cache_watcher.clone(), + describe_cache_watcher, + &task_manager, + ); + + // start dkg task + if config.ecash_signer.enabled { + let dkg_bte_keypair = load_bte_keypair(&config.ecash_signer)?; + + DkgController::start( + &config.ecash_signer, + nyxd_client.clone(), + ecash_keypair_wrapper, + dkg_bte_keypair, + identity_public_key, + rand::rngs::OsRng, + &task_manager, + )?; + } + + let has_performance_data = + config.network_monitor.enabled || config.performance_provider.use_performance_contract_data; + + // and then only start the uptime updater (and the monitor itself, duh) + // if the monitoring is enabled + if config.network_monitor.enabled { + network_monitor::start::( + config, + &mixnet_contract_cache_state, + described_nodes_cache.clone(), + node_status_cache_state.clone(), + &storage, + nyxd_client.clone(), + &task_manager, + ) + .await; + + HistoricalUptimeUpdater::start(storage.to_owned(), &task_manager); + } + + // start 'rewarding' if its enabled and there exists source for performance data + if config.rewarding.enabled && has_performance_data { + epoch_operations::ensure_rewarding_permission(&nyxd_client).await?; + EpochAdvancer::start( + nyxd_client, + &mixnet_contract_cache_state, + &node_status_cache_state, + described_nodes_cache.clone(), + &storage, + &task_manager, + ); + } + + // finally start a background task watching the contract changes and requesting + // self-described cache refresh upon being close to key rotation rollover + KeyRotationController::new( + describe_cache_refresh_requester, + contract_cache_watcher, + mixnet_contract_cache_state, + ) + .start(task_manager.subscribe_named("KeyRotationController")); + + let bind_address = config.base.bind_address.to_owned(); + let server = router.build_server(&bind_address).await?; + + let cancellation_token = CancellationToken::new(); + let shutdown_button = cancellation_token.clone(); + let axum_shutdown_receiver = cancellation_token.cancelled_owned(); + let server_handle = tokio::spawn(async move { + { + info!("Started Axum HTTP V2 server on {bind_address}"); + server.run(axum_shutdown_receiver).await + } + }); + + let shutdown = ShutdownHandles::new(task_manager, server_handle, shutdown_button); + + Ok(shutdown) } pub(crate) async fn execute(args: Args) -> anyhow::Result<()> { diff --git a/nym-api/src/support/config/mod.rs b/nym-api/src/support/config/mod.rs index 77adbc273aa..cdb0a64c73f 100644 --- a/nym-api/src/support/config/mod.rs +++ b/nym-api/src/support/config/mod.rs @@ -70,6 +70,8 @@ pub(crate) const CHAIN_STALL_THRESHOLD: Duration = Duration::from_secs(5 * 60); // so this default is more than enough pub(crate) const DEFAULT_CONTRACT_DETAILS_CACHE_TTL: Duration = Duration::from_secs(60 * 60); +pub(crate) const DEFAULT_NODE_SIGNERS_CACHE_REFRESH_INTERVAL: Duration = Duration::from_secs(600); + const DEFAULT_MONITOR_THRESHOLD: u8 = 60; const DEFAULT_MIN_MIXNODE_RELIABILITY: u8 = 50; const DEFAULT_MIN_GATEWAY_RELIABILITY: u8 = 20; @@ -127,6 +129,9 @@ pub struct Config { pub rewarding: Rewarding, + #[serde(default)] + pub signers_cache: SignersCache, + #[serde(alias = "coconut_signer")] pub ecash_signer: EcashSigner, @@ -152,6 +157,7 @@ impl Config { describe_cache: Default::default(), contracts_info_cache: Default::default(), rewarding: Default::default(), + signers_cache: Default::default(), ecash_signer: EcashSigner::new_default(id.as_ref()), address_cache: Default::default(), } @@ -414,6 +420,38 @@ impl Default for PerformanceProviderDebug { } } +#[derive(Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct SignersCache { + pub enabled: bool, + + pub debug: SignersCacheDebug, +} + +impl Default for SignersCache { + fn default() -> Self { + SignersCache { + enabled: true, + debug: Default::default(), + } + } +} + +#[derive(Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct SignersCacheDebug { + // TODO: make it into a decaying function so that if multiple signers are down, + // the refresh interval would decrease + #[serde(with = "humantime_serde")] + pub refresh_interval: Duration, +} + +impl Default for SignersCacheDebug { + fn default() -> Self { + SignersCacheDebug { + refresh_interval: DEFAULT_NODE_SIGNERS_CACHE_REFRESH_INTERVAL, + } + } +} + #[derive(Debug, PartialEq, Eq)] pub struct AddressCacheConfig { pub time_to_live: Duration, diff --git a/nym-api/src/support/http/state/mod.rs b/nym-api/src/support/http/state/mod.rs index e9114b375fa..7a9bb8abb94 100644 --- a/nym-api/src/support/http/state/mod.rs +++ b/nym-api/src/support/http/state/mod.rs @@ -106,6 +106,12 @@ impl FromRef for MixnetContractCache { } } +impl FromRef for SharedCache { + fn from_ref(app_state: &AppState) -> Self { + app_state.ecash_signers_cache.clone() + } +} + impl AppState { pub(crate) fn private_signing_key(&self) -> &ed25519::PrivateKey { // even though we have to go through ecash state, the key is always available diff --git a/nym-wallet/Cargo.lock b/nym-wallet/Cargo.lock index 32fdd73dfc0..2b9ac7375d9 100644 --- a/nym-wallet/Cargo.lock +++ b/nym-wallet/Cargo.lock @@ -2541,18 +2541,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "getset" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3586f256131df87204eb733da72e3d3eb4f343c639f4b7be279ac7c48baeafe" -dependencies = [ - "proc-macro-error2", - "proc-macro2", - "quote", - "syn 2.0.100", -] - [[package]] name = "ghash" version = "0.5.1" @@ -4020,7 +4008,6 @@ dependencies = [ "cosmrs", "cosmwasm-std", "ecdsa", - "getset", "hex", "humantime-serde", "nym-coconut-dkg-common", @@ -4029,6 +4016,7 @@ dependencies = [ "nym-contracts-common", "nym-credentials-interface", "nym-crypto", + "nym-ecash-signer-check-types", "nym-ecash-time", "nym-mixnet-contract-common", "nym-network-defaults", @@ -4171,6 +4159,21 @@ dependencies = [ "thiserror 2.0.12", ] +[[package]] +name = "nym-ecash-signer-check-types" +version = "0.1.0" +dependencies = [ + "nym-coconut-dkg-common", + "nym-crypto", + "semver", + "serde", + "thiserror 2.0.12", + "time", + "tracing", + "url", + "utoipa", +] + [[package]] name = "nym-ecash-time" version = "0.1.0" @@ -5414,28 +5417,6 @@ dependencies = [ "version_check", ] -[[package]] -name = "proc-macro-error-attr2" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" -dependencies = [ - "proc-macro2", - "quote", -] - -[[package]] -name = "proc-macro-error2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" -dependencies = [ - "proc-macro-error-attr2", - "proc-macro2", - "quote", - "syn 2.0.100", -] - [[package]] name = "proc-macro-hack" version = "0.5.20+deprecated" From d6deca201a2e83356417be0b18a3ad94350bada2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C4=99drzej=20Stuczy=C5=84ski?= Date: Thu, 10 Jul 2025 17:04:47 +0100 Subject: [PATCH 06/10] bugfixes and endpoint improvements --- .../src/dealer_information.rs | 2 +- .../src/helper_traits.rs | 2 +- common/ecash-signer-check/Cargo.toml | 1 - .../nym-api-requests/src/models/network.rs | 27 +++++++++++++++++++ nym-api/src/signers_cache/handlers.rs | 1 + nym-api/src/signers_cache/mod.rs | 5 +++- nym-api/src/support/caching/refresher.rs | 20 ++++++++++++++ nym-api/src/support/config/mod.rs | 8 ++++++ 8 files changed, 62 insertions(+), 4 deletions(-) diff --git a/common/ecash-signer-check-types/src/dealer_information.rs b/common/ecash-signer-check-types/src/dealer_information.rs index ffdcd7a0a15..b589fecb105 100644 --- a/common/ecash-signer-check-types/src/dealer_information.rs +++ b/common/ecash-signer-check-types/src/dealer_information.rs @@ -60,7 +60,7 @@ impl RawDealerInformation { })?, owner_address: self.owner_address.clone(), node_index: self.node_index, - public_key: self.announce_address.parse().map_err(|source| { + public_key: self.public_key.parse().map_err(|source| { MalformedDealer::InvalidDealerPubkey { dealer_url: self.announce_address.clone(), source, diff --git a/common/ecash-signer-check-types/src/helper_traits.rs b/common/ecash-signer-check-types/src/helper_traits.rs index 837c2538aa8..c6d6becd331 100644 --- a/common/ecash-signer-check-types/src/helper_traits.rs +++ b/common/ecash-signer-check-types/src/helper_traits.rs @@ -89,7 +89,7 @@ pub trait SignerResponse: Verifiable + TimestampedResponse { now: OffsetDateTime, stale_response_threshold: Duration, ) -> bool { - if !self.verify_signature(&pub_key) { + if !self.verify_signature(pub_key) { warn!("failed signature verification on chain status response"); return false; } diff --git a/common/ecash-signer-check/Cargo.toml b/common/ecash-signer-check/Cargo.toml index b521591882b..be22872ad8c 100644 --- a/common/ecash-signer-check/Cargo.toml +++ b/common/ecash-signer-check/Cargo.toml @@ -23,6 +23,5 @@ nym-validator-client = { path = "../client-libs/validator-client" } nym-network-defaults = { path = "../network-defaults" } nym-ecash-signer-check-types = { path = "../ecash-signer-check-types" } - [lints] workspace = true diff --git a/nym-api/nym-api-requests/src/models/network.rs b/nym-api/nym-api-requests/src/models/network.rs index 333639fa124..8305f81f5d3 100644 --- a/nym-api/nym-api-requests/src/models/network.rs +++ b/nym-api/nym-api-requests/src/models/network.rs @@ -30,6 +30,8 @@ pub struct SignersStatusResponseBody { pub as_at: OffsetDateTime, pub overview: SignersStatusOverview, + + pub results: Vec, } pub type TypedSignerResult = SignerResult< @@ -39,6 +41,31 @@ pub type TypedSignerResult = SignerResult< ChainBlocksStatusResponse, >; +#[derive(Serialize, Deserialize, ToSchema, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct MinimalSignerResult { + pub announce_address: String, + pub owner_address: String, + pub node_index: u64, + pub public_key: String, + + pub local_chain_working: bool, + pub credential_issuance_available: bool, +} + +impl From<&TypedSignerResult> for MinimalSignerResult { + fn from(result: &TypedSignerResult) -> MinimalSignerResult { + MinimalSignerResult { + announce_address: result.information.announce_address.clone(), + owner_address: result.information.owner_address.clone(), + node_index: result.information.node_index, + public_key: result.information.public_key.clone(), + local_chain_working: result.chain_available(), + credential_issuance_available: result.signing_available(), + } + } +} + #[derive(Serialize, Deserialize, ToSchema, Clone, Debug)] #[serde(rename_all = "camelCase")] pub struct DetailedSignersStatusResponseBody { diff --git a/nym-api/src/signers_cache/handlers.rs b/nym-api/src/signers_cache/handlers.rs index a1ab99592e3..13e5a85e04a 100644 --- a/nym-api/src/signers_cache/handlers.rs +++ b/nym-api/src/signers_cache/handlers.rs @@ -45,6 +45,7 @@ pub(crate) async fn signers_status( SignersStatusResponseBody { as_at, overview: SignersStatusOverview::new(&cached.signer_results), + results: cached.signer_results.iter().map(Into::into).collect(), } .sign(state.private_signing_key()), )) diff --git a/nym-api/src/signers_cache/mod.rs b/nym-api/src/signers_cache/mod.rs index c43a27ed5b9..f79e1a1f1da 100644 --- a/nym-api/src/signers_cache/mod.rs +++ b/nym-api/src/signers_cache/mod.rs @@ -22,6 +22,9 @@ pub(crate) fn start_refresher( ) .named("signers-cache-refresher"); let shared_cache = refresher.get_shared_cache(); - refresher.start(task_manager.subscribe_named("signers-cache-refresher")); + refresher.start_with_delay( + task_manager.subscribe_named("signers-cache-refresher"), + config.debug.refresher_start_delay, + ); shared_cache } diff --git a/nym-api/src/support/caching/refresher.rs b/nym-api/src/support/caching/refresher.rs index 616aa00d008..cfb412e4493 100644 --- a/nym-api/src/support/caching/refresher.rs +++ b/nym-api/src/support/caching/refresher.rs @@ -266,6 +266,26 @@ where tokio::spawn(async move { self.run(task_client).await }); } + pub fn start_with_delay(mut self, mut task_client: TaskClient, delay: Duration) + where + T: Send + Sync + 'static, + E: Send + Sync + 'static, + S: Send + Sync + 'static, + { + tokio::spawn(async move { + let sleep = tokio::time::sleep(delay); + tokio::select! { + biased; + _ = task_client.recv() => { + trace!("{}: Received shutdown", self.name); + return + } + _ = sleep => {}, + } + self.run(task_client).await + }); + } + pub fn start_with_watcher(self, task_client: TaskClient) -> CacheUpdateWatcher where T: Send + Sync + 'static, diff --git a/nym-api/src/support/config/mod.rs b/nym-api/src/support/config/mod.rs index cdb0a64c73f..612de6d815e 100644 --- a/nym-api/src/support/config/mod.rs +++ b/nym-api/src/support/config/mod.rs @@ -71,6 +71,8 @@ pub(crate) const CHAIN_STALL_THRESHOLD: Duration = Duration::from_secs(5 * 60); pub(crate) const DEFAULT_CONTRACT_DETAILS_CACHE_TTL: Duration = Duration::from_secs(60 * 60); pub(crate) const DEFAULT_NODE_SIGNERS_CACHE_REFRESH_INTERVAL: Duration = Duration::from_secs(600); +pub(crate) const DEFAULT_NODE_SIGNERS_CACHE_REFRESHER_START_DELAY: Duration = + Duration::from_secs(30); const DEFAULT_MONITOR_THRESHOLD: u8 = 60; const DEFAULT_MIN_MIXNODE_RELIABILITY: u8 = 50; @@ -442,12 +444,18 @@ pub struct SignersCacheDebug { // the refresh interval would decrease #[serde(with = "humantime_serde")] pub refresh_interval: Duration, + + // give it some time so that the actual api on THIS singer could start + // and it wouldn't self-report itself as being down + #[serde(with = "humantime_serde")] + pub refresher_start_delay: Duration, } impl Default for SignersCacheDebug { fn default() -> Self { SignersCacheDebug { refresh_interval: DEFAULT_NODE_SIGNERS_CACHE_REFRESH_INTERVAL, + refresher_start_delay: DEFAULT_NODE_SIGNERS_CACHE_REFRESHER_START_DELAY, } } } From 9972e4bb4ee3aa50e522bbbab6284ea30a84d5ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C4=99drzej=20Stuczy=C5=84ski?= Date: Thu, 10 Jul 2025 17:14:29 +0100 Subject: [PATCH 07/10] nym api test clippy --- nym-api/src/ecash/tests/mod.rs | 4 +++- nym-api/src/main.rs | 3 +-- nym-api/tests/public-api/nym_nodes.rs | 4 ++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/nym-api/src/ecash/tests/mod.rs b/nym-api/src/ecash/tests/mod.rs index faa142c2eaa..2152ae7274f 100644 --- a/nym-api/src/ecash/tests/mod.rs +++ b/nym-api/src/ecash/tests/mod.rs @@ -32,9 +32,10 @@ use cw3::{Proposal, ProposalResponse, Vote, VoteInfo, VoteResponse, Votes}; use cw4::{Cw4Contract, MemberResponse}; use nym_api_requests::ecash::models::{ IssuedTicketbooksChallengeCommitmentRequestBody, IssuedTicketbooksChallengeCommitmentResponse, - IssuedTicketbooksForResponse, SignableMessageBody, + IssuedTicketbooksForResponse, }; use nym_api_requests::ecash::{BlindSignRequestBody, BlindedSignatureResponse}; +use nym_api_requests::signable::SignableMessageBody; use nym_coconut_dkg_common::dealer::{ DealerDetails, DealerDetailsResponse, DealerType, RegisteredDealerDetails, }; @@ -1277,6 +1278,7 @@ impl TestFixture { AppState { nyxd_client, chain_status_cache: ChainStatusCache::new(Duration::from_secs(42)), + ecash_signers_cache: Default::default(), address_info_cache: AddressInfoCache::new(Duration::from_secs(42), 1000), forced_refresh: ForcedRefresh::new(true), mixnet_contract_cache: MixnetContractCache::new(), diff --git a/nym-api/src/main.rs b/nym-api/src/main.rs index a4ce80521b4..a36027bd3ce 100644 --- a/nym-api/src/main.rs +++ b/nym-api/src/main.rs @@ -8,7 +8,6 @@ use ::nym_config::defaults::setup_env; use clap::Parser; use mixnet_contract_cache::cache::MixnetContractCache; use node_status_api::NodeStatusCache; -use nym_bin_common::logging::setup_tracing_logger; use support::nyxd; use tracing::{info, trace}; @@ -34,7 +33,7 @@ async fn main() -> Result<(), anyhow::Error> { // instrument tokio console subscriber needs RUSTFLAGS="--cfg tokio_unstable" at build time console_subscriber::init(); } else { - setup_tracing_logger(); + nym_bin_common::logging::setup_tracing_logger(); }} info!("Starting nym api..."); diff --git a/nym-api/tests/public-api/nym_nodes.rs b/nym-api/tests/public-api/nym_nodes.rs index dda646b3b82..425a1156202 100644 --- a/nym-api/tests/public-api/nym_nodes.rs +++ b/nym-api/tests/public-api/nym_nodes.rs @@ -1,5 +1,5 @@ use crate::utils::{base_url, get_any_node_id, make_request, test_client, validate_json_response}; -use chrono::Utc; +use time::OffsetDateTime; #[tokio::test] async fn test_get_bonded_nodes() -> Result<(), String> { @@ -83,7 +83,7 @@ async fn test_get_annotation_for_node() -> Result<(), String> { #[tokio::test] async fn test_get_historical_performance() -> Result<(), String> { let id = get_any_node_id().await?; - let date = Utc::now().date_naive().to_string(); + let date = OffsetDateTime::now_utc().date().to_string(); let url = format!("{}/v1/nym-nodes/historical-performance/{}", base_url()?, id); let res = test_client() .get(&url) From 72ce3b63a2414e172dbbec2e415dfb2b3d9c8afe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C4=99drzej=20Stuczy=C5=84ski?= Date: Fri, 11 Jul 2025 08:59:05 +0100 Subject: [PATCH 08/10] added threshold information to the response --- common/ecash-signer-check/src/client_check.rs | 8 +++--- common/ecash-signer-check/src/lib.rs | 25 +++++++++++++------ .../nym-api-requests/src/models/network.rs | 10 +++++++- nym-api/src/signers_cache/cache/mod.rs | 4 +-- nym-api/src/signers_cache/cache/refresher.rs | 4 +-- nym-api/src/signers_cache/handlers.rs | 19 +++++++++++--- 6 files changed, 49 insertions(+), 21 deletions(-) diff --git a/common/ecash-signer-check/src/client_check.rs b/common/ecash-signer-check/src/client_check.rs index a932b676fd4..27bfac74f2f 100644 --- a/common/ecash-signer-check/src/client_check.rs +++ b/common/ecash-signer-check/src/client_check.rs @@ -196,22 +196,22 @@ pub(crate) async fn check_client( ) -> TypedSignerResult { let dealer_information = RawDealerInformation::new(&dealer_details, contract_share); - // 6. attempt to construct client instances out of them + // 7. attempt to construct client instances out of them let Ok(parsed_information) = dealer_information.parse() else { return SignerStatus::ProvidedInvalidDetails.with_details(dealer_information, dkg_epoch); }; let mut client = ClientUnderTest::new(&parsed_information.announce_address); - // 7. check basic connection status - can you retrieve build information? + // 8. check basic connection status - can you retrieve build information? if !client.try_retrieve_build_information().await { return SignerStatus::Unreachable.with_details(dealer_information, dkg_epoch); } - // 8. check perceived chain status + // 9. check perceived chain status let local_chain_status = client.check_local_chain().await; - // 9. check signer status + // 10. check signer status let signing_status = client.check_signing_status().await; SignerStatus::Tested { diff --git a/common/ecash-signer-check/src/lib.rs b/common/ecash-signer-check/src/lib.rs index 772962a5f0f..2915119a272 100644 --- a/common/ecash-signer-check/src/lib.rs +++ b/common/ecash-signer-check/src/lib.rs @@ -28,11 +28,16 @@ pub type TypedSignerResult = SignerResult< pub type LocalChainStatus = Status; pub type SigningStatus = Status; +pub struct SignersTestResult { + pub threshold: Option, + pub results: Vec, +} + pub async fn check_signers( rpc_endpoint: Url, // details such as denoms, prefixes, etc. network_details: NymNetworkDetails, -) -> Result, SignerCheckError> { +) -> Result { // 1. create nyx client instance let client = QueryHttpRpcNyxdClient::connect_with_network_details( rpc_endpoint.as_str(), @@ -43,9 +48,7 @@ pub async fn check_signers( check_signers_with_client(&client).await } -pub async fn check_signers_with_client( - client: &C, -) -> Result, SignerCheckError> +pub async fn check_signers_with_client(client: &C) -> Result where C: DkgQueryClient + Sync, { @@ -55,13 +58,19 @@ where .await .map_err(SignerCheckError::dkg_contract_query_failure)?; - // 3. retrieve information on current DKG dealers (i.e. eligible signers) + // 3. retrieve the dkg threshold as reference point + let threshold = client + .get_epoch_threshold(dkg_epoch.epoch_id) + .await + .map_err(SignerCheckError::dkg_contract_query_failure)?; + + // 4. retrieve information on current DKG dealers (i.e. eligible signers) let dealers = client .get_all_current_dealers() .await .map_err(SignerCheckError::dkg_contract_query_failure)?; - // 4. retrieve their published keys (if available) + // 5. retrieve their published keys (if available) let shares: HashMap<_, _> = client .get_all_verification_key_shares(dkg_epoch.epoch_id) .await @@ -70,7 +79,7 @@ where .map(|share| (share.node_index, share)) .collect(); - // 5. for each dealer attempt to perform the checks + // 6. for each dealer attempt to perform the checks let results = dealers .into_iter() .map(|d| { @@ -81,5 +90,5 @@ where .collect::>() .await; - Ok(results) + Ok(SignersTestResult { threshold, results }) } diff --git a/nym-api/nym-api-requests/src/models/network.rs b/nym-api/nym-api-requests/src/models/network.rs index 8305f81f5d3..e998656c678 100644 --- a/nym-api/nym-api-requests/src/models/network.rs +++ b/nym-api/nym-api-requests/src/models/network.rs @@ -84,6 +84,9 @@ pub struct SignersStatusOverview { #[schema(value_type = Option)] pub epoch_id: Option, + pub signing_threshold: Option, + pub threshold_available: Option, + pub total_signers: usize, pub unreachable_signers: usize, pub malformed_signers: usize, @@ -106,7 +109,7 @@ pub struct SignersStatusOverview { } impl SignersStatusOverview { - pub fn new(results: &[TypedSignerResult]) -> Self { + pub fn new(results: &[TypedSignerResult], signing_threshold: Option) -> Self { let epoch_id = results.first().map(|r| r.dkg_epoch_id); let mut unreachable_signers = 0; @@ -157,6 +160,11 @@ impl SignersStatusOverview { SignersStatusOverview { epoch_id, + signing_threshold, + threshold_available: signing_threshold.map(|threshold| { + (working_local_chain as u64) >= threshold + && (working_credential_issuance as u64) >= threshold + }), total_signers: results.len(), unreachable_signers, malformed_signers, diff --git a/nym-api/src/signers_cache/cache/mod.rs b/nym-api/src/signers_cache/cache/mod.rs index ed900f2a0d8..a44ab4da1bf 100644 --- a/nym-api/src/signers_cache/cache/mod.rs +++ b/nym-api/src/signers_cache/cache/mod.rs @@ -1,11 +1,11 @@ // Copyright 2025 - Nym Technologies SA // SPDX-License-Identifier: GPL-3.0-only -use nym_ecash_signer_check::TypedSignerResult; +use nym_ecash_signer_check::SignersTestResult; pub(crate) mod data; pub(crate) mod refresher; pub(crate) struct SignersCacheData { - pub(crate) signer_results: Vec, + pub(crate) signers_results: SignersTestResult, } diff --git a/nym-api/src/signers_cache/cache/refresher.rs b/nym-api/src/signers_cache/cache/refresher.rs index 41aa63144cc..aa0a341a6f5 100644 --- a/nym-api/src/signers_cache/cache/refresher.rs +++ b/nym-api/src/signers_cache/cache/refresher.rs @@ -27,7 +27,7 @@ impl SignersCacheDataProvider { } async fn refresh(&self) -> Result { - let signer_results = check_signers_with_client(&self.nyxd_client).await?; - Ok(SignersCacheData { signer_results }) + let signers_results = check_signers_with_client(&self.nyxd_client).await?; + Ok(SignersCacheData { signers_results }) } } diff --git a/nym-api/src/signers_cache/handlers.rs b/nym-api/src/signers_cache/handlers.rs index 13e5a85e04a..23046caad10 100644 --- a/nym-api/src/signers_cache/handlers.rs +++ b/nym-api/src/signers_cache/handlers.rs @@ -44,8 +44,16 @@ pub(crate) async fn signers_status( Ok(output.to_response( SignersStatusResponseBody { as_at, - overview: SignersStatusOverview::new(&cached.signer_results), - results: cached.signer_results.iter().map(Into::into).collect(), + overview: SignersStatusOverview::new( + &cached.signers_results.results, + cached.signers_results.threshold, + ), + results: cached + .signers_results + .results + .iter() + .map(Into::into) + .collect(), } .sign(state.private_signing_key()), )) @@ -76,8 +84,11 @@ pub(crate) async fn signers_status_detailed( Ok(output.to_response( DetailedSignersStatusResponseBody { as_at, - overview: SignersStatusOverview::new(&cached.signer_results), - details: cached.signer_results.clone(), + overview: SignersStatusOverview::new( + &cached.signers_results.results, + cached.signers_results.threshold, + ), + details: cached.signers_results.results.clone(), } .sign(state.private_signing_key()), )) From f9633502ff5571456ae2d9f4a944aa62e7a731ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C4=99drzej=20Stuczy=C5=84ski?= Date: Mon, 14 Jul 2025 09:30:15 +0100 Subject: [PATCH 09/10] chore: delay to Feta --- common/ecash-signer-check/src/client_check.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/common/ecash-signer-check/src/client_check.rs b/common/ecash-signer-check/src/client_check.rs index 27bfac74f2f..ad4ddfc9831 100644 --- a/common/ecash-signer-check/src/client_check.rs +++ b/common/ecash-signer-check/src/client_check.rs @@ -19,16 +19,16 @@ pub(crate) mod chain_status { // Dorina pub(crate) const MINIMUM_VERSION_LEGACY: semver::Version = semver::Version::new(1, 1, 51); - // Emmental - pub(crate) const MINIMUM_VERSION: semver::Version = semver::Version::new(1, 1, 62); + // Feta + pub(crate) const MINIMUM_VERSION: semver::Version = semver::Version::new(1, 1, 63); } pub(crate) mod signing_status { // Magura (possibly earlier) pub(crate) const MINIMUM_LEGACY_VERSION: semver::Version = semver::Version::new(1, 1, 46); - // Emmental - pub(crate) const MINIMUM_VERSION: semver::Version = semver::Version::new(1, 1, 62); + // Feta + pub(crate) const MINIMUM_VERSION: semver::Version = semver::Version::new(1, 1, 63); } struct ClientUnderTest { From 8db4bab77e3fd247e529046cf87651009ab3a3e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C4=99drzej=20Stuczy=C5=84ski?= Date: Wed, 23 Jul 2025 09:12:30 +0100 Subject: [PATCH 10/10] delay to gruyere --- common/ecash-signer-check/src/client_check.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/common/ecash-signer-check/src/client_check.rs b/common/ecash-signer-check/src/client_check.rs index ad4ddfc9831..b5c367794d6 100644 --- a/common/ecash-signer-check/src/client_check.rs +++ b/common/ecash-signer-check/src/client_check.rs @@ -19,16 +19,16 @@ pub(crate) mod chain_status { // Dorina pub(crate) const MINIMUM_VERSION_LEGACY: semver::Version = semver::Version::new(1, 1, 51); - // Feta - pub(crate) const MINIMUM_VERSION: semver::Version = semver::Version::new(1, 1, 63); + // Gruyere + pub(crate) const MINIMUM_VERSION: semver::Version = semver::Version::new(1, 1, 64); } pub(crate) mod signing_status { // Magura (possibly earlier) pub(crate) const MINIMUM_LEGACY_VERSION: semver::Version = semver::Version::new(1, 1, 46); - // Feta - pub(crate) const MINIMUM_VERSION: semver::Version = semver::Version::new(1, 1, 63); + // Gruyere + pub(crate) const MINIMUM_VERSION: semver::Version = semver::Version::new(1, 1, 64); } struct ClientUnderTest {