feat: key rotation (#5777)

* wip

* wip: wrap node's sphinx key with a manager

* wip: choosing correct key for packet processing

* further propagation of key rotation information

* attaching key rotation information to reply surbs

* added basic key rotation information to mixnet contract

* wip: introducing cached queries for key rotation info from nym api

* unified nym-api contract cache refreshing

* finish packet decoding

* multi api client + retrieving rotation id

* rotating sphinx key files

* logic for migrating config file

* wip: putting new sphinx keys to self described endpoints

* processing loop of KeyRotationController

* fixed sphinx key loading

* rotating bloomfilters

* wired up KeyRotationController

* flushing bloomfilters to disk and loading

* most of nym-node changes

* post rebase fixes

* fixes due to backwards compatible hostkeys

* split http state.rs file

* dont use deprecated fields

* fixed backwards compatible deserialisation of host information

* split up node describe cache

* added a dedicated CacheRefresher listener to perform full refresh outside the set interval

* controlling announced sphinx keys within nym-api

* retrieving rotation id when pulling topology

* split nym-nodes http handlers

* v2 nym-api endpoints to retrieve nodes with additional metadata information

* bug fixes...

* additional bugfixes and guards against stuck epoch

* testnet manager: set first nym-api as the rewarder

* fixed host information deserialisation

* fixed panic during first key rotation

* post rebase fixes

* clippy

* more guards against stuck epochs

* added helper method to reset node's sphinx key

* instantiate mixnet contract with custom key rotation validity

* additional bugfixes and debugging nym-api deadlock

* passing shutdown to nym apis client

* remove dead test

* post rebasing fixes

* missing MixnetQueryClient variants

* remove usage of deprecated methods in sdk example

* fix: incorrect method signature

* post rebasing fixes

* attempt to retrieve key rotation id before doing any config migration work

* ignore tests relying on networking behaviour

* allow networking failures in certain tests
This commit is contained in:
Jędrzej Stuczyński
2025-06-03 12:22:51 +02:00
committed by GitHub
parent adbe0392ca
commit d8c84cc4d6
204 changed files with 9392 additions and 3819 deletions
Generated
+4 -2
View File
@@ -4912,6 +4912,7 @@ dependencies = [
"tendermint-rpc",
"thiserror 2.0.12",
"time",
"tracing",
"ts-rs",
"utoipa",
]
@@ -6246,6 +6247,7 @@ dependencies = [
"nym-wireguard",
"nym-wireguard-types",
"rand 0.8.5",
"rand_chacha 0.3.1",
"serde",
"serde_json",
"sha2 0.10.9",
@@ -6484,6 +6486,7 @@ version = "0.3.0"
dependencies = [
"pem",
"tracing",
"zeroize",
]
[[package]]
@@ -6756,7 +6759,6 @@ dependencies = [
"nym-topology",
"rand 0.8.5",
"rand_chacha 0.3.1",
"serde",
"thiserror 2.0.12",
"tracing",
"wasm-bindgen",
@@ -6800,8 +6802,8 @@ dependencies = [
name = "nym-sphinx-forwarding"
version = "0.1.0"
dependencies = [
"nym-outfox",
"nym-sphinx-addressing",
"nym-sphinx-anonymous-replies",
"nym-sphinx-params",
"nym-sphinx-types",
"thiserror 2.0.12",
+1 -1
View File
@@ -349,7 +349,6 @@ utoipauto = "0.2"
uuid = "*"
vergen = { version = "=8.3.1", default-features = false }
walkdir = "2"
wasm-bindgen-test = "0.3.49"
x25519-dalek = "2.0.0"
zeroize = "1.7.0"
@@ -397,6 +396,7 @@ serde-wasm-bindgen = "0.6.5"
tsify = "0.4.5"
wasm-bindgen = "0.2.99"
wasm-bindgen-futures = "0.4.49"
wasm-bindgen-test = "0.3.49"
wasmtimer = "0.4.1"
web-sys = "0.3.76"
@@ -12,7 +12,7 @@ use crate::client::topology_control::{TopologyAccessor, TopologyReadPermit};
use nym_sphinx::acknowledgements::AckKey;
use nym_sphinx::addressing::clients::Recipient;
use nym_sphinx::anonymous_replies::requests::{AnonymousSenderTag, RepliableMessage, ReplyMessage};
use nym_sphinx::anonymous_replies::{ReplySurb, SurbEncryptionKey};
use nym_sphinx::anonymous_replies::ReplySurbWithKeyRotation;
use nym_sphinx::chunking::fragment::{Fragment, FragmentIdentifier};
use nym_sphinx::message::NymMessage;
use nym_sphinx::params::{PacketSize, PacketType};
@@ -44,7 +44,10 @@ pub enum PreparationError {
}
impl PreparationError {
fn return_surbs(self, returned_surbs: Vec<ReplySurb>) -> SurbWrappedPreparationError {
fn return_surbs(
self,
returned_surbs: Vec<ReplySurbWithKeyRotation>,
) -> SurbWrappedPreparationError {
SurbWrappedPreparationError {
source: self,
returned_surbs: Some(returned_surbs),
@@ -58,7 +61,7 @@ pub struct SurbWrappedPreparationError {
#[source]
source: PreparationError,
returned_surbs: Option<Vec<ReplySurb>>,
returned_surbs: Option<Vec<ReplySurbWithKeyRotation>>,
}
impl<T> From<T> for SurbWrappedPreparationError
@@ -268,10 +271,10 @@ where
}
}
async fn generate_reply_surbs_with_keys(
async fn generate_reply_surbs(
&mut self,
amount: usize,
) -> Result<(Vec<ReplySurb>, Vec<SurbEncryptionKey>), PreparationError> {
) -> Result<Vec<ReplySurbWithKeyRotation>, PreparationError> {
let topology_permit = self.topology_access.get_read_permit().await;
let topology = self.get_topology(&topology_permit)?;
@@ -281,19 +284,14 @@ where
topology,
)?;
let reply_keys = reply_surbs
.iter()
.map(|s| *s.encryption_key())
.collect::<Vec<_>>();
Ok((reply_surbs, reply_keys))
Ok(reply_surbs)
}
pub(crate) async fn try_send_single_surb_message(
&mut self,
target: AnonymousSenderTag,
message: ReplyMessage,
reply_surb: ReplySurb,
reply_surb: ReplySurbWithKeyRotation,
is_extra_surb_request: bool,
) -> Result<(), SurbWrappedPreparationError> {
let msg = NymMessage::new_reply(message);
@@ -347,7 +345,7 @@ where
pub(crate) async fn try_request_additional_reply_surbs(
&mut self,
from: AnonymousSenderTag,
reply_surb: ReplySurb,
reply_surb: ReplySurbWithKeyRotation,
amount: u32,
) -> Result<(), SurbWrappedPreparationError> {
debug!("requesting {amount} reply SURBs from {from}");
@@ -387,7 +385,7 @@ where
&mut self,
target: AnonymousSenderTag,
fragments: Vec<FragmentWithMaxRetransmissions>,
reply_surbs: Vec<ReplySurb>,
reply_surbs: Vec<ReplySurbWithKeyRotation>,
lane: TransmissionLane,
) -> Result<(), SurbWrappedPreparationError> {
// TODO: technically this is performing an unnecessary cloning, but in the grand scheme of things
@@ -404,7 +402,7 @@ where
&mut self,
target: AnonymousSenderTag,
fragments: Vec<(TransmissionLane, FragmentWithMaxRetransmissions)>,
reply_surbs: Vec<ReplySurb>,
reply_surbs: Vec<ReplySurbWithKeyRotation>,
) -> Result<(), SurbWrappedPreparationError> {
let prepared_fragments = self
.prepare_reply_chunks_for_sending(
@@ -541,8 +539,12 @@ where
) -> Result<(), PreparationError> {
debug!("Sending additional reply SURBs with packet type {packet_type}");
let sender_tag = self.get_or_create_sender_tag(&recipient);
let (reply_surbs, reply_keys) =
self.generate_reply_surbs_with_keys(amount as usize).await?;
let reply_surbs = self.generate_reply_surbs(amount as usize).await?;
let reply_keys = reply_surbs
.iter()
.map(|s| *s.encryption_key())
.collect::<Vec<_>>();
let message = NymMessage::new_repliable(RepliableMessage::new_additional_surbs(
self.config.use_legacy_sphinx_format,
@@ -579,9 +581,12 @@ where
) -> Result<(), SurbWrappedPreparationError> {
debug!("Sending message with reply SURBs with packet type {packet_type}");
let sender_tag = self.get_or_create_sender_tag(&recipient);
let (reply_surbs, reply_keys) = self
.generate_reply_surbs_with_keys(num_reply_surbs as usize)
.await?;
let reply_surbs = self.generate_reply_surbs(num_reply_surbs as usize).await?;
let reply_keys = reply_surbs
.iter()
.map(|s| *s.encryption_key())
.collect::<Vec<_>>();
let message = NymMessage::new_repliable(RepliableMessage::new_data(
self.config.use_legacy_sphinx_format,
@@ -629,7 +634,7 @@ where
pub(crate) async fn prepare_reply_chunks_for_sending(
&mut self,
fragments: Vec<Fragment>,
reply_surbs: Vec<ReplySurb>,
reply_surbs: Vec<ReplySurbWithKeyRotation>,
) -> Result<Vec<PreparedFragment>, SurbWrappedPreparationError> {
debug_assert_eq!(
fragments.len(),
@@ -665,7 +670,7 @@ where
pub(crate) async fn try_prepare_single_reply_chunk_for_sending(
&mut self,
reply_surb: ReplySurb,
reply_surb: ReplySurbWithKeyRotation,
chunk: Fragment,
) -> Result<PreparedFragment, SurbWrappedPreparationError> {
let topology_permit = self.topology_access.get_read_permit().await;
@@ -11,7 +11,7 @@ use futures::StreamExt;
use log::{debug, error, info, trace, warn};
use nym_sphinx::addressing::clients::Recipient;
use nym_sphinx::anonymous_replies::requests::AnonymousSenderTag;
use nym_sphinx::anonymous_replies::ReplySurb;
use nym_sphinx::anonymous_replies::ReplySurbWithKeyRotation;
use nym_sphinx::chunking::fragment::FragmentIdentifier;
use nym_task::connections::{ConnectionId, TransmissionLane};
use nym_task::TaskClient;
@@ -499,7 +499,7 @@ where
async fn handle_received_surbs(
&mut self,
from: AnonymousSenderTag,
reply_surbs: Vec<ReplySurb>,
reply_surbs: Vec<ReplySurbWithKeyRotation>,
from_surb_request: bool,
) {
trace!("handling received surbs");
@@ -6,7 +6,7 @@ use futures::channel::{mpsc, oneshot};
use log::error;
use nym_sphinx::addressing::clients::Recipient;
use nym_sphinx::anonymous_replies::requests::AnonymousSenderTag;
use nym_sphinx::anonymous_replies::ReplySurb;
use nym_sphinx::anonymous_replies::ReplySurbWithKeyRotation;
use nym_task::connections::{ConnectionId, TransmissionLane};
use std::sync::Weak;
@@ -81,7 +81,7 @@ impl ReplyControllerSender {
pub(crate) fn send_additional_surbs(
&self,
sender_tag: AnonymousSenderTag,
reply_surbs: Vec<ReplySurb>,
reply_surbs: Vec<ReplySurbWithKeyRotation>,
from_surb_request: bool,
) -> Result<(), ReplyControllerSenderError> {
self.0
@@ -167,7 +167,7 @@ pub enum ReplyControllerMessage {
AdditionalSurbs {
sender_tag: AnonymousSenderTag,
reply_surbs: Vec<ReplySurb>,
reply_surbs: Vec<ReplySurbWithKeyRotation>,
from_surb_request: bool,
},
@@ -4,7 +4,7 @@
use async_trait::async_trait;
use log::{debug, error, warn};
use nym_topology::provider_trait::TopologyProvider;
use nym_topology::NymTopology;
use nym_topology::{NymTopology, NymTopologyMetadata};
use nym_validator_client::UserAgent;
use rand::prelude::SliceRandom;
use rand::thread_rng;
@@ -89,55 +89,84 @@ impl NymApiTopologyProvider {
let rewarded_set_fut = self.validator_client.get_current_rewarded_set();
let topology = if self.config.use_extended_topology {
let all_nodes_fut = self.validator_client.get_all_basic_nodes();
let all_nodes_fut = self.validator_client.get_all_basic_nodes_with_metadata();
// Join rewarded_set_fut and all_nodes_fut concurrently
let (rewarded_set, all_nodes) = futures::try_join!(rewarded_set_fut, all_nodes_fut)
let (rewarded_set, all_nodes_res) = futures::try_join!(rewarded_set_fut, all_nodes_fut)
.inspect_err(|err| error!("failed to get network nodes: {err}"))
.ok()?;
let metadata = all_nodes_res.metadata;
let all_nodes = all_nodes_res.nodes;
debug!(
"there are {} nodes on the network (before filtering)",
all_nodes.len()
);
let mut topology = NymTopology::new_empty(rewarded_set);
topology.add_additional_nodes(all_nodes.iter().filter(|n| {
n.performance.round_to_integer() >= self.config.min_node_performance()
}));
let nodes_filtered = all_nodes
.into_iter()
.filter(|n| n.performance.round_to_integer() >= self.config.min_node_performance())
.collect::<Vec<_>>();
topology
NymTopology::new(
NymTopologyMetadata::new(metadata.rotation_id, metadata.absolute_epoch_id),
rewarded_set,
Vec::new(),
)
.with_skimmed_nodes(&nodes_filtered)
} else {
// if we're not using extended topology, we're only getting active set mixnodes and gateways
let mixnodes_fut = self
.validator_client
.get_all_basic_active_mixing_assigned_nodes();
.get_all_basic_active_mixing_assigned_nodes_with_metadata();
// TODO: we really should be getting ACTIVE gateways only
let gateways_fut = self.validator_client.get_all_basic_entry_assigned_nodes();
let gateways_fut = self
.validator_client
.get_all_basic_entry_assigned_nodes_v2();
let (rewarded_set, mixnodes, gateways) =
let (rewarded_set, mixnodes_res, gateways_res) =
futures::try_join!(rewarded_set_fut, mixnodes_fut, gateways_fut)
.inspect_err(|err| {
error!("failed to get network nodes: {err}");
})
.ok()?;
let metadata = mixnodes_res.metadata;
let mixnodes = mixnodes_res.nodes;
if gateways_res.metadata != metadata {
warn!("inconsistent nodes metadata between mixnodes and gateways calls! {metadata:?} and {:?}", gateways_res.metadata);
return None;
}
let gateways = gateways_res.nodes;
debug!(
"there are {} mixnodes and {} gateways in total (before performance filtering)",
mixnodes.len(),
gateways.len()
);
let mut topology = NymTopology::new_empty(rewarded_set);
topology.add_additional_nodes(mixnodes.iter().filter(|m| {
m.performance.round_to_integer() >= self.config.min_mixnode_performance
}));
topology.add_additional_nodes(gateways.iter().filter(|m| {
m.performance.round_to_integer() >= self.config.min_gateway_performance
}));
let mut nodes = Vec::new();
for mix in mixnodes {
if mix.performance.round_to_integer() >= self.config.min_mixnode_performance {
nodes.push(mix)
}
}
for gateway in gateways {
if gateway.performance.round_to_integer() >= self.config.min_gateway_performance {
nodes.push(gateway)
}
}
topology
NymTopology::new(
NymTopologyMetadata::new(metadata.rotation_id, metadata.absolute_epoch_id),
rewarded_set,
Vec::new(),
)
.with_skimmed_nodes(&nodes)
};
if !topology.is_minimally_routable() {
+1 -1
View File
@@ -107,7 +107,7 @@ pub async fn gateways_for_init<R: Rng>(
log::debug!("Fetching list of gateways from: {nym_api}");
let gateways = client.get_all_basic_entry_assigned_nodes().await?;
let gateways = client.get_all_basic_entry_assigned_nodes_v2().await?.nodes;
info!("nym api reports {} gateways", gateways.len());
log::trace!("Gateways: {:#?}", gateways);
@@ -0,0 +1,8 @@
/*
* Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
* SPDX-License-Identifier: Apache-2.0
*/
-- default value of 0 implies 'unknown' variant
ALTER TABLE reply_surb
ADD COLUMN encoded_key_rotation TINYINT NOT NULL DEFAULT 0;
@@ -205,7 +205,10 @@ impl StorageManager {
) -> Result<Vec<StoredReplySurb>, sqlx::Error> {
sqlx::query_as!(
StoredReplySurb,
"SELECT * FROM reply_surb WHERE reply_surb_sender_id = ?",
r#"
SELECT reply_surb_sender_id, reply_surb, encoded_key_rotation as "encoded_key_rotation: u8" FROM reply_surb
WHERE reply_surb_sender_id = ?
"#,
sender_id
)
.fetch_all(&self.connection_pool)
@@ -230,10 +233,11 @@ impl StorageManager {
) -> Result<(), sqlx::Error> {
sqlx::query!(
r#"
INSERT INTO reply_surb(reply_surb_sender_id, reply_surb) VALUES (?, ?);
INSERT INTO reply_surb(reply_surb_sender_id, reply_surb, encoded_key_rotation) VALUES (?, ?, ?);
"#,
stored_reply_surb.reply_surb_sender_id,
stored_reply_surb.reply_surb
stored_reply_surb.reply_surb,
stored_reply_surb.encoded_key_rotation
)
.execute(&self.connection_pool)
.await?;
@@ -8,8 +8,10 @@ use nym_crypto::Digest;
use nym_sphinx::addressing::clients::{Recipient, RecipientBytes};
use nym_sphinx::anonymous_replies::encryption_key::EncryptionKeyDigest;
use nym_sphinx::anonymous_replies::requests::{AnonymousSenderTag, SENDER_TAG_SIZE};
use nym_sphinx::anonymous_replies::{ReplySurb, SurbEncryptionKey, SurbEncryptionKeySize};
use nym_sphinx::params::ReplySurbKeyDigestAlgorithm;
use nym_sphinx::anonymous_replies::{
ReplySurb, ReplySurbWithKeyRotation, SurbEncryptionKey, SurbEncryptionKeySize,
};
use nym_sphinx::params::{ReplySurbKeyDigestAlgorithm, SphinxKeyRotation};
#[derive(Debug, Clone)]
pub struct StoredSenderTag {
@@ -146,24 +148,40 @@ impl TryFrom<StoredSurbSender> for (AnonymousSenderTag, i64) {
pub struct StoredReplySurb {
pub reply_surb_sender_id: i64,
pub reply_surb: Vec<u8>,
// encodes only whether it's 'even', 'odd' or 'unknown' (default)
// and not the whole id because that's redundant
pub encoded_key_rotation: u8,
}
impl StoredReplySurb {
pub fn new(reply_surb_sender_id: i64, reply_surb: &ReplySurb) -> Self {
pub fn new(reply_surb_sender_id: i64, reply_surb: &ReplySurbWithKeyRotation) -> Self {
StoredReplySurb {
reply_surb_sender_id,
reply_surb: reply_surb.to_bytes(),
reply_surb: reply_surb.inner_reply_surb().to_bytes(),
encoded_key_rotation: reply_surb.key_rotation() as u8,
}
}
}
impl TryFrom<StoredReplySurb> for ReplySurb {
impl TryFrom<StoredReplySurb> for ReplySurbWithKeyRotation {
type Error = StorageError;
fn try_from(value: StoredReplySurb) -> Result<Self, Self::Error> {
ReplySurb::from_bytes(&value.reply_surb).map_err(|err| StorageError::CorruptedData {
details: format!("failed to recover the reply surb: {err}"),
})
let key_rotation =
SphinxKeyRotation::try_from(value.encoded_key_rotation).map_err(|err| {
StorageError::CorruptedData {
details: format!("stored key rotation was malformed: {err}"),
}
})?;
let reply_surb = ReplySurb::from_bytes(&value.reply_surb).map_err(|err| {
StorageError::CorruptedData {
details: format!("failed to recover the reply surb: {err}"),
}
})?;
Ok(reply_surb.with_key_rotation(key_rotation))
}
}
@@ -5,7 +5,7 @@ use dashmap::iter::Iter;
use dashmap::DashMap;
use log::trace;
use nym_sphinx::anonymous_replies::requests::AnonymousSenderTag;
use nym_sphinx::anonymous_replies::ReplySurb;
use nym_sphinx::anonymous_replies::ReplySurbWithKeyRotation;
use std::collections::VecDeque;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
@@ -134,7 +134,7 @@ impl ReceivedReplySurbsMap {
&self,
target: &AnonymousSenderTag,
amount: usize,
) -> (Option<Vec<ReplySurb>>, usize) {
) -> (Option<Vec<ReplySurbWithKeyRotation>>, usize) {
if let Some(mut entry) = self.inner.data.get_mut(target) {
let surbs_left = entry.items_left();
if surbs_left < self.min_surb_threshold() + amount {
@@ -150,7 +150,7 @@ impl ReceivedReplySurbsMap {
pub fn get_reply_surb_ignoring_threshold(
&self,
target: &AnonymousSenderTag,
) -> Option<(Option<ReplySurb>, usize)> {
) -> Option<(Option<ReplySurbWithKeyRotation>, usize)> {
self.inner
.data
.get_mut(target)
@@ -160,7 +160,7 @@ impl ReceivedReplySurbsMap {
pub fn get_reply_surb(
&self,
target: &AnonymousSenderTag,
) -> Option<(Option<ReplySurb>, usize)> {
) -> Option<(Option<ReplySurbWithKeyRotation>, usize)> {
self.inner.data.get_mut(target).map(|mut entry| {
let surbs_left = entry.items_left();
if surbs_left < self.min_surb_threshold() {
@@ -171,7 +171,7 @@ impl ReceivedReplySurbsMap {
})
}
pub fn insert_surbs<I: IntoIterator<Item = ReplySurb>>(
pub fn insert_surbs<I: IntoIterator<Item = ReplySurbWithKeyRotation>>(
&self,
target: &AnonymousSenderTag,
surbs: I,
@@ -189,14 +189,14 @@ impl ReceivedReplySurbsMap {
pub struct ReceivedReplySurbs {
// in the future we'd probably want to put extra data here to indicate when the SURBs got received
// so we could invalidate entries from the previous key rotations
data: VecDeque<ReplySurb>,
data: VecDeque<ReplySurbWithKeyRotation>,
pending_reception: u32,
surbs_last_received_at_timestamp: i64,
}
impl ReceivedReplySurbs {
fn new(initial_surbs: VecDeque<ReplySurb>) -> Self {
fn new(initial_surbs: VecDeque<ReplySurbWithKeyRotation>) -> Self {
ReceivedReplySurbs {
data: initial_surbs,
pending_reception: 0,
@@ -206,7 +206,7 @@ impl ReceivedReplySurbs {
#[cfg(all(not(target_arch = "wasm32"), feature = "fs-surb-storage"))]
pub fn new_retrieved(
surbs: Vec<ReplySurb>,
surbs: Vec<ReplySurbWithKeyRotation>,
surbs_last_received_at_timestamp: i64,
) -> ReceivedReplySurbs {
ReceivedReplySurbs {
@@ -217,7 +217,7 @@ impl ReceivedReplySurbs {
}
#[cfg(all(not(target_arch = "wasm32"), feature = "fs-surb-storage"))]
pub fn surbs_ref(&self) -> &VecDeque<ReplySurb> {
pub fn surbs_ref(&self) -> &VecDeque<ReplySurbWithKeyRotation> {
&self.data
}
@@ -243,7 +243,10 @@ impl ReceivedReplySurbs {
self.pending_reception = 0;
}
pub fn get_reply_surbs(&mut self, amount: usize) -> (Option<Vec<ReplySurb>>, usize) {
pub fn get_reply_surbs(
&mut self,
amount: usize,
) -> (Option<Vec<ReplySurbWithKeyRotation>>, usize) {
if self.items_left() < amount {
(None, self.items_left())
} else {
@@ -252,11 +255,11 @@ impl ReceivedReplySurbs {
}
}
pub fn get_reply_surb(&mut self) -> (Option<ReplySurb>, usize) {
pub fn get_reply_surb(&mut self) -> (Option<ReplySurbWithKeyRotation>, usize) {
(self.pop_surb(), self.items_left())
}
fn pop_surb(&mut self) -> Option<ReplySurb> {
fn pop_surb(&mut self) -> Option<ReplySurbWithKeyRotation> {
self.data.pop_front()
}
@@ -265,7 +268,10 @@ impl ReceivedReplySurbs {
}
// realistically we're always going to be getting multiple surbs at once
pub fn insert_reply_surbs<I: IntoIterator<Item = ReplySurb>>(&mut self, surbs: I) {
pub fn insert_reply_surbs<I: IntoIterator<Item = ReplySurbWithKeyRotation>>(
&mut self,
surbs: I,
) {
let mut v = surbs.into_iter().collect::<VecDeque<_>>();
trace!("storing {} surbs in the storage", v.len());
self.data.append(&mut v);
@@ -21,8 +21,8 @@ use nym_crypto::asymmetric::ed25519;
use nym_gateway_requests::registration::handshake::client_handshake;
use nym_gateway_requests::{
BinaryRequest, ClientControlRequest, ClientRequest, GatewayProtocolVersionExt,
SensitiveServerResponse, ServerResponse, SharedGatewayKey, SharedSymmetricKey,
CREDENTIAL_UPDATE_V2_PROTOCOL_VERSION, CURRENT_PROTOCOL_VERSION,
GatewayRequestsError, SensitiveServerResponse, ServerResponse, SharedGatewayKey,
SharedSymmetricKey, CREDENTIAL_UPDATE_V2_PROTOCOL_VERSION, CURRENT_PROTOCOL_VERSION,
};
use nym_sphinx::forwarding::packet::MixPacket;
use nym_statistics_common::clients::connection::ConnectionStatsEvent;
@@ -662,6 +662,7 @@ impl<C, St> GatewayClient<C, St> {
let supports_aes_gcm_siv = gw_protocol.supports_aes256_gcm_siv();
let supports_auth_v2 = gw_protocol.supports_authenticate_v2();
let supports_key_rotation_info = gw_protocol.supports_key_rotation_packet();
if !supports_aes_gcm_siv {
warn!("this gateway is on an old version that doesn't support AES256-GCM-SIV");
@@ -669,6 +670,9 @@ impl<C, St> GatewayClient<C, St> {
if !supports_auth_v2 {
warn!("this gateway is on an old version that doesn't support authentication v2")
}
if !supports_key_rotation_info {
warn!("this gateway is on an old version that doesn't support key rotation packets")
}
if self.authenticated {
debug!("Already authenticated");
@@ -849,6 +853,22 @@ impl<C, St> GatewayClient<C, St> {
}
}
fn mix_packet_to_ws_message(&self, packet: MixPacket) -> Result<Message, GatewayRequestsError> {
// note: into_ws_message encrypts the requests and adds a MAC on it. Perhaps it should
// be more explicit in the naming?
let req = if self.negotiated_protocol.supports_key_rotation_packet() {
BinaryRequest::ForwardSphinxV2 { packet }
} else {
BinaryRequest::ForwardSphinx { packet }
};
req.into_ws_message(
self.shared_key
.as_ref()
.expect("no shared key present even though we're authenticated!"),
)
}
pub async fn batch_send_mix_packets(
&mut self,
packets: Vec<MixPacket>,
@@ -877,13 +897,7 @@ impl<C, St> GatewayClient<C, St> {
let messages: Result<Vec<_>, _> = packets
.into_iter()
.map(|mix_packet| {
BinaryRequest::ForwardSphinx { packet: mix_packet }.into_ws_message(
self.shared_key
.as_ref()
.expect("no shared key present even though we're authenticated!"),
)
})
.map(|mix_packet| self.mix_packet_to_ws_message(mix_packet))
.collect();
if let Err(err) = self
@@ -949,13 +963,8 @@ impl<C, St> GatewayClient<C, St> {
if !self.connection.is_established() {
return Err(GatewayClientError::ConnectionNotEstablished);
}
// note: into_ws_message encrypts the requests and adds a MAC on it. Perhaps it should
// be more explicit in the naming?
let msg = BinaryRequest::ForwardSphinx { packet: mix_packet }.into_ws_message(
self.shared_key
.as_ref()
.expect("no shared key present even though we're authenticated!"),
)?;
let msg = self.mix_packet_to_ws_message(mix_packet)?;
self.send_with_reconnection_on_failure(msg).await
}
+11 -22
View File
@@ -3,11 +3,9 @@
use dashmap::DashMap;
use futures::StreamExt;
use nym_sphinx::addressing::nodes::NymNodeRoutingAddress;
use nym_sphinx::forwarding::packet::MixPacket;
use nym_sphinx::framing::codec::NymCodec;
use nym_sphinx::framing::packet::FramedNymPacket;
use nym_sphinx::params::PacketType;
use nym_sphinx::NymPacket;
use std::io;
use std::net::SocketAddr;
use std::ops::Deref;
@@ -49,12 +47,7 @@ impl Config {
pub trait SendWithoutResponse {
// Without response in this context means we will not listen for anything we might get back (not
// that we should get anything), including any possible io errors
fn send_without_response(
&self,
address: NymNodeRoutingAddress,
packet: NymPacket,
packet_type: PacketType,
) -> io::Result<()>;
fn send_without_response(&self, packet: MixPacket) -> io::Result<()>;
}
pub struct Client {
@@ -65,7 +58,7 @@ pub struct Client {
#[derive(Default, Clone)]
pub struct ActiveConnections {
inner: Arc<DashMap<NymNodeRoutingAddress, ConnectionSender>>,
inner: Arc<DashMap<SocketAddr, ConnectionSender>>,
}
impl ActiveConnections {
@@ -82,7 +75,7 @@ impl ActiveConnections {
}
impl Deref for ActiveConnections {
type Target = DashMap<NymNodeRoutingAddress, ConnectionSender>;
type Target = DashMap<SocketAddr, ConnectionSender>;
fn deref(&self) -> &Self::Target {
&self.inner
}
@@ -196,7 +189,7 @@ impl Client {
}
}
fn make_connection(&self, address: NymNodeRoutingAddress, pending_packet: FramedNymPacket) {
fn make_connection(&self, address: SocketAddr, pending_packet: FramedNymPacket) {
let (sender, receiver) = mpsc::channel(self.config.maximum_connection_buffer_size);
// this CAN'T fail because we just created the channel which has a non-zero capacity
@@ -233,7 +226,7 @@ impl Client {
connections_count.fetch_add(1, Ordering::SeqCst);
ManagedConnection::new(
address.into(),
address,
receiver,
initial_connection_timeout,
current_reconnection_attempt,
@@ -246,18 +239,14 @@ impl Client {
}
impl SendWithoutResponse for Client {
fn send_without_response(
&self,
address: NymNodeRoutingAddress,
packet: NymPacket,
packet_type: PacketType,
) -> io::Result<()> {
trace!("Sending packet to {address:?}");
let framed_packet = FramedNymPacket::new(packet, packet_type);
fn send_without_response(&self, packet: MixPacket) -> io::Result<()> {
let address = packet.next_hop_address();
trace!("Sending packet to {address}");
let framed_packet = FramedNymPacket::from(packet);
let Some(sender) = self.active_connections.get_mut(&address) else {
// there was never a connection to begin with
debug!("establishing initial connection to {}", address);
debug!("establishing initial connection to {address}");
// it's not a 'big' error, but we did not manage to send the packet, but queue the packet
// for sending for as soon as the connection is created
self.make_connection(address, framed_packet);
@@ -25,7 +25,9 @@ use nym_api_requests::models::{
NymNodeDescription, RewardEstimationResponse, StakeSaturationResponse,
};
use nym_api_requests::models::{LegacyDescribedGateway, MixNodeBondAnnotated};
use nym_api_requests::nym_nodes::{NodesByAddressesResponse, SkimmedNode};
use nym_api_requests::nym_nodes::{
NodesByAddressesResponse, SkimmedNode, SkimmedNodesWithMetadata,
};
use nym_coconut_dkg_common::types::EpochId;
use nym_http_api_client::UserAgent;
use nym_mixnet_contract_common::EpochRewardedSet;
@@ -46,6 +48,46 @@ use crate::rpc::http_client;
#[cfg(feature = "http-client")]
use crate::{DirectSigningHttpRpcValidatorClient, HttpRpcClient, QueryHttpRpcValidatorClient};
// a simple helper macro to define to repeatedly call a paged query until a full response is constructed
macro_rules! collect_paged_skimmed_v2 {
( $self:ident, $f: ident ) => {{
// unroll first loop iteration in order to obtain the metadata
let mut page = 0;
let res = $self
.nym_api
.$f(false, Some(page), None, $self.use_bincode)
.await?;
let mut nodes = res.nodes.data;
let metadata = res.metadata;
if res.nodes.pagination.total == nodes.len() {
return Ok(SkimmedNodesWithMetadata::new(nodes, metadata));
}
page += 1;
loop {
let mut res = $self
.nym_api
.$f(false, Some(page), None, $self.use_bincode)
.await?;
if metadata != res.metadata {
return Err(ValidatorClientError::InconsistentPagedMetadata);
}
nodes.append(&mut res.nodes.data);
if nodes.len() < res.nodes.pagination.total {
page += 1
} else {
break;
}
}
Ok(SkimmedNodesWithMetadata::new(nodes, metadata))
}};
}
#[must_use]
#[derive(Debug, Clone)]
pub struct Config {
@@ -425,103 +467,67 @@ impl NymApiClient {
/// retrieve basic information for nodes are capable of operating as an entry gateway
/// this includes legacy gateways and nym-nodes
#[deprecated(note = "use get_all_basic_entry_assigned_nodes_with_metadata instead")]
pub async fn get_all_basic_entry_assigned_nodes(
&self,
) -> Result<Vec<SkimmedNode>, ValidatorClientError> {
// TODO: deal with paging in macro or some helper function or something, because it's the same pattern everywhere
let mut page = 0;
let mut nodes = Vec::new();
self.get_all_basic_entry_assigned_nodes_v2()
.await
.map(|res| res.nodes)
}
loop {
let mut res = self
.nym_api
.get_basic_entry_assigned_nodes(false, Some(page), None, self.use_bincode)
.await?;
nodes.append(&mut res.nodes.data);
if nodes.len() < res.nodes.pagination.total {
page += 1
} else {
break;
}
}
Ok(nodes)
pub async fn get_all_basic_entry_assigned_nodes_v2(
&self,
) -> Result<SkimmedNodesWithMetadata, ValidatorClientError> {
collect_paged_skimmed_v2!(self, get_basic_entry_assigned_nodes_v2)
}
/// retrieve basic information for nodes that got assigned 'mixing' node in this epoch
/// this includes legacy mixnodes and nym-nodes
#[deprecated(note = "use get_all_basic_active_mixing_assigned_nodes_with_metadata instead")]
pub async fn get_all_basic_active_mixing_assigned_nodes(
&self,
) -> Result<Vec<SkimmedNode>, ValidatorClientError> {
// TODO: deal with paging in macro or some helper function or something, because it's the same pattern everywhere
let mut page = 0;
let mut nodes = Vec::new();
self.get_all_basic_active_mixing_assigned_nodes_with_metadata()
.await
.map(|res| res.nodes)
}
loop {
let mut res = self
.nym_api
.get_basic_active_mixing_assigned_nodes(false, Some(page), None, self.use_bincode)
.await?;
nodes.append(&mut res.nodes.data);
if nodes.len() < res.nodes.pagination.total {
page += 1
} else {
break;
}
}
Ok(nodes)
pub async fn get_all_basic_active_mixing_assigned_nodes_with_metadata(
&self,
) -> Result<SkimmedNodesWithMetadata, ValidatorClientError> {
collect_paged_skimmed_v2!(self, get_basic_active_mixing_assigned_nodes_v2)
}
/// retrieve basic information for nodes are capable of operating as a mixnode
/// this includes legacy mixnodes and nym-nodes
#[deprecated(note = "use get_all_basic_mixing_capable_nodes_with_metadata instead")]
pub async fn get_all_basic_mixing_capable_nodes(
&self,
) -> Result<Vec<SkimmedNode>, ValidatorClientError> {
// TODO: deal with paging in macro or some helper function or something, because it's the same pattern everywhere
let mut page = 0;
let mut nodes = Vec::new();
self.get_all_basic_mixing_capable_nodes_with_metadata()
.await
.map(|res| res.nodes)
}
loop {
let mut res = self
.nym_api
.get_basic_mixing_capable_nodes(false, Some(page), None, self.use_bincode)
.await?;
nodes.append(&mut res.nodes.data);
if nodes.len() < res.nodes.pagination.total {
page += 1
} else {
break;
}
}
Ok(nodes)
pub async fn get_all_basic_mixing_capable_nodes_with_metadata(
&self,
) -> Result<SkimmedNodesWithMetadata, ValidatorClientError> {
collect_paged_skimmed_v2!(self, get_basic_mixing_capable_nodes_v2)
}
/// retrieve basic information for all bonded nodes on the network
#[deprecated(note = "use get_all_basic_nodes_with_metadata instead")]
pub async fn get_all_basic_nodes(&self) -> Result<Vec<SkimmedNode>, ValidatorClientError> {
// TODO: deal with paging in macro or some helper function or something, because it's the same pattern everywhere
let mut page = 0;
let mut nodes = Vec::new();
self.get_all_basic_nodes_with_metadata()
.await
.map(|res| res.nodes)
}
loop {
let mut res = self
.nym_api
.get_basic_nodes(false, Some(page), None, self.use_bincode)
.await?;
nodes.append(&mut res.nodes.data);
if nodes.len() < res.nodes.pagination.total {
page += 1
} else {
break;
}
}
Ok(nodes)
pub async fn get_all_basic_nodes_with_metadata(
&self,
) -> Result<SkimmedNodesWithMetadata, ValidatorClientError> {
collect_paged_skimmed_v2!(self, get_basic_nodes_v2)
}
pub async fn health(&self) -> Result<ApiHealthResponse, ValidatorClientError> {
@@ -22,6 +22,9 @@ pub enum ValidatorClientError {
#[error("nyxd request failed: {0}")]
NyxdError(#[from] crate::nyxd::error::NyxdError),
#[error("the response metadata has changed between pages")]
InconsistentPagedMetadata,
#[error("No validator API url has been provided")]
NoAPIUrlAvailable,
}
@@ -14,11 +14,12 @@ use nym_api_requests::ecash::models::{
use nym_api_requests::ecash::VerificationKeyResponse;
use nym_api_requests::models::{
AnnotationResponse, ApiHealthResponse, BinaryBuildInformationOwned, ChainStatusResponse,
LegacyDescribedMixNode, NodePerformanceResponse, NodeRefreshBody, NymNodeDescription,
PerformanceHistoryResponse, RewardedSetResponse,
KeyRotationInfoResponse, LegacyDescribedMixNode, NodePerformanceResponse, NodeRefreshBody,
NymNodeDescription, PerformanceHistoryResponse, RewardedSetResponse,
};
use nym_api_requests::nym_nodes::{
NodesByAddressesRequestBody, NodesByAddressesResponse, PaginatedCachedNodesResponse,
NodesByAddressesRequestBody, NodesByAddressesResponse, PaginatedCachedNodesResponseV1,
PaginatedCachedNodesResponseV2,
};
use nym_api_requests::pagination::PaginatedResponse;
pub use nym_api_requests::{
@@ -62,7 +63,7 @@ pub trait NymApiClientExt: ApiClient {
async fn health(&self) -> Result<ApiHealthResponse, NymAPIError> {
self.get_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::API_STATUS_ROUTES,
routes::HEALTH,
],
@@ -75,7 +76,7 @@ pub trait NymApiClientExt: ApiClient {
async fn build_information(&self) -> Result<BinaryBuildInformationOwned, NymAPIError> {
self.get_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::API_STATUS_ROUTES,
routes::BUILD_INFORMATION,
],
@@ -87,7 +88,7 @@ pub trait NymApiClientExt: ApiClient {
#[deprecated]
#[instrument(level = "debug", skip(self))]
async fn get_mixnodes(&self) -> Result<Vec<MixNodeDetails>, NymAPIError> {
self.get_json(&[routes::API_VERSION, routes::MIXNODES], NO_PARAMS)
self.get_json(&[routes::V1_API_VERSION, routes::MIXNODES], NO_PARAMS)
.await
}
@@ -96,7 +97,7 @@ pub trait NymApiClientExt: ApiClient {
async fn get_mixnodes_detailed(&self) -> Result<Vec<MixNodeBondAnnotated>, NymAPIError> {
self.get_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::STATUS,
routes::MIXNODES,
routes::DETAILED,
@@ -111,7 +112,7 @@ pub trait NymApiClientExt: ApiClient {
async fn get_gateways_detailed(&self) -> Result<Vec<GatewayBondAnnotated>, NymAPIError> {
self.get_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::STATUS,
routes::GATEWAYS,
routes::DETAILED,
@@ -128,7 +129,7 @@ pub trait NymApiClientExt: ApiClient {
) -> Result<Vec<GatewayBondAnnotated>, NymAPIError> {
self.get_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::STATUS,
routes::GATEWAYS,
routes::DETAILED_UNFILTERED,
@@ -145,7 +146,7 @@ pub trait NymApiClientExt: ApiClient {
) -> Result<Vec<MixNodeBondAnnotated>, NymAPIError> {
self.get_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::STATUS,
routes::MIXNODES,
routes::DETAILED_UNFILTERED,
@@ -158,7 +159,7 @@ pub trait NymApiClientExt: ApiClient {
#[deprecated]
#[instrument(level = "debug", skip(self))]
async fn get_gateways(&self) -> Result<Vec<GatewayBond>, NymAPIError> {
self.get_json(&[routes::API_VERSION, routes::GATEWAYS], NO_PARAMS)
self.get_json(&[routes::V1_API_VERSION, routes::GATEWAYS], NO_PARAMS)
.await
}
@@ -166,7 +167,7 @@ pub trait NymApiClientExt: ApiClient {
#[instrument(level = "debug", skip(self))]
async fn get_gateways_described(&self) -> Result<Vec<LegacyDescribedGateway>, NymAPIError> {
self.get_json(
&[routes::API_VERSION, routes::GATEWAYS, routes::DESCRIBED],
&[routes::V1_API_VERSION, routes::GATEWAYS, routes::DESCRIBED],
NO_PARAMS,
)
.await
@@ -176,7 +177,7 @@ pub trait NymApiClientExt: ApiClient {
#[instrument(level = "debug", skip(self))]
async fn get_mixnodes_described(&self) -> Result<Vec<LegacyDescribedMixNode>, NymAPIError> {
self.get_json(
&[routes::API_VERSION, routes::MIXNODES, routes::DESCRIBED],
&[routes::V1_API_VERSION, routes::MIXNODES, routes::DESCRIBED],
NO_PARAMS,
)
.await
@@ -201,7 +202,7 @@ pub trait NymApiClientExt: ApiClient {
self.get_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::NYM_NODES_ROUTES,
routes::NYM_NODES_PERFORMANCE_HISTORY,
&*node_id.to_string(),
@@ -229,7 +230,7 @@ pub trait NymApiClientExt: ApiClient {
self.get_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::NYM_NODES_ROUTES,
routes::NYM_NODES_DESCRIBED,
],
@@ -256,7 +257,7 @@ pub trait NymApiClientExt: ApiClient {
self.get_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::NYM_NODES_ROUTES,
routes::NYM_NODES_BONDED,
],
@@ -270,7 +271,7 @@ pub trait NymApiClientExt: ApiClient {
async fn get_basic_mixnodes(&self) -> Result<CachedNodesResponse<SkimmedNode>, NymAPIError> {
self.get_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
"unstable",
routes::NYM_NODES_ROUTES,
"mixnodes",
@@ -286,7 +287,7 @@ pub trait NymApiClientExt: ApiClient {
async fn get_basic_gateways(&self) -> Result<CachedNodesResponse<SkimmedNode>, NymAPIError> {
self.get_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
"unstable",
routes::NYM_NODES_ROUTES,
"gateways",
@@ -301,7 +302,7 @@ pub trait NymApiClientExt: ApiClient {
async fn get_rewarded_set(&self) -> Result<RewardedSetResponse, NymAPIError> {
self.get_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::NYM_NODES_ROUTES,
routes::NYM_NODES_REWARDED_SET,
],
@@ -312,6 +313,7 @@ pub trait NymApiClientExt: ApiClient {
/// retrieve basic information for nodes are capable of operating as an entry gateway
/// this includes legacy gateways and nym-nodes
#[deprecated(note = "use get_basic_entry_assigned_nodes_v2")]
#[instrument(level = "debug", skip(self))]
async fn get_basic_entry_assigned_nodes(
&self,
@@ -319,7 +321,7 @@ pub trait NymApiClientExt: ApiClient {
page: Option<u32>,
per_page: Option<u32>,
use_bincode: bool,
) -> Result<PaginatedCachedNodesResponse<SkimmedNode>, NymAPIError> {
) -> Result<PaginatedCachedNodesResponseV1<SkimmedNode>, NymAPIError> {
let mut params = Vec::new();
if no_legacy {
@@ -340,7 +342,49 @@ pub trait NymApiClientExt: ApiClient {
self.get_response(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
"unstable",
routes::NYM_NODES_ROUTES,
"skimmed",
"entry-gateways",
"all",
],
&params,
)
.await
}
/// retrieve basic information for nodes are capable of operating as an entry gateway
/// this includes legacy gateways and nym-nodes
#[instrument(level = "debug", skip(self))]
async fn get_basic_entry_assigned_nodes_v2(
&self,
no_legacy: bool,
page: Option<u32>,
per_page: Option<u32>,
use_bincode: bool,
) -> Result<PaginatedCachedNodesResponseV2<SkimmedNode>, NymAPIError> {
let mut params = Vec::new();
if no_legacy {
params.push(("no_legacy", "true".to_string()))
}
if let Some(page) = page {
params.push(("page", page.to_string()))
}
if let Some(per_page) = per_page {
params.push(("per_page", per_page.to_string()))
}
if use_bincode {
params.push(("output", "bincode".to_string()))
}
self.get_response(
&[
routes::V2_API_VERSION,
"unstable",
routes::NYM_NODES_ROUTES,
"skimmed",
@@ -354,6 +398,7 @@ pub trait NymApiClientExt: ApiClient {
/// retrieve basic information for nodes that got assigned 'mixing' node in this epoch
/// this includes legacy mixnodes and nym-nodes
#[deprecated(note = "use get_basic_active_mixing_assigned_nodes_v2")]
#[instrument(level = "debug", skip(self))]
async fn get_basic_active_mixing_assigned_nodes(
&self,
@@ -361,7 +406,7 @@ pub trait NymApiClientExt: ApiClient {
page: Option<u32>,
per_page: Option<u32>,
use_bincode: bool,
) -> Result<PaginatedCachedNodesResponse<SkimmedNode>, NymAPIError> {
) -> Result<PaginatedCachedNodesResponseV1<SkimmedNode>, NymAPIError> {
let mut params = Vec::new();
if no_legacy {
@@ -382,7 +427,7 @@ pub trait NymApiClientExt: ApiClient {
self.get_response(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
"unstable",
routes::NYM_NODES_ROUTES,
"skimmed",
@@ -397,13 +442,13 @@ pub trait NymApiClientExt: ApiClient {
/// retrieve basic information for nodes that got assigned 'mixing' node in this epoch
/// this includes legacy mixnodes and nym-nodes
#[instrument(level = "debug", skip(self))]
async fn get_basic_mixing_capable_nodes(
async fn get_basic_active_mixing_assigned_nodes_v2(
&self,
no_legacy: bool,
page: Option<u32>,
per_page: Option<u32>,
use_bincode: bool,
) -> Result<PaginatedCachedNodesResponse<SkimmedNode>, NymAPIError> {
) -> Result<PaginatedCachedNodesResponseV2<SkimmedNode>, NymAPIError> {
let mut params = Vec::new();
if no_legacy {
@@ -424,7 +469,50 @@ pub trait NymApiClientExt: ApiClient {
self.get_response(
&[
routes::API_VERSION,
routes::V2_API_VERSION,
"unstable",
routes::NYM_NODES_ROUTES,
"skimmed",
"mixnodes",
"active",
],
&params,
)
.await
}
/// retrieve basic information for nodes that got assigned 'mixing' node in this epoch
/// this includes legacy mixnodes and nym-nodes
#[deprecated(note = "use get_basic_mixing_capable_nodes_v2")]
#[instrument(level = "debug", skip(self))]
async fn get_basic_mixing_capable_nodes(
&self,
no_legacy: bool,
page: Option<u32>,
per_page: Option<u32>,
use_bincode: bool,
) -> Result<PaginatedCachedNodesResponseV1<SkimmedNode>, NymAPIError> {
let mut params = Vec::new();
if no_legacy {
params.push(("no_legacy", "true".to_string()))
}
if let Some(page) = page {
params.push(("page", page.to_string()))
}
if let Some(per_page) = per_page {
params.push(("per_page", per_page.to_string()))
}
if use_bincode {
params.push(("output", "bincode".to_string()))
}
self.get_response(
&[
routes::V1_API_VERSION,
"unstable",
routes::NYM_NODES_ROUTES,
"skimmed",
@@ -436,14 +524,16 @@ pub trait NymApiClientExt: ApiClient {
.await
}
/// retrieve basic information for nodes that got assigned 'mixing' node in this epoch
/// this includes legacy mixnodes and nym-nodes
#[instrument(level = "debug", skip(self))]
async fn get_basic_nodes(
async fn get_basic_mixing_capable_nodes_v2(
&self,
no_legacy: bool,
page: Option<u32>,
per_page: Option<u32>,
use_bincode: bool,
) -> Result<PaginatedCachedNodesResponse<SkimmedNode>, NymAPIError> {
) -> Result<PaginatedCachedNodesResponseV2<SkimmedNode>, NymAPIError> {
let mut params = Vec::new();
if no_legacy {
@@ -464,7 +554,86 @@ pub trait NymApiClientExt: ApiClient {
self.get_response(
&[
routes::API_VERSION,
routes::V2_API_VERSION,
"unstable",
routes::NYM_NODES_ROUTES,
"skimmed",
"mixnodes",
"all",
],
&params,
)
.await
}
#[deprecated(note = "use get_basic_nodes_v2")]
#[instrument(level = "debug", skip(self))]
async fn get_basic_nodes(
&self,
no_legacy: bool,
page: Option<u32>,
per_page: Option<u32>,
use_bincode: bool,
) -> Result<PaginatedCachedNodesResponseV1<SkimmedNode>, NymAPIError> {
let mut params = Vec::new();
if no_legacy {
params.push(("no_legacy", "true".to_string()))
}
if let Some(page) = page {
params.push(("page", page.to_string()))
}
if let Some(per_page) = per_page {
params.push(("per_page", per_page.to_string()))
}
if use_bincode {
params.push(("output", "bincode".to_string()))
}
self.get_response(
&[
routes::V1_API_VERSION,
"unstable",
routes::NYM_NODES_ROUTES,
"skimmed",
],
&params,
)
.await
}
#[instrument(level = "debug", skip(self))]
async fn get_basic_nodes_v2(
&self,
no_legacy: bool,
page: Option<u32>,
per_page: Option<u32>,
use_bincode: bool,
) -> Result<PaginatedCachedNodesResponseV2<SkimmedNode>, NymAPIError> {
let mut params = Vec::new();
if no_legacy {
params.push(("no_legacy", "true".to_string()))
}
if let Some(page) = page {
params.push(("page", page.to_string()))
}
if let Some(per_page) = per_page {
params.push(("per_page", per_page.to_string()))
}
if use_bincode {
params.push(("output", "bincode".to_string()))
}
self.get_response(
&[
routes::V2_API_VERSION,
"unstable",
routes::NYM_NODES_ROUTES,
"skimmed",
@@ -478,7 +647,7 @@ pub trait NymApiClientExt: ApiClient {
#[instrument(level = "debug", skip(self))]
async fn get_active_mixnodes(&self) -> Result<Vec<MixNodeDetails>, NymAPIError> {
self.get_json(
&[routes::API_VERSION, routes::MIXNODES, routes::ACTIVE],
&[routes::V1_API_VERSION, routes::MIXNODES, routes::ACTIVE],
NO_PARAMS,
)
.await
@@ -489,7 +658,7 @@ pub trait NymApiClientExt: ApiClient {
async fn get_active_mixnodes_detailed(&self) -> Result<Vec<MixNodeBondAnnotated>, NymAPIError> {
self.get_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::STATUS,
routes::MIXNODES,
routes::ACTIVE,
@@ -504,7 +673,7 @@ pub trait NymApiClientExt: ApiClient {
#[instrument(level = "debug", skip(self))]
async fn get_rewarded_mixnodes(&self) -> Result<Vec<MixNodeDetails>, NymAPIError> {
self.get_json(
&[routes::API_VERSION, routes::MIXNODES, routes::REWARDED],
&[routes::V1_API_VERSION, routes::MIXNODES, routes::REWARDED],
NO_PARAMS,
)
.await
@@ -518,7 +687,7 @@ pub trait NymApiClientExt: ApiClient {
) -> Result<MixnodeStatusReportResponse, NymAPIError> {
self.get_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::STATUS,
routes::MIXNODE,
&mix_id.to_string(),
@@ -537,7 +706,7 @@ pub trait NymApiClientExt: ApiClient {
) -> Result<GatewayStatusReportResponse, NymAPIError> {
self.get_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::STATUS,
routes::GATEWAY,
identity,
@@ -556,7 +725,7 @@ pub trait NymApiClientExt: ApiClient {
) -> Result<MixnodeUptimeHistoryResponse, NymAPIError> {
self.get_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::STATUS,
routes::MIXNODE,
&mix_id.to_string(),
@@ -575,7 +744,7 @@ pub trait NymApiClientExt: ApiClient {
) -> Result<GatewayUptimeHistoryResponse, NymAPIError> {
self.get_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::STATUS,
routes::GATEWAY,
identity,
@@ -593,7 +762,7 @@ pub trait NymApiClientExt: ApiClient {
) -> Result<Vec<MixNodeBondAnnotated>, NymAPIError> {
self.get_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::STATUS,
routes::MIXNODES,
routes::REWARDED,
@@ -614,7 +783,7 @@ pub trait NymApiClientExt: ApiClient {
if let Some(since) = since {
self.get_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::STATUS_ROUTES,
routes::GATEWAY,
identity,
@@ -626,7 +795,7 @@ pub trait NymApiClientExt: ApiClient {
} else {
self.get_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::STATUS_ROUTES,
routes::GATEWAY,
identity,
@@ -647,7 +816,7 @@ pub trait NymApiClientExt: ApiClient {
if let Some(since) = since {
self.get_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::STATUS_ROUTES,
routes::MIXNODE,
&mix_id.to_string(),
@@ -659,7 +828,7 @@ pub trait NymApiClientExt: ApiClient {
} else {
self.get_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::STATUS_ROUTES,
routes::MIXNODE,
&mix_id.to_string(),
@@ -679,7 +848,7 @@ pub trait NymApiClientExt: ApiClient {
) -> Result<MixnodeStatusResponse, NymAPIError> {
self.get_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::STATUS_ROUTES,
routes::MIXNODE,
&mix_id.to_string(),
@@ -698,7 +867,7 @@ pub trait NymApiClientExt: ApiClient {
) -> Result<RewardEstimationResponse, NymAPIError> {
self.get_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::STATUS_ROUTES,
routes::MIXNODE,
&mix_id.to_string(),
@@ -718,7 +887,7 @@ pub trait NymApiClientExt: ApiClient {
) -> Result<RewardEstimationResponse, NymAPIError> {
self.post_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::STATUS_ROUTES,
routes::MIXNODE,
&mix_id.to_string(),
@@ -738,7 +907,7 @@ pub trait NymApiClientExt: ApiClient {
) -> Result<StakeSaturationResponse, NymAPIError> {
self.get_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::STATUS_ROUTES,
routes::MIXNODE,
&mix_id.to_string(),
@@ -758,7 +927,7 @@ pub trait NymApiClientExt: ApiClient {
) -> Result<nym_api_requests::models::InclusionProbabilityResponse, NymAPIError> {
self.get_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::STATUS_ROUTES,
routes::MIXNODE,
&mix_id.to_string(),
@@ -776,7 +945,7 @@ pub trait NymApiClientExt: ApiClient {
) -> Result<NodePerformanceResponse, NymAPIError> {
self.get_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::NYM_NODES_ROUTES,
routes::NYM_NODES_PERFORMANCE,
&node_id.to_string(),
@@ -792,7 +961,7 @@ pub trait NymApiClientExt: ApiClient {
) -> Result<AnnotationResponse, NymAPIError> {
self.get_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::NYM_NODES_ROUTES,
routes::NYM_NODES_ANNOTATION,
&node_id.to_string(),
@@ -806,7 +975,7 @@ pub trait NymApiClientExt: ApiClient {
async fn get_mixnode_avg_uptime(&self, mix_id: NodeId) -> Result<UptimeResponse, NymAPIError> {
self.get_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::STATUS_ROUTES,
routes::MIXNODE,
&mix_id.to_string(),
@@ -821,7 +990,11 @@ pub trait NymApiClientExt: ApiClient {
#[instrument(level = "debug", skip(self))]
async fn get_mixnodes_blacklisted(&self) -> Result<Vec<NodeId>, NymAPIError> {
self.get_json(
&[routes::API_VERSION, routes::MIXNODES, routes::BLACKLISTED],
&[
routes::V1_API_VERSION,
routes::MIXNODES,
routes::BLACKLISTED,
],
NO_PARAMS,
)
.await
@@ -831,7 +1004,11 @@ pub trait NymApiClientExt: ApiClient {
#[instrument(level = "debug", skip(self))]
async fn get_gateways_blacklisted(&self) -> Result<Vec<IdentityKey>, NymAPIError> {
self.get_json(
&[routes::API_VERSION, routes::GATEWAYS, routes::BLACKLISTED],
&[
routes::V1_API_VERSION,
routes::GATEWAYS,
routes::BLACKLISTED,
],
NO_PARAMS,
)
.await
@@ -844,7 +1021,7 @@ pub trait NymApiClientExt: ApiClient {
) -> Result<BlindedSignatureResponse, NymAPIError> {
self.post_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::ECASH_ROUTES,
routes::ECASH_BLIND_SIGN,
],
@@ -861,7 +1038,7 @@ pub trait NymApiClientExt: ApiClient {
) -> Result<EcashTicketVerificationResponse, NymAPIError> {
self.post_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::ECASH_ROUTES,
routes::VERIFY_ECASH_TICKET,
],
@@ -878,7 +1055,7 @@ pub trait NymApiClientExt: ApiClient {
) -> Result<EcashBatchTicketRedemptionResponse, NymAPIError> {
self.post_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::ECASH_ROUTES,
routes::BATCH_REDEEM_ECASH_TICKETS,
],
@@ -903,7 +1080,7 @@ pub trait NymApiClientExt: ApiClient {
self.get_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::ECASH_ROUTES,
routes::PARTIAL_EXPIRATION_DATE_SIGNATURES,
],
@@ -924,7 +1101,7 @@ pub trait NymApiClientExt: ApiClient {
self.get_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::ECASH_ROUTES,
routes::PARTIAL_COIN_INDICES_SIGNATURES,
],
@@ -948,7 +1125,7 @@ pub trait NymApiClientExt: ApiClient {
self.get_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::ECASH_ROUTES,
routes::GLOBAL_EXPIRATION_DATE_SIGNATURES,
],
@@ -969,7 +1146,7 @@ pub trait NymApiClientExt: ApiClient {
self.get_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::ECASH_ROUTES,
routes::GLOBAL_COIN_INDICES_SIGNATURES,
],
@@ -989,7 +1166,7 @@ pub trait NymApiClientExt: ApiClient {
};
self.get_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::ECASH_ROUTES,
ecash::MASTER_VERIFICATION_KEY,
],
@@ -1005,7 +1182,7 @@ pub trait NymApiClientExt: ApiClient {
) -> Result<(), NymAPIError> {
self.post_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::NYM_NODES_ROUTES,
routes::NYM_NODES_REFRESH_DESCRIBED,
],
@@ -1022,7 +1199,7 @@ pub trait NymApiClientExt: ApiClient {
) -> Result<IssuedTicketbooksForResponse, NymAPIError> {
self.get_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::ECASH_ROUTES,
routes::ECASH_ISSUED_TICKETBOOKS_FOR,
&expiration_date.to_string(),
@@ -1039,7 +1216,7 @@ pub trait NymApiClientExt: ApiClient {
) -> Result<IssuedTicketbooksForCountResponse, NymAPIError> {
self.get_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::ECASH_ROUTES,
routes::ECASH_ISSUED_TICKETBOOKS_FOR_COUNT,
&expiration_date.to_string(),
@@ -1056,7 +1233,7 @@ pub trait NymApiClientExt: ApiClient {
) -> Result<IssuedTicketbooksChallengeCommitmentResponse, NymAPIError> {
self.post_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::ECASH_ROUTES,
routes::ECASH_ISSUED_TICKETBOOKS_CHALLENGE_COMMITMENT,
],
@@ -1073,7 +1250,7 @@ pub trait NymApiClientExt: ApiClient {
) -> Result<IssuedTicketbooksDataResponse, NymAPIError> {
self.post_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
routes::ECASH_ROUTES,
routes::ECASH_ISSUED_TICKETBOOKS_DATA,
],
@@ -1089,7 +1266,7 @@ pub trait NymApiClientExt: ApiClient {
) -> Result<NodesByAddressesResponse, NymAPIError> {
self.post_json(
&[
routes::API_VERSION,
routes::V1_API_VERSION,
"unstable",
routes::NYM_NODES_ROUTES,
routes::nym_nodes::BY_ADDRESSES,
@@ -1103,7 +1280,7 @@ pub trait NymApiClientExt: ApiClient {
#[instrument(level = "debug", skip(self))]
async fn get_network_details(&self) -> Result<NymNetworkDetailsResponse, NymAPIError> {
self.get_json(
&[routes::API_VERSION, routes::NETWORK, routes::DETAILS],
&[routes::V1_API_VERSION, routes::NETWORK, routes::DETAILS],
NO_PARAMS,
)
.await
@@ -1112,7 +1289,24 @@ pub trait NymApiClientExt: ApiClient {
#[instrument(level = "debug", skip(self))]
async fn get_chain_status(&self) -> Result<ChainStatusResponse, NymAPIError> {
self.get_json(
&[routes::API_VERSION, routes::NETWORK, routes::CHAIN_STATUS],
&[
routes::V1_API_VERSION,
routes::NETWORK,
routes::CHAIN_STATUS,
],
NO_PARAMS,
)
.await
}
#[instrument(level = "debug", skip(self))]
async fn get_key_rotation_info(&self) -> Result<KeyRotationInfoResponse, NymAPIError> {
self.get_json(
&[
routes::V1_API_VERSION,
routes::EPOCH,
routes::KEY_ROTATION_INFO,
],
NO_PARAMS,
)
.await
@@ -1,9 +1,8 @@
// Copyright 2021 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use nym_network_defaults::NYM_API_VERSION;
pub const API_VERSION: &str = NYM_API_VERSION;
pub const V1_API_VERSION: &str = "v1";
pub const V2_API_VERSION: &str = "v2";
pub const MIXNODES: &str = "mixnodes";
pub const GATEWAYS: &str = "gateways";
pub const DESCRIBED: &str = "described";
@@ -79,3 +78,11 @@ pub const SERVICE_PROVIDERS: &str = "services";
pub const DETAILS: &str = "details";
pub const CHAIN_STATUS: &str = "chain-status";
pub const NETWORK: &str = "network";
pub const EPOCH: &str = "epoch";
pub use epoch_routes::*;
pub mod epoch_routes {
pub const CURRENT: &str = "current";
pub const KEY_ROTATION_INFO: &str = "key-rotation-info";
}
@@ -12,8 +12,8 @@ use nym_mixnet_contract_common::gateway::{PreassignedGatewayIdsResponse, Preassi
use nym_mixnet_contract_common::nym_node::{
EpochAssignmentResponse, NodeDetailsByIdentityResponse, NodeDetailsResponse,
NodeOwnershipResponse, NodeRewardingDetailsResponse, PagedNymNodeBondsResponse,
PagedNymNodeDetailsResponse, PagedUnbondedNymNodesResponse, Role, RolesMetadataResponse,
StakeSaturationResponse, UnbondedNodeResponse, UnbondedNymNode,
PagedNymNodeDetailsResponse, PagedUnbondedNymNodesResponse, RewardedSetMetadata, Role,
RolesMetadataResponse, StakeSaturationResponse, UnbondedNodeResponse, UnbondedNymNode,
};
use nym_mixnet_contract_common::reward_params::WorkFactor;
use nym_mixnet_contract_common::{
@@ -28,12 +28,12 @@ use nym_mixnet_contract_common::{
ContractBuildInformation, ContractState, ContractStateParams, CurrentIntervalResponse,
CurrentNymNodeVersionResponse, Delegation, EpochEventId, EpochRewardedSet, EpochStatus,
GatewayBond, GatewayBondResponse, GatewayOwnershipResponse, HistoricalNymNodeVersionEntry,
IdentityKey, IdentityKeyRef, IntervalEventId, MixNodeBond, MixNodeDetails,
MixOwnershipResponse, MixnodeDetailsByIdentityResponse, MixnodeDetailsResponse, NodeId,
NumberOfPendingEventsResponse, NymNodeBond, NymNodeDetails, NymNodeVersionHistoryResponse,
PagedAllDelegationsResponse, PagedDelegatorDelegationsResponse, PagedGatewayResponse,
PagedMixnodeBondsResponse, PagedNodeDelegationsResponse, PendingEpochEvent,
PendingEpochEventResponse, PendingEpochEventsResponse, PendingIntervalEvent,
IdentityKey, IdentityKeyRef, IntervalEventId, KeyRotationIdResponse, KeyRotationState,
MixNodeBond, MixNodeDetails, MixOwnershipResponse, MixnodeDetailsByIdentityResponse,
MixnodeDetailsResponse, NodeId, NumberOfPendingEventsResponse, NymNodeBond, NymNodeDetails,
NymNodeVersionHistoryResponse, PagedAllDelegationsResponse, PagedDelegatorDelegationsResponse,
PagedGatewayResponse, PagedMixnodeBondsResponse, PagedNodeDelegationsResponse,
PendingEpochEvent, PendingEpochEventResponse, PendingEpochEventsResponse, PendingIntervalEvent,
PendingIntervalEventResponse, PendingIntervalEventsResponse, QueryMsg as MixnetQueryMsg,
RewardedSet, UnbondedMixnode,
};
@@ -546,6 +546,16 @@ pub trait MixnetQueryClient {
})
.await
}
async fn get_key_rotation_state(&self) -> Result<KeyRotationState, NyxdError> {
self.query_mixnet_contract(MixnetQueryMsg::GetKeyRotationState {})
.await
}
async fn get_key_rotation_id(&self) -> Result<KeyRotationIdResponse, NyxdError> {
self.query_mixnet_contract(MixnetQueryMsg::GetKeyRotationId {})
.await
}
}
// extension trait to the query client to deal with the paged queries
@@ -673,12 +683,20 @@ pub trait MixnetQueryClientExt: MixnetQueryClient {
async fn get_rewarded_set(&self) -> Result<EpochRewardedSet, NyxdError> {
let error_response = |message| Err(NyxdError::extension_query_failure("mixnet", message));
// bypass for catch 22 for fresh contracts. we can't refresh cache because there's no rewarded set,
// but we can't set the rewarded set because we didn't refresh the cache
let metadata = self.get_rewarded_set_metadata().await?;
if !metadata.metadata.fully_assigned {
let is_default = metadata.metadata == RewardedSetMetadata::default();
if !metadata.metadata.fully_assigned && !is_default {
return error_response("the rewarded set hasn't been fully assigned for this epoch");
}
let expected_epoch_id = metadata.metadata.epoch_id;
if is_default {
return Ok(Default::default());
}
// if we have to query those things more frequently, we could do it concurrently,
// but as it stands now, it happens so infrequently it might as well be sequential
let entry = self.get_role_assignment(Role::EntryGateway).await?;
@@ -955,6 +973,8 @@ mod tests {
QueryMsg::GetNymNodeVersionHistory { limit, start_after } => client
.get_nym_node_version_history_paged(start_after, limit)
.ignore(),
QueryMsg::GetKeyRotationState {} => client.get_key_rotation_state().ignore(),
QueryMsg::GetKeyRotationId {} => client.get_key_rotation_id().ignore(),
}
}
}
@@ -138,6 +138,14 @@ impl NyxdClient<HttpClient> {
config,
})
}
pub fn connect_to_default_env<U>(endpoint: U) -> Result<QueryHttpRpcNyxdClient, NyxdError>
where
U: TryInto<HttpClientUrl, Error = TendermintRpcError>,
{
let config = Config::try_from_nym_network_details(&NymNetworkDetails::new_from_env())?;
Self::connect(config, endpoint)
}
}
impl NyxdClient<ReqwestRpcClient> {
@@ -157,6 +157,7 @@ pub async fn generate(args: Args) {
minimum: args.minimum_interval_operating_cost.amount.into(),
maximum: args.maximum_interval_operating_cost.amount.into(),
},
key_validity_in_epochs: None,
};
debug!("instantiate_msg: {:?}", instantiate_msg);
@@ -40,3 +40,6 @@ contract-testing = []
utoipa = ["dep:utoipa"]
schema = ["cw2"]
generate-ts = ['ts-rs']
[lints]
workspace = true
@@ -193,6 +193,9 @@ pub enum MixnetContractError {
#[error("attempted to perform the operation with 0 coins. This is not allowed")]
ZeroCoinAmount,
#[error("key rotation validity below minimum value")]
TooShortRotationInterval,
#[error("this validator ({current_validator}) is not the one responsible for advancing this epoch. It's responsibility of {chosen_validator}.")]
RewardingValidatorMismatch {
current_validator: Addr,
@@ -358,7 +358,7 @@ impl Interval {
self.total_elapsed_epochs
}
pub const fn current_epoch_absolute_id(&self) -> u32 {
pub const fn current_epoch_absolute_id(&self) -> EpochId {
// since we count epochs starting from 0, if n epochs have elapsed, the current one has absolute id of n
self.total_elapsed_epochs
}
@@ -0,0 +1,155 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::EpochId;
use cosmwasm_schema::cw_serde;
pub type KeyRotationId = u32;
#[cw_serde]
#[derive(Copy)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
pub struct KeyRotationState {
/// Defines how long each key rotation is valid for (in terms of epochs)
pub validity_epochs: u32,
/// Records the initial epoch_id when the key rotation has been introduced (0 for fresh contracts).
/// It is used for determining when rotation is meant to advance.
#[cfg_attr(feature = "utoipa", schema(value_type = u32))]
pub initial_epoch_id: EpochId,
}
impl KeyRotationState {
pub fn key_rotation_id(&self, current_epoch_id: EpochId) -> KeyRotationId {
let diff = current_epoch_id.saturating_sub(self.initial_epoch_id);
diff / self.validity_epochs
}
pub fn next_rotation_starting_epoch_id(&self, current_epoch_id: EpochId) -> EpochId {
self.current_rotation_starting_epoch_id(current_epoch_id) + self.validity_epochs
}
pub fn current_rotation_starting_epoch_id(&self, current_epoch_id: EpochId) -> EpochId {
let current_rotation_id = self.key_rotation_id(current_epoch_id);
self.initial_epoch_id + self.validity_epochs * current_rotation_id
}
}
#[cw_serde]
pub struct KeyRotationIdResponse {
pub rotation_id: KeyRotationId,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn key_rotation_id() {
let state = KeyRotationState {
validity_epochs: 24,
initial_epoch_id: 0,
};
assert_eq!(0, state.key_rotation_id(0));
assert_eq!(0, state.key_rotation_id(23));
assert_eq!(1, state.key_rotation_id(24));
assert_eq!(1, state.key_rotation_id(47));
assert_eq!(2, state.key_rotation_id(48));
let state = KeyRotationState {
validity_epochs: 12,
initial_epoch_id: 0,
};
assert_eq!(0, state.key_rotation_id(0));
assert_eq!(0, state.key_rotation_id(11));
assert_eq!(1, state.key_rotation_id(12));
assert_eq!(1, state.key_rotation_id(23));
assert_eq!(2, state.key_rotation_id(24));
let state = KeyRotationState {
validity_epochs: 24,
initial_epoch_id: 10000,
};
assert_eq!(0, state.key_rotation_id(123));
assert_eq!(0, state.key_rotation_id(10000));
assert_eq!(0, state.key_rotation_id(10001));
assert_eq!(0, state.key_rotation_id(10023));
assert_eq!(1, state.key_rotation_id(10024));
assert_eq!(1, state.key_rotation_id(10047));
assert_eq!(2, state.key_rotation_id(10048));
assert_eq!(2, state.key_rotation_id(10060));
}
#[test]
fn next_rotation_starting_epoch_id() {
let state = KeyRotationState {
validity_epochs: 24,
initial_epoch_id: 0,
};
assert_eq!(24, state.next_rotation_starting_epoch_id(0));
assert_eq!(24, state.next_rotation_starting_epoch_id(23));
assert_eq!(48, state.next_rotation_starting_epoch_id(24));
assert_eq!(48, state.next_rotation_starting_epoch_id(47));
assert_eq!(72, state.next_rotation_starting_epoch_id(48));
let state = KeyRotationState {
validity_epochs: 12,
initial_epoch_id: 0,
};
assert_eq!(12, state.next_rotation_starting_epoch_id(0));
assert_eq!(12, state.next_rotation_starting_epoch_id(11));
assert_eq!(24, state.next_rotation_starting_epoch_id(12));
assert_eq!(24, state.next_rotation_starting_epoch_id(23));
assert_eq!(36, state.next_rotation_starting_epoch_id(24));
let state = KeyRotationState {
validity_epochs: 24,
initial_epoch_id: 10000,
};
assert_eq!(10024, state.next_rotation_starting_epoch_id(123));
assert_eq!(10024, state.next_rotation_starting_epoch_id(10000));
assert_eq!(10024, state.next_rotation_starting_epoch_id(10001));
assert_eq!(10024, state.next_rotation_starting_epoch_id(10023));
assert_eq!(10048, state.next_rotation_starting_epoch_id(10024));
assert_eq!(10048, state.next_rotation_starting_epoch_id(10047));
assert_eq!(10072, state.next_rotation_starting_epoch_id(10048));
assert_eq!(10072, state.next_rotation_starting_epoch_id(10060));
}
#[test]
fn current_rotation_starting_epoch_id() {
let state = KeyRotationState {
validity_epochs: 24,
initial_epoch_id: 0,
};
assert_eq!(0, state.current_rotation_starting_epoch_id(0));
assert_eq!(0, state.current_rotation_starting_epoch_id(23));
assert_eq!(24, state.current_rotation_starting_epoch_id(24));
assert_eq!(24, state.current_rotation_starting_epoch_id(47));
assert_eq!(48, state.current_rotation_starting_epoch_id(48));
let state = KeyRotationState {
validity_epochs: 12,
initial_epoch_id: 0,
};
assert_eq!(0, state.current_rotation_starting_epoch_id(0));
assert_eq!(0, state.current_rotation_starting_epoch_id(11));
assert_eq!(12, state.current_rotation_starting_epoch_id(12));
assert_eq!(12, state.current_rotation_starting_epoch_id(23));
assert_eq!(24, state.current_rotation_starting_epoch_id(24));
let state = KeyRotationState {
validity_epochs: 24,
initial_epoch_id: 10000,
};
assert_eq!(10000, state.current_rotation_starting_epoch_id(123));
assert_eq!(10000, state.current_rotation_starting_epoch_id(10000));
assert_eq!(10000, state.current_rotation_starting_epoch_id(10001));
assert_eq!(10000, state.current_rotation_starting_epoch_id(10023));
assert_eq!(10024, state.current_rotation_starting_epoch_id(10024));
assert_eq!(10024, state.current_rotation_starting_epoch_id(10047));
assert_eq!(10048, state.current_rotation_starting_epoch_id(10048));
assert_eq!(10048, state.current_rotation_starting_epoch_id(10060));
}
}
@@ -1,10 +1,6 @@
// Copyright 2021-2023 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
#![warn(clippy::expect_used)]
#![warn(clippy::unwrap_used)]
#![warn(clippy::todo)]
mod config_score;
pub mod constants;
pub mod delegation;
@@ -13,6 +9,7 @@ pub mod events;
pub mod gateway;
pub mod helpers;
pub mod interval;
pub mod key_rotation;
pub mod mixnode;
pub mod msg;
pub mod nym_node;
@@ -37,6 +34,7 @@ pub use gateway::{
pub use interval::{
CurrentIntervalResponse, EpochId, EpochState, EpochStatus, Interval, IntervalId,
};
pub use key_rotation::*;
pub use mixnode::{
LegacyMixLayer, MixNode, MixNodeBond, MixNodeConfigUpdate, MixNodeDetails,
MixOwnershipResponse, MixnodeDetailsByIdentityResponse, MixnodeDetailsResponse, NodeCostParams,
@@ -170,6 +170,11 @@ impl NodeRewarding {
}
}
// we panic here as opposed to returning an error as this is undefined behaviour,
// because the pledge amount has decreased (i.e. slashing has occurred) which
// should not be possible under any situation. at this point we don't know how many other things
// might have failed so we have to bail
#[allow(clippy::panic)]
pub fn pending_detailed_operator_reward(&self, original_pledge: &Coin) -> StdResult<Decimal> {
let initial_dec = original_pledge.amount.into_base_decimal()?;
if initial_dec > self.operator {
@@ -189,6 +194,11 @@ impl NodeRewarding {
Ok(truncate_reward(delegator_reward, &delegation.amount.denom))
}
// we panic here as opposed to returning an error as this is undefined behaviour,
// because the pledge amount has decreased (i.e. slashing has occurred) which
// should not be possible under any situation. at this point we don't know how many other things
// might have failed so we have to bail
#[allow(clippy::panic)]
pub fn withdraw_operator_reward(
&mut self,
original_pledge: &Coin,
@@ -35,6 +35,7 @@ use crate::{
PreassignedGatewayIdsResponse,
},
interval::{CurrentIntervalResponse, EpochStatus},
key_rotation::{KeyRotationIdResponse, KeyRotationState},
mixnode::{
MixOwnershipResponse, MixStakeSaturationResponse, MixnodeDetailsByIdentityResponse,
MixnodeDetailsResponse, MixnodeRewardingDetailsResponse, PagedMixnodeBondsResponse,
@@ -81,6 +82,18 @@ pub struct InstantiateMsg {
#[serde(default)]
pub interval_operating_cost: OperatingCostRange,
#[serde(default)]
pub key_validity_in_epochs: Option<u32>,
}
impl InstantiateMsg {
// needs to give us enough time to pre-announce key for following epoch
// and have an overlap with the preceding epoch
pub const MIN_KEY_ROTATION_VALIDITY: u32 = 3;
pub fn key_validity_in_epochs(&self) -> u32 {
self.key_validity_in_epochs.unwrap_or(24)
}
}
#[cw_serde]
@@ -857,6 +870,15 @@ pub enum QueryMsg {
/// Cosmos address used for the query of the signing nonce.
address: String,
},
// sphinx key rotation-related
#[cfg_attr(feature = "schema", returns(KeyRotationState))]
/// Gets the current state config of the key rotation (i.e. starting epoch id and validity duration)
GetKeyRotationState {},
/// Gets the current key rotation id
#[cfg_attr(feature = "schema", returns(KeyRotationIdResponse))]
GetKeyRotationId {},
}
#[cw_serde]
@@ -151,6 +151,9 @@ impl Simulator {
}
}
// this code is not meant to be used in production systems, only in tests
// so a panic due to inconsistent arguments is fine
#[allow(clippy::panic)]
pub fn simulate_epoch(
&mut self,
node_params: &BTreeMap<NodeId, NodeRewardingParameters>,
+9 -1
View File
@@ -19,7 +19,7 @@ pub use shared_key::{
SharedGatewayKey, SharedKeyConversionError, SharedKeyUsageError, SharedSymmetricKey,
};
pub const CURRENT_PROTOCOL_VERSION: u8 = AUTHENTICATE_V2_PROTOCOL_VERSION;
pub const CURRENT_PROTOCOL_VERSION: u8 = EMBEDDED_KEY_ROTATION_INFO_VERSION;
/// Defines the current version of the communication protocol between gateway and clients.
/// It has to be incremented for any breaking change.
@@ -28,10 +28,12 @@ pub const CURRENT_PROTOCOL_VERSION: u8 = AUTHENTICATE_V2_PROTOCOL_VERSION;
// 2 - changes to client credentials structure
// 3 - change to AES-GCM-SIV and non-zero IVs
// 4 - introduction of v2 authentication protocol to prevent reply attacks
// 5 - add key rotation information to the serialised mix packet
pub const INITIAL_PROTOCOL_VERSION: u8 = 1;
pub const CREDENTIAL_UPDATE_V2_PROTOCOL_VERSION: u8 = 2;
pub const AES_GCM_SIV_PROTOCOL_VERSION: u8 = 3;
pub const AUTHENTICATE_V2_PROTOCOL_VERSION: u8 = 4;
pub const EMBEDDED_KEY_ROTATION_INFO_VERSION: u8 = 5;
// TODO: could using `Mac` trait here for OutputSize backfire?
// Should hmac itself be exposed, imported and used instead?
@@ -40,6 +42,7 @@ pub type LegacyGatewayMacSize = <GatewayIntegrityHmacAlgorithm as OutputSizeUser
pub trait GatewayProtocolVersionExt {
fn supports_aes256_gcm_siv(&self) -> bool;
fn supports_authenticate_v2(&self) -> bool;
fn supports_key_rotation_packet(&self) -> bool;
}
impl GatewayProtocolVersionExt for Option<u8> {
@@ -52,4 +55,9 @@ impl GatewayProtocolVersionExt for Option<u8> {
let Some(protocol) = *self else { return false };
protocol >= AUTHENTICATE_V2_PROTOCOL_VERSION
}
fn supports_key_rotation_packet(&self) -> bool {
let Some(protocol) = *self else { return false };
protocol >= EMBEDDED_KEY_ROTATION_INFO_VERSION
}
}
@@ -11,6 +11,9 @@ use tungstenite::Message;
#[non_exhaustive]
pub enum BinaryRequest {
ForwardSphinx { packet: MixPacket },
// identical to `ForwardSphinx`, but also contains information about sphinx key rotation used
ForwardSphinxV2 { packet: MixPacket },
}
#[repr(u8)]
@@ -18,6 +21,9 @@ pub enum BinaryRequest {
#[non_exhaustive]
pub enum BinaryRequestKind {
ForwardSphinx = 1,
// identical to `ForwardSphinx`, but also contains information about sphinx key rotation used
ForwardSphinxV2 = 2,
}
// Right now the only valid `BinaryRequest` is a request to forward a sphinx packet.
@@ -29,6 +35,7 @@ impl BinaryRequest {
pub fn kind(&self) -> BinaryRequestKind {
match self {
BinaryRequest::ForwardSphinx { .. } => BinaryRequestKind::ForwardSphinx,
BinaryRequest::ForwardSphinxV2 { .. } => BinaryRequestKind::ForwardSphinxV2,
}
}
@@ -38,9 +45,13 @@ impl BinaryRequest {
) -> Result<Self, GatewayRequestsError> {
match kind {
BinaryRequestKind::ForwardSphinx => {
let packet = MixPacket::try_from_bytes(plaintext)?;
let packet = MixPacket::try_from_v1_bytes(plaintext)?;
Ok(BinaryRequest::ForwardSphinx { packet })
}
BinaryRequestKind::ForwardSphinxV2 => {
let packet = MixPacket::try_from_v2_bytes(plaintext)?;
Ok(BinaryRequest::ForwardSphinxV2 { packet })
}
}
}
@@ -58,7 +69,8 @@ impl BinaryRequest {
let kind = self.kind();
let plaintext = match self {
BinaryRequest::ForwardSphinx { packet } => packet.into_bytes()?,
BinaryRequest::ForwardSphinx { packet } => packet.into_v1_bytes()?,
BinaryRequest::ForwardSphinxV2 { packet } => packet.into_v2_bytes()?,
};
BinaryData::make_encrypted_blob(kind as u8, &plaintext, shared_key)
@@ -70,7 +82,9 @@ impl BinaryRequest {
) -> Result<Message, GatewayRequestsError> {
// all variants are currently encrypted
let blob = match self {
BinaryRequest::ForwardSphinx { .. } => self.into_encrypted_tagged_bytes(shared_key)?,
BinaryRequest::ForwardSphinx { .. } | BinaryRequest::ForwardSphinxV2 { .. } => {
self.into_encrypted_tagged_bytes(shared_key)?
}
};
Ok(Message::Binary(blob))
+15
View File
@@ -601,6 +601,21 @@ impl Client {
self.base_urls = new_urls
}
/// Create new instance of `Client` using the provided base url and existing client config
pub fn clone_with_new_url(&self, new_url: Url) -> Self {
Client {
base_urls: vec![new_url],
current_idx: Arc::new(Default::default()),
reqwest_client: self.reqwest_client.clone(),
front: self.front.clone(),
retry_limit: self.retry_limit,
#[cfg(target_arch = "wasm32")]
request_timeout: self.request_timeout,
}
}
/// Get the currently configured host that this client uses when sending API requests.
pub fn current_url(&self) -> &Url {
&self.base_urls[self.current_idx.load(std::sync::atomic::Ordering::Relaxed)]
@@ -30,6 +30,10 @@ impl<T> Bincode<T> {
self.0.headers.insert(name, value.into());
self
}
pub(crate) fn map<U, F: FnOnce(T) -> U>(self, op: F) -> Bincode<U> {
Bincode(self.0.map(op))
}
}
impl<T> IntoResponse for Bincode<T>
@@ -32,6 +32,10 @@ impl<T> Json<T> {
self.0.headers.insert(name, value.into());
self
}
pub(crate) fn map<U, F: FnOnce(T) -> U>(self, op: F) -> Json<U> {
Json(self.0.map(op))
}
}
impl<T> IntoResponse for Json<T>
+16 -2
View File
@@ -14,11 +14,10 @@ pub mod bincode;
pub mod json;
pub mod yaml;
pub use bincode::Bincode;
pub use json::Json;
pub use yaml::Yaml;
pub use bincode::Bincode;
#[derive(Debug, Clone, Default)]
pub(crate) struct ResponseWrapper<T> {
data: T,
@@ -33,6 +32,13 @@ impl<T> ResponseWrapper<T> {
}
}
pub(crate) fn map<U, F: FnOnce(T) -> U>(self, op: F) -> ResponseWrapper<U> {
ResponseWrapper {
data: op(self.data),
headers: self.headers,
}
}
#[must_use]
pub(crate) fn with_header(
mut self,
@@ -60,6 +66,14 @@ impl<T> FormattedResponse<T> {
}
}
pub fn map<U, F: FnOnce(T) -> U>(self, op: F) -> FormattedResponse<U> {
match self {
FormattedResponse::Json(inner) => FormattedResponse::Json(inner.map(op)),
FormattedResponse::Yaml(inner) => FormattedResponse::Yaml(inner.map(op)),
FormattedResponse::Bincode(inner) => FormattedResponse::Bincode(inner.map(op)),
}
}
#[must_use]
pub fn with_header(
self,
@@ -30,6 +30,10 @@ impl<T> Yaml<T> {
self.0.headers.insert(name, value.into());
self
}
pub(crate) fn map<U, F: FnOnce(T) -> U>(self, op: F) -> Yaml<U> {
Yaml(self.0.map(op))
}
}
impl<T> IntoResponse for Yaml<T>
@@ -10,7 +10,6 @@ repository = { workspace = true }
[dependencies]
rand = { workspace = true }
bs58 = { workspace = true }
serde = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
@@ -22,7 +21,7 @@ nym-sphinx-types = { path = "../types" }
nym-topology = { path = "../../topology" }
[target."cfg(target_arch = \"wasm32\")".dependencies.wasm-bindgen]
version = "0.2.95"
workspace = true
[dev-dependencies]
rand_chacha = { workspace = true }
@@ -6,4 +6,4 @@ pub mod reply_surb;
pub mod requests;
pub use encryption_key::{SurbEncryptionKey, SurbEncryptionKeySize};
pub use reply_surb::{ReplySurb, ReplySurbError};
pub use reply_surb::{ReplySurb, ReplySurbError, ReplySurbWithKeyRotation};
@@ -8,16 +8,13 @@ use nym_sphinx_addressing::nodes::{
NymNodeRoutingAddress, NymNodeRoutingAddressError, MAX_NODE_ADDRESS_UNPADDED_LEN,
};
use nym_sphinx_params::packet_sizes::PacketSize;
use nym_sphinx_params::{PacketType, ReplySurbKeyDigestAlgorithm};
use nym_sphinx_params::{PacketType, ReplySurbKeyDigestAlgorithm, SphinxKeyRotation};
use nym_sphinx_types::{
NymPacket, SURBMaterial, SphinxError, HEADER_SIZE, NODE_ADDRESS_LENGTH, SURB,
X25519_WITH_EXPLICIT_PAYLOAD_KEYS_VERSION,
};
use nym_topology::{NymRouteProvider, NymTopologyError};
use rand::{CryptoRng, RngCore};
use serde::de::{Error as SerdeError, Visitor};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::fmt::{self, Formatter};
use std::time::Duration;
use thiserror::Error;
@@ -48,44 +45,6 @@ pub struct ReplySurb {
pub(crate) encryption_key: SurbEncryptionKey,
}
// Serialize + Deserialize is not really used anymore (it was for a CBOR experiment)
// however, if we decided we needed it again, it's already here
impl Serialize for ReplySurb {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_bytes(&self.to_bytes())
}
}
impl<'de> Deserialize<'de> for ReplySurb {
fn deserialize<D>(deserializer: D) -> Result<Self, <D as Deserializer<'de>>::Error>
where
D: Deserializer<'de>,
{
struct ReplySurbVisitor;
impl Visitor<'_> for ReplySurbVisitor {
type Value = ReplySurb;
fn expecting(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
write!(formatter, "A replySURB must contain a valid symmetric encryption key and a correctly formed sphinx header")
}
fn visit_bytes<E>(self, bytes: &[u8]) -> Result<Self::Value, E>
where
E: SerdeError,
{
ReplySurb::from_bytes(bytes)
.map_err(|_| SerdeError::invalid_length(bytes.len(), &self))
}
}
deserializer.deserialize_bytes(ReplySurbVisitor)
}
}
impl ReplySurb {
/// base overhead of a reply surb that exists regardless of type or number of key materials.
pub(crate) const BASE_OVERHEAD: usize =
@@ -123,6 +82,7 @@ impl ReplySurb {
Ok(ReplySurb {
surb: surb_material.construct_SURB().unwrap(),
encryption_key: SurbEncryptionKey::new(rng),
// used_key_rotation: SphinxKeyRotation::from(topology.current_key_rotation()),
})
}
@@ -198,8 +158,75 @@ impl ReplySurb {
.use_surb(message_bytes, packet_size.payload_size())
.expect("this error indicates inconsistent message length checking - it shouldn't have happened!");
let first_hop_address = NymNodeRoutingAddress::try_from(first_hop).unwrap();
let first_hop_address = NymNodeRoutingAddress::try_from(first_hop)?;
Ok((NymPacket::Sphinx(packet), first_hop_address))
}
pub fn to_legacy(self) -> ReplySurbWithKeyRotation {
self.with_key_rotation(SphinxKeyRotation::Unknown)
}
pub fn with_key_rotation(self, key_rotation: SphinxKeyRotation) -> ReplySurbWithKeyRotation {
ReplySurbWithKeyRotation {
inner: self,
key_rotation,
}
}
}
#[derive(Debug)]
pub struct ReplySurbWithKeyRotation {
pub(crate) inner: ReplySurb,
pub(crate) key_rotation: SphinxKeyRotation,
}
impl ReplySurbWithKeyRotation {
pub fn encryption_key(&self) -> &SurbEncryptionKey {
self.inner.encryption_key()
}
pub fn inner_reply_surb(&self) -> &ReplySurb {
&self.inner
}
pub fn key_rotation(&self) -> SphinxKeyRotation {
self.key_rotation
}
pub fn apply_surb<M: AsRef<[u8]>>(
self,
message: M,
packet_size: PacketSize,
_packet_type: PacketType,
) -> Result<AppliedReplySurb, ReplySurbError> {
let (packet, first_hop_address) =
self.inner.apply_surb(message, packet_size, _packet_type)?;
Ok(AppliedReplySurb {
packet,
first_hop_address,
key_rotation: self.key_rotation,
})
}
}
pub struct AppliedReplySurb {
pub(crate) packet: NymPacket,
pub(crate) first_hop_address: NymNodeRoutingAddress,
pub(crate) key_rotation: SphinxKeyRotation,
}
impl AppliedReplySurb {
pub fn first_hop_address(&self) -> NymNodeRoutingAddress {
self.first_hop_address
}
pub fn key_rotation(&self) -> SphinxKeyRotation {
self.key_rotation
}
pub fn into_packet(self) -> NymPacket {
self.packet
}
}
@@ -1,16 +1,16 @@
// Copyright 2022 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::{ReplySurb, ReplySurbError};
use crate::requests::v1::{AdditionalSurbsV1, DataV1, HeartbeatV1};
use crate::requests::v2::{AdditionalSurbsV2, DataV2, HeartbeatV2};
use crate::{ReplySurbError, ReplySurbWithKeyRotation};
use nym_sphinx_addressing::clients::{Recipient, RecipientFormattingError};
use nym_sphinx_params::key_rotation::InvalidSphinxKeyRotation;
use rand::{CryptoRng, RngCore};
use std::fmt::{Display, Formatter};
use std::mem;
use thiserror::Error;
use crate::requests::v1::{AdditionalSurbsV1, DataV1, HeartbeatV1};
use crate::requests::v2::{AdditionalSurbsV2, DataV2, HeartbeatV2};
#[cfg(target_arch = "wasm32")]
use wasm_bindgen::prelude::*;
@@ -84,7 +84,7 @@ impl AnonymousSenderTag {
#[derive(Debug, Error)]
pub enum InvalidReplyRequestError {
#[error("Did not provide sufficient number of bytes to deserialize a valid request")]
#[error("Did not provide sufficient number of bytes to deserialise a valid request")]
RequestTooShortToDeserialize,
#[error("{received} is not a valid content tag for a repliable message")]
@@ -93,10 +93,13 @@ pub enum InvalidReplyRequestError {
#[error("{received} is not a valid content tag for a reply message")]
InvalidReplyContentTag { received: u8 },
#[error("failed to deserialize recipient information - {0}")]
#[error("failed to deserialise sphinx key rotation details: {0}")]
MalformedSphinxKeyRotation(#[from] InvalidSphinxKeyRotation),
#[error("failed to deserialise recipient information: {0}")]
MalformedRecipient(#[from] RecipientFormattingError),
#[error("failed to deserialize replySURB - {0}")]
#[error("failed to deserialise replySURB: {0}")]
MalformedReplySurb(#[from] ReplySurbError),
}
@@ -136,7 +139,7 @@ impl RepliableMessage {
use_legacy_surb_format: bool,
data: Vec<u8>,
sender_tag: AnonymousSenderTag,
reply_surbs: Vec<ReplySurb>,
reply_surbs: Vec<ReplySurbWithKeyRotation>,
) -> Self {
let content = if use_legacy_surb_format {
RepliableMessageContent::Data(DataV1 {
@@ -159,7 +162,7 @@ impl RepliableMessage {
pub fn new_additional_surbs(
use_legacy_surb_format: bool,
sender_tag: AnonymousSenderTag,
reply_surbs: Vec<ReplySurb>,
reply_surbs: Vec<ReplySurbWithKeyRotation>,
) -> Self {
let content = if use_legacy_surb_format {
RepliableMessageContent::AdditionalSurbs(AdditionalSurbsV1 { reply_surbs })
@@ -484,9 +487,10 @@ mod tests {
use crate::requests::v1::{AdditionalSurbsV1, DataV1, HeartbeatV1};
use crate::requests::v2::{AdditionalSurbsV2, DataV2, HeartbeatV2};
use crate::requests::{AnonymousSenderTag, RepliableMessageContent, ReplyMessageContent};
use crate::{ReplySurb, SurbEncryptionKey};
use crate::{ReplySurb, ReplySurbWithKeyRotation, SurbEncryptionKey};
use nym_crypto::asymmetric::{ed25519, x25519};
use nym_sphinx_addressing::clients::Recipient;
use nym_sphinx_params::SphinxKeyRotation;
use nym_sphinx_types::{
Delay, Destination, DestinationAddressBytes, Node, NodeAddressBytes, PrivateKey,
SURBMaterial, NODE_ADDRESS_LENGTH, X25519_WITH_EXPLICIT_PAYLOAD_KEYS_VERSION,
@@ -571,10 +575,12 @@ mod tests {
n: usize,
legacy: bool,
hops: u8,
) -> Vec<ReplySurb> {
) -> Vec<ReplySurbWithKeyRotation> {
let mut surbs = Vec::with_capacity(n);
for _ in 0..n {
surbs.push(reply_surb(rng, legacy, hops))
surbs.push(
reply_surb(rng, legacy, hops).with_key_rotation(SphinxKeyRotation::Unknown),
)
}
surbs
}
@@ -2,7 +2,7 @@
// SPDX-License-Identifier: Apache-2.0
use crate::requests::InvalidReplyRequestError;
use crate::ReplySurb;
use crate::{ReplySurb, ReplySurbWithKeyRotation};
use nym_sphinx_types::PAYLOAD_KEY_SIZE;
use std::fmt::Display;
use std::mem;
@@ -14,10 +14,10 @@ const fn v1_reply_surb_serialised_len() -> usize {
ReplySurb::BASE_OVERHEAD + 4 * PAYLOAD_KEY_SIZE
}
fn v1_reply_surbs_serialised_len(surbs: &[ReplySurb]) -> usize {
fn v1_reply_surbs_serialised_len(surbs: &[ReplySurbWithKeyRotation]) -> usize {
// sanity checks; this should probably be removed later on
if let Some(reply_surb) = surbs.first() {
if reply_surb.surb.uses_key_seeds() {
if reply_surb.inner.surb.uses_key_seeds() {
error!("using v1 surbs encoding with updated structure - the surbs will be unusable")
}
}
@@ -30,7 +30,7 @@ fn v1_reply_surbs_serialised_len(surbs: &[ReplySurb]) -> usize {
// NUM_SURBS (u32) || SURB_DATA
fn recover_reply_surbs_v1(
bytes: &[u8],
) -> Result<(Vec<ReplySurb>, usize), InvalidReplyRequestError> {
) -> Result<(Vec<ReplySurbWithKeyRotation>, usize), InvalidReplyRequestError> {
let mut consumed = mem::size_of::<u32>();
if bytes.len() < consumed {
return Err(InvalidReplyRequestError::RequestTooShortToDeserialize);
@@ -45,7 +45,7 @@ fn recover_reply_surbs_v1(
let mut reply_surbs = Vec::with_capacity(num_surbs as usize);
for _ in 0..num_surbs as usize {
let surb_bytes = &bytes[consumed..consumed + surb_size];
let reply_surb = ReplySurb::from_bytes(surb_bytes)?;
let reply_surb = ReplySurb::from_bytes(surb_bytes)?.to_legacy();
reply_surbs.push(reply_surb);
consumed += surb_size;
@@ -55,19 +55,21 @@ fn recover_reply_surbs_v1(
}
// length (u32) prefixed reply surbs with legacy serialisation of 4 hops and full payload keys attached
fn reply_surbs_bytes_v1(reply_surbs: &[ReplySurb]) -> impl Iterator<Item = u8> + use<'_> {
fn reply_surbs_bytes_v1(
reply_surbs: &[ReplySurbWithKeyRotation],
) -> impl Iterator<Item = u8> + use<'_> {
let num_surbs = reply_surbs.len() as u32;
num_surbs
.to_be_bytes()
.into_iter()
.chain(reply_surbs.iter().flat_map(|s| s.to_bytes()))
.chain(reply_surbs.iter().flat_map(|s| s.inner.to_bytes()))
}
#[derive(Debug)]
pub struct DataV1 {
pub message: Vec<u8>,
pub reply_surbs: Vec<ReplySurb>,
pub reply_surbs: Vec<ReplySurbWithKeyRotation>,
}
impl Display for DataV1 {
@@ -83,7 +85,7 @@ impl Display for DataV1 {
#[derive(Debug)]
pub struct AdditionalSurbsV1 {
pub reply_surbs: Vec<ReplySurb>,
pub reply_surbs: Vec<ReplySurbWithKeyRotation>,
}
impl Display for AdditionalSurbsV1 {
@@ -98,7 +100,7 @@ impl Display for AdditionalSurbsV1 {
#[derive(Debug)]
pub struct HeartbeatV1 {
pub additional_reply_surbs: Vec<ReplySurb>,
pub additional_reply_surbs: Vec<ReplySurbWithKeyRotation>,
}
impl Display for HeartbeatV1 {
@@ -2,7 +2,8 @@
// SPDX-License-Identifier: Apache-2.0
use crate::requests::InvalidReplyRequestError;
use crate::ReplySurb;
use crate::{ReplySurb, ReplySurbWithKeyRotation};
use nym_sphinx_params::SphinxKeyRotation;
use nym_sphinx_types::constants::PAYLOAD_KEY_SEED_SIZE;
use std::fmt::Display;
use std::iter::once;
@@ -13,21 +14,29 @@ const fn v2_reply_surb_serialised_len(num_hops: u8) -> usize {
}
// sphinx doesn't support more than 5 hops (so cast to u8 is safe)
// ASSUMPTION: all surbs are generated with the same parameters (if they're not, then the client is hurting itself)
fn reply_surbs_hops(reply_surbs: &[ReplySurb]) -> u8 {
// ASSUMPTION: all surbs are generated with the same parameters (if they're not, then the client is hurting itself),
// which includes the same number of hops and the same underlying sphinx key rotation
fn reply_surbs_hops(reply_surbs: &[ReplySurbWithKeyRotation]) -> u8 {
reply_surbs
.first()
.map(|reply_surb| reply_surb.surb.materials_count() as u8)
.map(|reply_surb| reply_surb.inner.surb.materials_count() as u8)
.unwrap_or_default()
}
fn v2_reply_surbs_serialised_len(surbs: &[ReplySurb]) -> usize {
fn key_rotation(reply_surbs: &[ReplySurbWithKeyRotation]) -> SphinxKeyRotation {
reply_surbs
.first()
.map(|reply_surb| reply_surb.key_rotation)
.unwrap_or_default()
}
fn v2_reply_surbs_serialised_len(surbs: &[ReplySurbWithKeyRotation]) -> usize {
let num_surbs = surbs.len();
let num_hops = reply_surbs_hops(surbs);
// sanity checks; this should probably be removed later on
if let Some(reply_surb) = surbs.first() {
if !reply_surb.surb.uses_key_seeds() {
if !reply_surb.inner.surb.uses_key_seeds() {
error!("using v2 surbs encoding with legacy structure - the surbs will be unusable")
}
}
@@ -35,14 +44,14 @@ fn v2_reply_surbs_serialised_len(surbs: &[ReplySurb]) -> usize {
// when serialising surbs are always prepended with:
// - u16-encoded count,
// - u8-encoded number of hops
// - u8 reserved value
// - u8-encoded sphinx key rotation (or unused for 'old' variant)
4 + num_surbs * v2_reply_surb_serialised_len(num_hops)
}
// NUM_SURBS (u16) || HOPS (u8) || RESERVED (u8) || SURB_DATA
// NUM_SURBS (u16) || HOPS (u8) || KEY ROTATION (u8) || SURB_DATA
fn recover_reply_surbs_v2(
bytes: &[u8],
) -> Result<(Vec<ReplySurb>, usize), InvalidReplyRequestError> {
) -> Result<(Vec<ReplySurbWithKeyRotation>, usize), InvalidReplyRequestError> {
if bytes.len() < 4 {
return Err(InvalidReplyRequestError::RequestTooShortToDeserialize);
}
@@ -50,7 +59,7 @@ fn recover_reply_surbs_v2(
// we're not attaching more than 65k surbs...
let num_surbs = u16::from_be_bytes([bytes[0], bytes[1]]);
let num_hops = bytes[2];
let _reserved = bytes[3];
let key_rotation = SphinxKeyRotation::try_from(bytes[3])?;
let mut consumed = 4;
let surb_size = v2_reply_surb_serialised_len(num_hops);
@@ -61,7 +70,7 @@ fn recover_reply_surbs_v2(
let mut reply_surbs = Vec::with_capacity(num_surbs as usize);
for _ in 0..num_surbs as usize {
let surb_bytes = &bytes[consumed..consumed + surb_size];
let reply_surb = ReplySurb::from_bytes(surb_bytes)?;
let reply_surb = ReplySurb::from_bytes(surb_bytes)?.with_key_rotation(key_rotation);
reply_surbs.push(reply_surb);
consumed += surb_size;
@@ -70,23 +79,25 @@ fn recover_reply_surbs_v2(
Ok((reply_surbs, consumed))
}
fn reply_surbs_bytes_v2(reply_surbs: &[ReplySurb]) -> impl Iterator<Item = u8> + use<'_> {
fn reply_surbs_bytes_v2(
reply_surbs: &[ReplySurbWithKeyRotation],
) -> impl Iterator<Item = u8> + use<'_> {
let num_surbs = reply_surbs.len() as u16;
let num_hops = reply_surbs_hops(reply_surbs);
let reserved = 0;
let key_rotation = key_rotation(reply_surbs) as u8;
num_surbs
.to_be_bytes()
.into_iter()
.chain(once(num_hops))
.chain(once(reserved))
.chain(reply_surbs.iter().flat_map(|surb| surb.to_bytes()))
.chain(once(key_rotation))
.chain(reply_surbs.iter().flat_map(|surb| surb.inner.to_bytes()))
}
#[derive(Debug)]
pub struct DataV2 {
pub message: Vec<u8>,
pub reply_surbs: Vec<ReplySurb>,
pub reply_surbs: Vec<ReplySurbWithKeyRotation>,
}
impl Display for DataV2 {
@@ -102,7 +113,7 @@ impl Display for DataV2 {
#[derive(Debug)]
pub struct AdditionalSurbsV2 {
pub reply_surbs: Vec<ReplySurb>,
pub reply_surbs: Vec<ReplySurbWithKeyRotation>,
}
impl Display for AdditionalSurbsV2 {
@@ -117,7 +128,7 @@ impl Display for AdditionalSurbsV2 {
#[derive(Debug)]
pub struct HeartbeatV2 {
pub additional_reply_surbs: Vec<ReplySurb>,
pub additional_reply_surbs: Vec<ReplySurbWithKeyRotation>,
}
impl Display for HeartbeatV2 {
+12 -2
View File
@@ -10,7 +10,9 @@ use nym_sphinx_addressing::nodes::NymNodeRoutingAddress;
use nym_sphinx_chunking::fragment::COVER_FRAG_ID;
use nym_sphinx_forwarding::packet::MixPacket;
use nym_sphinx_params::packet_sizes::PacketSize;
use nym_sphinx_params::{PacketEncryptionAlgorithm, PacketHkdfAlgorithm, PacketType};
use nym_sphinx_params::{
PacketEncryptionAlgorithm, PacketHkdfAlgorithm, PacketType, SphinxKeyRotation,
};
use nym_sphinx_types::NymPacket;
use nym_topology::{NymRouteProvider, NymTopologyError};
use rand::{CryptoRng, RngCore};
@@ -125,6 +127,9 @@ where
let delays = nym_sphinx_routing::generate_hop_delays(average_packet_delay, route.len());
let destination = full_address.as_sphinx_destination();
let rotation_id = topology.current_key_rotation();
let sphinx_key_rotation = SphinxKeyRotation::from(rotation_id);
let first_hop_address =
NymNodeRoutingAddress::try_from(route.first().unwrap().address).unwrap();
@@ -146,7 +151,12 @@ where
)?,
};
Ok(MixPacket::new(first_hop_address, packet, packet_type))
Ok(MixPacket::new(
first_hop_address,
packet,
packet_type,
sphinx_key_rotation,
))
}
/// Helper function used to determine if given message represents a loop cover message.
+1 -1
View File
@@ -11,5 +11,5 @@ repository = { workspace = true }
nym-sphinx-addressing = { path = "../addressing" }
nym-sphinx-params = { path = "../params" }
nym-sphinx-types = { path = "../types", features = ["sphinx", "outfox"] }
nym-outfox = { path = "../../../nym-outfox" }
nym-sphinx-anonymous-replies = { path = "../anonymous-replies" }
thiserror = { workspace = true }
+106 -35
View File
@@ -2,24 +2,36 @@
// SPDX-License-Identifier: Apache-2.0
use nym_sphinx_addressing::nodes::{NymNodeRoutingAddress, NymNodeRoutingAddressError};
use nym_sphinx_params::{PacketSize, PacketType};
use nym_sphinx_params::{PacketSize, PacketType, SphinxKeyRotation};
use nym_sphinx_types::{NymPacket, NymPacketError};
use std::fmt::{self, Debug, Formatter};
use nym_sphinx_anonymous_replies::reply_surb::AppliedReplySurb;
use nym_sphinx_params::key_rotation::InvalidSphinxKeyRotation;
use nym_sphinx_params::packet_sizes::InvalidPacketSize;
use nym_sphinx_params::packet_types::InvalidPacketType;
use std::net::SocketAddr;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum MixPacketFormattingError {
#[error("too few bytes provided to recover from bytes")]
TooFewBytesProvided,
#[error("provided packet mode is invalid")]
InvalidPacketType,
#[error("received request had invalid size - received {0}")]
InvalidPacketSize(usize),
#[error("provided packet mode is invalid: {0}")]
InvalidPacketType(#[from] InvalidPacketType),
#[error("received request had an invalid packet size: {0}")]
InvalidPacketSize(#[from] InvalidPacketSize),
#[error("provided key rotation is invalid: {0}")]
InvalidKeyRotation(#[from] InvalidSphinxKeyRotation),
#[error("address field was incorrectly encoded")]
InvalidAddress,
#[error("received sphinx packet was malformed")]
MalformedSphinxPacket,
#[error("Packet: {0}")]
Packet(#[from] NymPacketError),
}
@@ -30,20 +42,12 @@ impl From<NymNodeRoutingAddressError> for MixPacketFormattingError {
}
}
#[derive(Debug)]
pub struct MixPacket {
next_hop: NymNodeRoutingAddress,
packet: NymPacket,
packet_type: PacketType,
}
impl Debug for MixPacket {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
write!(
f,
"MixPacket to {:?} with packet_type {:?}. Packet {:?}",
self.next_hop, self.packet_type, self.packet
)
}
key_rotation: SphinxKeyRotation,
}
impl MixPacket {
@@ -51,11 +55,25 @@ impl MixPacket {
next_hop: NymNodeRoutingAddress,
packet: NymPacket,
packet_type: PacketType,
key_rotation: SphinxKeyRotation,
) -> Self {
MixPacket {
next_hop,
packet,
packet_type,
key_rotation,
}
}
pub fn from_applied_surb(
applied_reply_surb: AppliedReplySurb,
packet_type: PacketType,
) -> Self {
MixPacket {
next_hop: applied_reply_surb.first_hop_address(),
key_rotation: applied_reply_surb.key_rotation(),
packet: applied_reply_surb.into_packet(),
packet_type,
}
}
@@ -63,6 +81,10 @@ impl MixPacket {
self.next_hop
}
pub fn next_hop_address(&self) -> SocketAddr {
self.next_hop.into()
}
pub fn packet(&self) -> &NymPacket {
&self.packet
}
@@ -71,45 +93,94 @@ impl MixPacket {
self.packet
}
pub fn key_rotation(&self) -> SphinxKeyRotation {
self.key_rotation
}
pub fn packet_type(&self) -> PacketType {
self.packet_type
}
// the message is formatted as follows:
// packet_type || FIRST_HOP || packet
pub fn try_from_bytes(b: &[u8]) -> Result<Self, MixPacketFormattingError> {
let packet_type = match PacketType::try_from(b[0]) {
Ok(mode) => mode,
Err(_) => return Err(MixPacketFormattingError::InvalidPacketType),
};
pub fn try_from_v1_bytes(b: &[u8]) -> Result<Self, MixPacketFormattingError> {
// we need at least 1 byte to read packet type and another one to read type of the encoded first hop address
if b.len() < 2 {
return Err(MixPacketFormattingError::TooFewBytesProvided);
}
let packet_type = PacketType::try_from(b[0])?;
let next_hop = NymNodeRoutingAddress::try_from_bytes(&b[1..])?;
let addr_offset = next_hop.bytes_min_len();
let packet_data = &b[addr_offset + 1..];
let packet_size = packet_data.len();
if PacketSize::get_type(packet_size).is_err() {
Err(MixPacketFormattingError::InvalidPacketSize(packet_size))
} else {
let packet = match packet_type {
PacketType::Outfox => NymPacket::outfox_from_bytes(packet_data)?,
_ => NymPacket::sphinx_from_bytes(packet_data)?,
};
Ok(MixPacket {
next_hop,
packet,
packet_type,
})
}
// make sure the received data length corresponds to a valid packet
let _ = PacketSize::get_type(packet_size)?;
let packet = match packet_type {
PacketType::Mix => NymPacket::sphinx_from_bytes(packet_data)?,
PacketType::Outfox => NymPacket::outfox_from_bytes(packet_data)?,
};
Ok(MixPacket {
next_hop,
packet,
packet_type,
key_rotation: SphinxKeyRotation::Unknown,
})
}
pub fn into_bytes(self) -> Result<Vec<u8>, MixPacketFormattingError> {
pub fn into_v1_bytes(self) -> Result<Vec<u8>, MixPacketFormattingError> {
Ok(std::iter::once(self.packet_type as u8)
.chain(self.next_hop.as_bytes())
.chain(self.packet.to_bytes()?)
.collect())
}
// the message is formatted as follows:
// packet_type || KEY_ROTATION || FIRST_HOP || packet
pub fn try_from_v2_bytes(b: &[u8]) -> Result<Self, MixPacketFormattingError> {
// we need at least 1 byte to read packet type, 1 byte to read key rotation
// and finally another one to read type of the encoded first hop address
if b.len() < 3 {
return Err(MixPacketFormattingError::TooFewBytesProvided);
}
let packet_type = PacketType::try_from(b[0])?;
let key_rotation = SphinxKeyRotation::try_from(b[1])?;
let next_hop = NymNodeRoutingAddress::try_from_bytes(&b[2..])?;
let addr_offset = next_hop.bytes_min_len();
let packet_data = &b[addr_offset + 2..];
let packet_size = packet_data.len();
// make sure the received data length corresponds to a valid packet
let _ = PacketSize::get_type(packet_size)?;
let packet = match packet_type {
PacketType::Mix => NymPacket::sphinx_from_bytes(packet_data)?,
PacketType::Outfox => NymPacket::outfox_from_bytes(packet_data)?,
};
Ok(MixPacket {
next_hop,
packet,
packet_type,
key_rotation,
})
}
pub fn into_v2_bytes(self) -> Result<Vec<u8>, MixPacketFormattingError> {
Ok(std::iter::once(self.packet_type as u8)
.chain(std::iter::once(self.key_rotation as u8))
.chain(self.next_hop.as_bytes())
.chain(self.packet.to_bytes()?)
.collect())
}
}
// TODO: test for serialization and errors!
+61 -24
View File
@@ -3,6 +3,7 @@
use crate::packet::{FramedNymPacket, Header};
use bytes::{Buf, BufMut, BytesMut};
use nym_sphinx_params::key_rotation::InvalidSphinxKeyRotation;
use nym_sphinx_params::packet_sizes::{InvalidPacketSize, PacketSize};
use nym_sphinx_params::packet_types::InvalidPacketType;
use nym_sphinx_params::packet_version::{InvalidPacketVersion, PacketVersion};
@@ -23,6 +24,9 @@ pub enum NymCodecError {
#[error("the packet version information was malformed: {0}")]
InvalidPacketVersion(#[from] InvalidPacketVersion),
#[error("the sphinx key rotation information was malformed: {0}")]
InvalidSphinxKeyRotation(#[from] InvalidSphinxKeyRotation),
#[error("received unsupported packet version {received}. max supported is {max_supported}")]
UnsupportedPacketVersion {
received: PacketVersion,
@@ -65,8 +69,8 @@ impl Decoder for NymCodec {
fn decode(&mut self, src: &mut BytesMut) -> Result<Option<Self::Item>, Self::Error> {
if src.is_empty() {
// can't do anything if we have no bytes, but let's reserve enough for the most
// conservative case, i.e. receiving an ack packet
src.reserve(Header::SIZE + PacketSize::AckPacket.size());
// conservative case, i.e. receiving a legacy ack packet
src.reserve(Header::INITIAL_SIZE + PacketSize::AckPacket.size());
return Ok(None);
}
@@ -77,17 +81,20 @@ impl Decoder for NymCodec {
None => return Ok(None), // we have some data but not enough to get header back
};
let header_size = header.encoded_size();
let packet_size = header.packet_size.size();
let frame_len = Header::SIZE + packet_size;
if src.len() < frame_len {
let frame_size = header_size + packet_size;
if src.len() < frame_size {
// we don't have enough bytes to read the rest of frame
// (we have already read the full header)
src.reserve(packet_size);
return Ok(None);
}
// advance buffer past the header - at this point we have enough bytes
src.advance(Header::SIZE);
src.advance(header_size);
let packet_bytes = src.split_to(packet_size);
let packet = if let Some(slice) = packet_bytes.get(..) {
// here it could be debatable whether stream is corrupt or not,
@@ -100,8 +107,7 @@ impl Decoder for NymCodec {
return Ok(None);
};
// let packet = SphinxPacket::from_bytes(&sphinx_packet_bytes)?;
let nymsphinx_packet = FramedNymPacket { header, packet };
let framed_packet = FramedNymPacket { header, packet };
// As per docs:
// Before returning from the function, implementations should ensure that the buffer
@@ -114,11 +120,11 @@ impl Decoder for NymCodec {
// we also assume the next packet coming from the same client will use exactly the same versioning
// as the current packet
let mut allocate_for_next_packet = Header::SIZE + PacketSize::AckPacket.size();
let mut allocate_for_next_packet = header.encoded_size() + PacketSize::AckPacket.size();
if !src.is_empty() {
match Header::decode(src) {
Ok(Some(next_header)) => {
allocate_for_next_packet = Header::SIZE + next_header.packet_size.size();
allocate_for_next_packet = next_header.frame_size();
}
Ok(None) => {
// we don't have enough information to know how much to reserve, fallback to the ack case
@@ -126,22 +132,52 @@ impl Decoder for NymCodec {
// the next frame will be malformed but let's leave handling the error to the next
// call to 'decode', as presumably, the current sphinx packet is still valid
Err(_) => return Ok(Some(nymsphinx_packet)),
Err(_) => return Ok(Some(framed_packet)),
};
}
src.reserve(allocate_for_next_packet);
Ok(Some(nymsphinx_packet))
Ok(Some(framed_packet))
}
}
#[cfg(test)]
mod packet_encoding {
use super::*;
use nym_sphinx_params::packet_version::{
CURRENT_PACKET_VERSION, INITIAL_PACKET_VERSION_NUMBER,
};
use nym_sphinx_params::PacketType;
use nym_sphinx_types::{
Delay as SphinxDelay, Destination, DestinationAddressBytes, Node, NodeAddressBytes,
PrivateKey, DESTINATION_ADDRESS_LENGTH, IDENTIFIER_LENGTH, NODE_ADDRESS_LENGTH,
NymPacket, PrivateKey, DESTINATION_ADDRESS_LENGTH, IDENTIFIER_LENGTH, NODE_ADDRESS_LENGTH,
};
fn dummy_header() -> Header {
Header {
packet_version: CURRENT_PACKET_VERSION,
packet_size: Default::default(),
key_rotation: Default::default(),
packet_type: Default::default(),
}
}
fn dummy_outfox() -> Header {
Header {
packet_type: PacketType::Outfox,
packet_size: PacketSize::OutfoxRegularPacket,
..dummy_legacy_header()
}
}
fn dummy_legacy_header() -> Header {
Header {
packet_version: PacketVersion::try_from(INITIAL_PACKET_VERSION_NUMBER).unwrap(),
packet_size: Default::default(),
key_rotation: Default::default(),
packet_type: Default::default(),
}
}
fn random_pubkey() -> nym_sphinx_types::PublicKey {
let private_key = PrivateKey::random();
(&private_key).into()
@@ -222,7 +258,7 @@ mod packet_encoding {
#[test]
fn whole_packet_can_be_decoded_from_a_valid_encoded_instance() {
let header = Default::default();
let header = dummy_header();
let sphinx_packet = make_valid_sphinx_packet(Default::default());
let sphinx_bytes = sphinx_packet.to_bytes().unwrap();
@@ -241,7 +277,7 @@ mod packet_encoding {
#[test]
fn whole_outfox_can_be_decoded_from_a_valid_encoded_instance() {
let header = Header::outfox();
let header = dummy_outfox();
let packet = make_valid_outfox_packet(PacketSize::OutfoxRegularPacket);
let packet_bytes = packet.to_bytes().unwrap();
@@ -269,7 +305,7 @@ mod packet_encoding {
assert!(NymCodec.decode(&mut empty_bytes).unwrap().is_none());
assert_eq!(
empty_bytes.capacity(),
Header::SIZE + PacketSize::AckPacket.size()
Header::INITIAL_SIZE + PacketSize::AckPacket.size()
);
}
@@ -287,13 +323,14 @@ mod packet_encoding {
let header = Header {
packet_version: PacketVersion::new(),
packet_size,
..Default::default()
key_rotation: Default::default(),
packet_type: Default::default(),
};
let mut bytes = BytesMut::new();
header.encode(&mut bytes);
assert!(NymCodec.decode(&mut bytes).unwrap().is_none());
assert_eq!(bytes.capacity(), Header::SIZE + packet_size.size())
assert_eq!(bytes.capacity(), Header::V8_SIZE + packet_size.size())
}
}
@@ -301,7 +338,7 @@ mod packet_encoding {
fn for_full_frame_with_versioned_header() {
// if full frame is used exactly, there should be enough space for header + ack packet
let packet = FramedNymPacket {
header: Header::default(),
header: dummy_header(),
packet: make_valid_sphinx_packet(Default::default()),
};
@@ -310,7 +347,7 @@ mod packet_encoding {
assert!(NymCodec.decode(&mut bytes).unwrap().is_some());
assert_eq!(
bytes.capacity(),
Header::SIZE + PacketSize::AckPacket.size()
Header::V8_SIZE + PacketSize::AckPacket.size()
);
}
@@ -327,7 +364,7 @@ mod packet_encoding {
for packet_size in packet_sizes {
let first_packet = FramedNymPacket {
header: Header::default(),
header: dummy_header(),
packet: make_valid_sphinx_packet(Default::default()),
};
@@ -346,12 +383,12 @@ mod packet_encoding {
#[test]
fn can_decode_two_packets_immediately() {
let packet1 = FramedNymPacket {
header: Header::default(),
header: dummy_header(),
packet: make_valid_sphinx_packet(Default::default()),
};
let packet2 = FramedNymPacket {
header: Header::default(),
header: dummy_header(),
packet: make_valid_sphinx_packet(Default::default()),
};
@@ -368,12 +405,12 @@ mod packet_encoding {
#[test]
fn can_decode_two_packets_in_separate_calls() {
let packet1 = FramedNymPacket {
header: Header::default(),
header: dummy_header(),
packet: make_valid_sphinx_packet(Default::default()),
};
let packet2 = FramedNymPacket {
header: Header::default(),
header: dummy_header(),
packet: make_valid_sphinx_packet(Default::default()),
};
+84 -22
View File
@@ -3,6 +3,8 @@
use crate::codec::NymCodecError;
use bytes::{BufMut, BytesMut};
use nym_sphinx_forwarding::packet::MixPacket;
use nym_sphinx_params::key_rotation::SphinxKeyRotation;
use nym_sphinx_params::packet_sizes::PacketSize;
use nym_sphinx_params::packet_version::{PacketVersion, CURRENT_PACKET_VERSION};
use nym_sphinx_params::PacketType;
@@ -17,8 +19,20 @@ pub struct FramedNymPacket {
pub(crate) packet: NymPacket,
}
impl From<MixPacket> for FramedNymPacket {
fn from(packet: MixPacket) -> Self {
let typ = packet.packet_type();
let rot = packet.key_rotation();
FramedNymPacket::new(packet.into_packet(), typ, rot)
}
}
impl FramedNymPacket {
pub fn new(packet: NymPacket, packet_type: PacketType) -> Self {
pub fn new(
packet: NymPacket,
packet_type: PacketType,
key_rotation: SphinxKeyRotation,
) -> Self {
// If this fails somebody is using the library in a super incorrect way, because they
// already managed to somehow create a sphinx packet
let packet_size = PacketSize::get_type(packet.len()).unwrap();
@@ -26,6 +40,7 @@ impl FramedNymPacket {
let header = Header {
packet_version: PacketVersion::new(),
packet_size,
key_rotation,
packet_type,
};
@@ -52,6 +67,10 @@ impl FramedNymPacket {
&self.packet
}
pub fn key_rotation(&self) -> SphinxKeyRotation {
self.header.key_rotation
}
pub fn is_sphinx(&self) -> bool {
self.packet.is_sphinx()
}
@@ -60,13 +79,16 @@ impl FramedNymPacket {
// Contains any metadata that might be useful for sending between mix nodes.
// TODO: in theory all those data could be put in a single `u8` by setting appropriate bits,
// but would that really be worth it?
#[derive(Debug, Default, PartialEq, Eq, Copy, Clone)]
#[derive(Debug, PartialEq, Eq, Copy, Clone)]
pub struct Header {
/// Represents the wire format version used to construct this packet.
pub(crate) packet_version: PacketVersion,
pub packet_version: PacketVersion,
/// Represents type and consequently size of the included SphinxPacket.
pub(crate) packet_size: PacketSize,
pub packet_size: PacketSize,
/// Represents information regarding which key rotation has been used for constructing this packet.
pub key_rotation: SphinxKeyRotation,
/// Represents whether this packet is sent in a `vpn_mode` meaning it should not get delayed
/// and shared keys might get reused. Mixnodes are capable of inferring this mode from the
@@ -77,35 +99,48 @@ pub struct Header {
/// (note: this will be behind some encryption, either something implemented by us or some SSL action)
// Note: currently packet_type is deprecated but is still left as a concept behind to not break
// compatibility with existing network
pub(crate) packet_type: PacketType,
pub packet_type: PacketType,
}
impl Header {
pub(crate) const SIZE: usize = 3;
pub fn outfox() -> Header {
Header {
packet_version: PacketVersion::default(),
packet_size: PacketSize::OutfoxRegularPacket,
packet_type: PacketType::Outfox,
}
}
pub(crate) const INITIAL_SIZE: usize = 3;
pub(crate) const V8_SIZE: usize = 4;
pub(crate) fn encode(&self, dst: &mut BytesMut) {
dst.reserve(Self::SIZE);
let len = self.encoded_size();
if dst.len() < len {
dst.reserve(len);
}
dst.put_u8(self.packet_version.as_u8());
dst.put_u8(self.packet_size as u8);
dst.put_u8(self.packet_type as u8);
if !self.packet_version.is_initial() {
dst.put_u8(self.key_rotation as u8)
}
// reserve bytes for the actual packet
dst.reserve(self.packet_size.size());
}
pub(crate) fn frame_size(&self) -> usize {
self.encoded_size() + self.packet_size.size()
}
pub(crate) fn encoded_size(&self) -> usize {
if self.packet_version.is_initial() {
Self::INITIAL_SIZE
} else {
Self::V8_SIZE
}
}
pub(crate) fn decode(src: &mut BytesMut) -> Result<Option<Self>, NymCodecError> {
if src.len() < Self::SIZE {
if src.len() < Self::INITIAL_SIZE {
// can't do anything if we don't have enough bytes - but reserve enough for the next call
src.reserve(Self::SIZE);
src.reserve(Self::INITIAL_SIZE);
return Ok(None);
}
@@ -119,10 +154,23 @@ impl Header {
});
}
// we need to be able to decode the full header
if !packet_version.is_initial() && src.len() < Self::V8_SIZE {
src.reserve(1);
return Ok(None);
}
let key_rotation = if packet_version.is_initial() {
SphinxKeyRotation::Unknown
} else {
SphinxKeyRotation::try_from(src[3])?
};
Ok(Some(Header {
packet_version,
packet_size: PacketSize::try_from(src[1])?,
packet_type: PacketType::try_from(src[2])?,
key_rotation,
}))
}
}
@@ -130,10 +178,20 @@ impl Header {
#[cfg(test)]
mod header_encoding {
use super::*;
use nym_sphinx_params::packet_version::INITIAL_PACKET_VERSION_NUMBER;
fn dummy_header() -> Header {
Header {
packet_version: CURRENT_PACKET_VERSION,
packet_size: Default::default(),
key_rotation: Default::default(),
packet_type: Default::default(),
}
}
#[test]
fn header_can_be_decoded_from_a_valid_encoded_instance() {
let header = Header::default();
let header = dummy_header();
let mut bytes = BytesMut::new();
header.encode(&mut bytes);
let decoded = Header::decode(&mut bytes).unwrap().unwrap();
@@ -153,6 +211,7 @@ mod header_encoding {
PacketVersion::new().as_u8(),
unknown_packet_size,
PacketType::default() as u8,
SphinxKeyRotation::EvenRotation as u8,
]
.as_ref(),
);
@@ -167,7 +226,9 @@ mod header_encoding {
let mut bytes = BytesMut::from(
[
PacketVersion::new().as_u8(),
PacketVersion::try_from(INITIAL_PACKET_VERSION_NUMBER)
.unwrap()
.as_u8(),
PacketSize::default() as u8,
unknown_packet_type,
]
@@ -181,12 +242,12 @@ mod header_encoding {
let mut empty_bytes = BytesMut::new();
let decode_attempt_1 = Header::decode(&mut empty_bytes).unwrap();
assert!(decode_attempt_1.is_none());
assert!(empty_bytes.capacity() > Header::SIZE);
assert!(empty_bytes.capacity() > Header::V8_SIZE);
let mut empty_bytes = BytesMut::with_capacity(1);
let decode_attempt_2 = Header::decode(&mut empty_bytes).unwrap();
assert!(decode_attempt_2.is_none());
assert!(empty_bytes.capacity() > Header::SIZE);
assert!(empty_bytes.capacity() > Header::V8_SIZE);
}
#[test]
@@ -202,7 +263,7 @@ mod header_encoding {
let header = Header {
packet_version: PacketVersion::new(),
packet_size,
..Default::default()
..dummy_header()
};
let mut bytes = BytesMut::new();
header.encode(&mut bytes);
@@ -217,6 +278,7 @@ mod header_encoding {
let unchecked_header = Header {
packet_version: future_version,
packet_size: PacketSize::RegularPacket,
key_rotation: SphinxKeyRotation::EvenRotation,
packet_type: PacketType::Mix,
};
let mut bytes = BytesMut::new();
+53 -14
View File
@@ -5,7 +5,7 @@ use crate::packet::FramedNymPacket;
use nym_sphinx_acknowledgements::surb_ack::{SurbAck, SurbAckRecoveryError};
use nym_sphinx_addressing::nodes::{NymNodeRoutingAddress, NymNodeRoutingAddressError};
use nym_sphinx_forwarding::packet::MixPacket;
use nym_sphinx_params::{PacketSize, PacketType};
use nym_sphinx_params::{PacketSize, PacketType, SphinxKeyRotation};
use nym_sphinx_types::header::shared_secret::ExpandedSharedSecret;
use nym_sphinx_types::{
Delay as SphinxDelay, DestinationAddressBytes, NodeAddressBytes, NymPacket, NymPacketError,
@@ -103,10 +103,18 @@ pub enum PacketProcessingError {
#[error("attempted to partially process an outfox packet")]
PartialOutfoxProcessing,
#[error("the key needed for unwrapping this packet has already expired")]
ExpiredKey,
#[error("this packet has already been processed before")]
PacketReplay,
}
pub struct PartialyUnwrappedPacketWithKeyRotation {
pub packet: PartiallyUnwrappedPacket,
pub used_key_rotation: u32,
}
pub struct PartiallyUnwrappedPacket {
received_data: FramedNymPacket,
partial_result: PartialMixProcessingResult,
@@ -119,16 +127,19 @@ impl PartiallyUnwrappedPacket {
pub fn new(
received_data: FramedNymPacket,
sphinx_key: &PrivateKey,
) -> Result<Self, PacketProcessingError> {
) -> Result<Self, (FramedNymPacket, PacketProcessingError)> {
let partial_result = match received_data.packet() {
NymPacket::Sphinx(packet) => {
let expanded_shared_secret =
packet.header.compute_expanded_shared_secret(sphinx_key);
// don't continue if the header is malformed
packet
if let Err(err) = packet
.header
.ensure_header_integrity(&expanded_shared_secret)?;
.ensure_header_integrity(&expanded_shared_secret)
{
return Err((received_data, err.into()));
}
PartialMixProcessingResult::Sphinx {
expanded_shared_secret,
@@ -147,6 +158,7 @@ impl PartiallyUnwrappedPacket {
let packet_size = self.received_data.packet_size();
let packet_type = self.received_data.packet_type();
let key_rotation = self.received_data.header.key_rotation;
let packet = self.received_data.into_inner();
// currently partial unwrapping is only implemented for sphinx packets.
@@ -161,12 +173,22 @@ impl PartiallyUnwrappedPacket {
return Err(PacketProcessingError::PartialOutfoxProcessing);
};
let processed_packet = packet.process_with_expanded_secret(&expanded_shared_secret)?;
wrap_processed_sphinx_packet(processed_packet, packet_size, packet_type)
wrap_processed_sphinx_packet(processed_packet, packet_size, packet_type, key_rotation)
}
pub fn replay_tag(&self) -> Option<&[u8; REPLAY_TAG_SIZE]> {
self.partial_result.replay_tag()
}
pub fn with_key_rotation(
self,
used_key_rotation: u32,
) -> PartialyUnwrappedPacketWithKeyRotation {
PartialyUnwrappedPacketWithKeyRotation {
packet: self,
used_key_rotation,
}
}
}
impl From<(FramedNymPacket, PartialMixProcessingResult)> for PartiallyUnwrappedPacket {
@@ -186,13 +208,14 @@ pub fn process_framed_packet(
) -> Result<MixProcessingResult, PacketProcessingError> {
let packet_size = received.packet_size();
let packet_type = received.packet_type();
let key_rotation = received.key_rotation();
// unwrap the sphinx packet
let processed_packet = perform_framed_unwrapping(received, sphinx_key)?;
// for forward packets, extract next hop and set delay (but do NOT delay here)
// for final packets, extract SURBAck
perform_final_processing(processed_packet, packet_size, packet_type)
perform_final_processing(processed_packet, packet_size, packet_type, key_rotation)
}
fn perform_framed_unwrapping(
@@ -217,6 +240,7 @@ fn wrap_processed_sphinx_packet(
packet: nym_sphinx_types::ProcessedPacket,
packet_size: PacketSize,
packet_type: PacketType,
key_rotation: SphinxKeyRotation,
) -> Result<MixProcessingResult, PacketProcessingError> {
let processing_data = match packet.data {
ProcessedPacketData::ForwardHop {
@@ -228,6 +252,7 @@ fn wrap_processed_sphinx_packet(
next_hop_address,
delay,
packet_type,
key_rotation,
),
// right now there's no use for the surb_id included in the header - probably it should get removed from the
// sphinx all together?
@@ -240,6 +265,7 @@ fn wrap_processed_sphinx_packet(
payload.recover_plaintext()?,
packet_size,
packet_type,
key_rotation,
),
}?;
@@ -253,6 +279,7 @@ fn wrap_processed_outfox_packet(
packet: OutfoxProcessedPacket,
packet_size: PacketSize,
packet_type: PacketType,
key_rotation: SphinxKeyRotation,
) -> Result<MixProcessingResult, PacketProcessingError> {
let next_address = *packet.next_address();
let packet = packet.into_packet();
@@ -262,6 +289,7 @@ fn wrap_processed_outfox_packet(
packet.recover_plaintext()?.to_vec(),
packet_size,
packet_type,
key_rotation,
)?;
Ok(MixProcessingResult {
packet_version: MixPacketVersion::Outfox,
@@ -272,6 +300,7 @@ fn wrap_processed_outfox_packet(
NymNodeRoutingAddress::try_from_bytes(&next_address)?,
NymPacket::Outfox(packet),
PacketType::Outfox,
SphinxKeyRotation::Unknown,
);
Ok(MixProcessingResult {
packet_version: MixPacketVersion::Outfox,
@@ -287,13 +316,14 @@ fn perform_final_processing(
packet: NymProcessedPacket,
packet_size: PacketSize,
packet_type: PacketType,
key_rotation: SphinxKeyRotation,
) -> Result<MixProcessingResult, PacketProcessingError> {
match packet {
NymProcessedPacket::Sphinx(packet) => {
wrap_processed_sphinx_packet(packet, packet_size, packet_type)
wrap_processed_sphinx_packet(packet, packet_size, packet_type, key_rotation)
}
NymProcessedPacket::Outfox(packet) => {
wrap_processed_outfox_packet(packet, packet_size, packet_type)
wrap_processed_outfox_packet(packet, packet_size, packet_type, key_rotation)
}
}
}
@@ -303,8 +333,10 @@ fn process_final_hop(
payload: Vec<u8>,
packet_size: PacketSize,
packet_type: PacketType,
key_rotation: SphinxKeyRotation,
) -> Result<MixProcessingResultData, PacketProcessingError> {
let (forward_ack, message) = split_into_ack_and_message(payload, packet_size, packet_type)?;
let (forward_ack, message) =
split_into_ack_and_message(payload, packet_size, packet_type, key_rotation)?;
Ok(MixProcessingResultData::FinalHop {
final_hop_data: ProcessedFinalHop {
@@ -319,6 +351,7 @@ fn split_into_ack_and_message(
data: Vec<u8>,
packet_size: PacketSize,
packet_type: PacketType,
key_rotation: SphinxKeyRotation,
) -> Result<(Option<MixPacket>, Vec<u8>), PacketProcessingError> {
match packet_size {
PacketSize::AckPacket | PacketSize::OutfoxAckPacket => {
@@ -340,7 +373,7 @@ fn split_into_ack_and_message(
return Err(err.into());
}
};
let forward_ack = MixPacket::new(ack_first_hop, ack_packet, packet_type);
let forward_ack = MixPacket::new(ack_first_hop, ack_packet, packet_type, key_rotation);
Ok((Some(forward_ack), message))
}
}
@@ -368,10 +401,11 @@ fn process_forward_hop(
forward_address: NodeAddressBytes,
delay: SphinxDelay,
packet_type: PacketType,
key_rotation: SphinxKeyRotation,
) -> Result<MixProcessingResultData, PacketProcessingError> {
let next_hop_address = NymNodeRoutingAddress::try_from(forward_address)?;
let packet = MixPacket::new(next_hop_address, packet, packet_type);
let packet = MixPacket::new(next_hop_address, packet, packet_type, key_rotation);
Ok(MixProcessingResultData::ForwardHop {
packet,
delay: Some(delay),
@@ -422,9 +456,13 @@ mod tests {
#[tokio::test]
async fn splitting_into_ack_and_message_returns_whole_data_for_ack() {
let data = vec![42u8; SurbAck::len(Some(PacketType::Mix)) + 10];
let (ack, message) =
split_into_ack_and_message(data.clone(), PacketSize::AckPacket, PacketType::Mix)
.unwrap();
let (ack, message) = split_into_ack_and_message(
data.clone(),
PacketSize::AckPacket,
PacketType::Mix,
SphinxKeyRotation::EvenRotation,
)
.unwrap();
assert!(ack.is_none());
assert_eq!(data, message)
}
@@ -436,6 +474,7 @@ mod tests {
data.clone(),
PacketSize::OutfoxAckPacket,
PacketType::Outfox,
SphinxKeyRotation::EvenRotation,
)
.unwrap();
assert!(ack.is_none());
@@ -0,0 +1,50 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use thiserror::Error;
#[derive(Default, Clone, Copy, Debug, PartialEq, Eq)]
#[repr(u8)]
pub enum SphinxKeyRotation {
// for legacy packets, where there's no explicit information which key has been used
#[default]
Unknown = 0,
OddRotation = 1,
EvenRotation = 2,
}
#[derive(Debug, Error)]
#[error("{received} is not a valid encoding of a sphinx key rotation")]
pub struct InvalidSphinxKeyRotation {
received: u8,
}
// convert from particular rotation id into SphinxKeyRotation variant
impl From<u32> for SphinxKeyRotation {
fn from(value: u32) -> Self {
if value == 0 || value == u32::MAX {
SphinxKeyRotation::Unknown
} else if value % 2 == 0 {
SphinxKeyRotation::EvenRotation
} else {
SphinxKeyRotation::OddRotation
}
}
}
// convert from an encoded SphinxKeyRotation into particular variant
// if value is actually provided, it MUST be one of the two. otherwise is invalid
impl TryFrom<u8> for SphinxKeyRotation {
type Error = InvalidSphinxKeyRotation;
fn try_from(value: u8) -> Result<Self, Self::Error> {
match value {
_ if value == (Self::Unknown as u8) => Ok(Self::Unknown),
_ if value == (Self::OddRotation as u8) => Ok(Self::OddRotation),
_ if value == (Self::EvenRotation as u8) => Ok(Self::EvenRotation),
received => Err(InvalidSphinxKeyRotation { received }),
}
}
}
+3
View File
@@ -9,9 +9,12 @@ use nym_crypto::Aes256GcmSiv;
type Aes128Ctr = ctr::Ctr64BE<Aes128>;
// Re-export for ease of use
pub use key_rotation::SphinxKeyRotation;
pub use packet_sizes::PacketSize;
pub use packet_types::PacketType;
pub use packet_version::PacketVersion;
pub mod key_rotation;
pub mod packet_sizes;
pub mod packet_types;
pub mod packet_version;
+1 -1
View File
@@ -11,7 +11,7 @@ use std::fmt;
use thiserror::Error;
#[derive(Error, Debug)]
#[error("{received} is not a valid packet mode tag")]
#[error("{received} is not a valid packet type tag")]
pub struct InvalidPacketType {
received: u8,
}
@@ -10,13 +10,15 @@ use thiserror::Error;
// - packet_version (starting with v1.1.0)
// - packet_size indicator
// - packet_type
// - sphinx key rotation (starting with v1.12.0/v1.13.0 - either Cheddar or Dolcelatte release)
// it also just so happens that the only valid values for packet_size indicator include values 1-6
// therefore if we receive byte `7` (or larger than that) we'll know we received a versioned packet,
// otherwise we should treat it as legacy
/// Increment it whenever we perform any breaking change in the wire format!
pub const INITIAL_PACKET_VERSION_NUMBER: u8 = 7;
pub const CURRENT_PACKET_VERSION_NUMBER: u8 = INITIAL_PACKET_VERSION_NUMBER;
pub const KEY_ROTATION_VERSION_NUMBER: u8 = 8;
pub const CURRENT_PACKET_VERSION_NUMBER: u8 = KEY_ROTATION_VERSION_NUMBER;
pub const CURRENT_PACKET_VERSION: PacketVersion =
PacketVersion::unchecked(CURRENT_PACKET_VERSION_NUMBER);
@@ -38,6 +40,10 @@ impl PacketVersion {
PacketVersion(CURRENT_PACKET_VERSION_NUMBER)
}
pub fn is_initial(&self) -> bool {
self.0 == INITIAL_PACKET_VERSION_NUMBER
}
const fn unchecked(version: u8) -> PacketVersion {
PacketVersion(version)
}
+15 -8
View File
@@ -13,13 +13,14 @@ use nym_sphinx_anonymous_replies::reply_surb::ReplySurb;
use nym_sphinx_chunking::fragment::{Fragment, FragmentIdentifier};
use nym_sphinx_forwarding::packet::MixPacket;
use nym_sphinx_params::packet_sizes::PacketSize;
use nym_sphinx_params::{PacketType, ReplySurbKeyDigestAlgorithm};
use nym_sphinx_params::{PacketType, ReplySurbKeyDigestAlgorithm, SphinxKeyRotation};
use nym_sphinx_types::{Delay, NymPacket};
use nym_topology::{NymRouteProvider, NymTopologyError};
use rand::{CryptoRng, Rng, SeedableRng};
use rand_chacha::ChaCha8Rng;
use tracing::*;
use nym_sphinx_anonymous_replies::ReplySurbWithKeyRotation;
use nym_sphinx_chunking::monitoring;
use std::time::Duration;
@@ -103,7 +104,7 @@ pub trait FragmentPreparer {
fragment: Fragment,
topology: &NymRouteProvider,
ack_key: &AckKey,
reply_surb: ReplySurb,
reply_surb: ReplySurbWithKeyRotation,
packet_sender: &Recipient,
packet_type: PacketType,
) -> Result<PreparedFragment, NymTopologyError> {
@@ -148,7 +149,7 @@ pub trait FragmentPreparer {
// the unwrap here is fine as the failures can only originate from attempting to use invalid payload lengths
// and we just very carefully constructed a (presumably) valid one
let (sphinx_packet, first_hop_address) = reply_surb
let applied_surb = reply_surb
.apply_surb(packet_payload, packet_size, packet_type)
.unwrap();
@@ -157,7 +158,7 @@ pub trait FragmentPreparer {
// well as the total delay of the ack packet.
// we don't know the delays inside the reply surbs so we use best-effort estimation from our poisson distribution
total_delay: expected_forward_delay + ack_delay,
mix_packet: MixPacket::new(first_hop_address, sphinx_packet, packet_type),
mix_packet: MixPacket::from_applied_surb(applied_surb, packet_type),
fragment_identifier,
})
}
@@ -211,6 +212,9 @@ pub trait FragmentPreparer {
let packet_size = PacketSize::get_type_from_plaintext(expected_plaintext, packet_type)
.expect("the message has been incorrectly fragmented");
let rotation_id = topology.current_key_rotation();
let sphinx_key_rotation = SphinxKeyRotation::from(rotation_id);
let fragment_identifier = fragment.fragment_identifier();
// create an ack
@@ -279,7 +283,7 @@ pub trait FragmentPreparer {
// well as the total delay of the ack packet.
// note that the last hop of the packet is a gateway that does not do any delays
total_delay: delays.iter().take(delays.len() - 1).sum::<Delay>() + ack_delay,
mix_packet: MixPacket::new(first_hop_address, packet, packet_type),
mix_packet: MixPacket::new(first_hop_address, packet, packet_type, sphinx_key_rotation),
fragment_identifier,
})
}
@@ -371,10 +375,12 @@ where
use_legacy_reply_surb_format: bool,
amount: usize,
topology: &NymRouteProvider,
) -> Result<Vec<ReplySurb>, NymTopologyError> {
) -> Result<Vec<ReplySurbWithKeyRotation>, NymTopologyError> {
let mut reply_surbs = Vec::with_capacity(amount);
let disabled_mix_hops = self.mix_hops_disabled();
let key_rotation = SphinxKeyRotation::from(topology.current_key_rotation());
for _ in 0..amount {
let reply_surb = ReplySurb::construct(
&mut self.rng,
@@ -383,7 +389,8 @@ where
use_legacy_reply_surb_format,
topology,
disabled_mix_hops, // TODO: support SURBs with no mix hops after changes to surb format / construction
)?;
)?
.with_key_rotation(key_rotation);
reply_surbs.push(reply_surb)
}
@@ -395,7 +402,7 @@ where
fragment: Fragment,
topology: &NymRouteProvider,
ack_key: &AckKey,
reply_surb: ReplySurb,
reply_surb: ReplySurbWithKeyRotation,
packet_type: PacketType,
) -> Result<PreparedFragment, NymTopologyError> {
let sender = self.sender_address;
+1
View File
@@ -50,6 +50,7 @@ pub enum NymPacketError {
FromSlice(#[from] TryFromSliceError),
}
// TODO: wrap that guy and add extra metadata to indicate key rotation?
#[allow(clippy::large_enum_variant)]
pub enum NymPacket {
#[cfg(feature = "sphinx")]
+2 -1
View File
@@ -9,4 +9,5 @@ repository = { workspace = true }
[dependencies]
pem = { workspace = true }
tracing = { workspace = true }
tracing = { workspace = true }
zeroize = { workspace = true }
+41 -12
View File
@@ -5,12 +5,35 @@ use crate::traits::{PemStorableKey, PemStorableKeyPair};
use pem::Pem;
use std::fs::File;
use std::io::{self, Read, Write};
use std::ops::Deref;
use std::path::{Path, PathBuf};
use tracing::debug;
use zeroize::{Zeroize, Zeroizing};
pub mod traits;
#[derive(Debug, Default)]
struct ZeroizingPem(Pem);
impl Zeroize for ZeroizingPem {
fn zeroize(&mut self) {
self.0.tag.zeroize();
self.0.contents.zeroize();
}
}
impl Drop for ZeroizingPem {
fn drop(&mut self) {
self.zeroize();
}
}
impl Deref for ZeroizingPem {
type Target = Pem;
fn deref(&self) -> &Self::Target {
&self.0
}
}
#[derive(Debug, Clone, Default)]
pub struct KeyPairPath {
pub private_key_path: PathBuf,
pub public_key_path: PathBuf,
@@ -56,7 +79,7 @@ where
if T::pem_type() != key_pem.tag {
return Err(io::Error::other(format!(
"unexpected key pem tag. Got '{}', expected: '{}'",
key_pem.tag,
key_pem.0.tag,
T::pem_type()
)));
}
@@ -77,25 +100,31 @@ where
write_pem_file(path, key.to_bytes(), T::pem_type())
}
fn read_pem_file<P: AsRef<Path>>(filepath: P) -> io::Result<Pem> {
fn read_pem_file<P: AsRef<Path>>(filepath: P) -> io::Result<ZeroizingPem> {
let mut pem_bytes = File::open(filepath)?;
let mut buf = Vec::new();
let mut buf = Zeroizing::new(Vec::new());
pem_bytes.read_to_end(&mut buf)?;
pem::parse(&buf).map_err(io::Error::other)
pem::parse(&buf).map(ZeroizingPem).map_err(io::Error::other)
}
fn write_pem_file<P: AsRef<Path>>(filepath: P, data: Vec<u8>, tag: &str) -> io::Result<()> {
fn write_pem_file<P: AsRef<Path>>(filepath: P, mut data: Vec<u8>, tag: &str) -> io::Result<()> {
// ensure the whole directory structure exists
if let Some(parent_dir) = filepath.as_ref().parent() {
std::fs::create_dir_all(parent_dir)?;
if let Err(err) = std::fs::create_dir_all(parent_dir) {
// in case of a failure, make sure to zeroize the data before returning
// (we can't wrap it in `Zeroize` due to `Pem` requirements)
data.zeroize();
return Err(err);
}
}
let pem = Pem {
tag: tag.to_string(),
contents: data,
};
let key = pem::encode(&pem);
let mut file = File::create(filepath.as_ref())?;
let pem = ZeroizingPem(Pem {
tag: tag.to_string(),
contents: data,
});
let key = Zeroizing::new(pem::encode(&pem));
file.write_all(key.as_bytes())?;
// note: this is only supported on unix (on different systems, like Windows, it will just
+4
View File
@@ -70,6 +70,10 @@ impl ShutdownToken {
}
}
pub fn ephemeral() -> Self {
ShutdownToken::new("ephemeral-token")
}
// Creates a ShutdownToken which will get cancelled whenever the current token gets cancelled.
// Unlike a cloned/forked ShutdownToken, cancelling a child token does not cancel the parent token.
#[must_use]
+56
View File
@@ -3,6 +3,8 @@
use ::serde::{Deserialize, Serialize};
use nym_api_requests::nym_nodes::SkimmedNode;
use nym_crypto::asymmetric::ed25519;
use nym_mixnet_contract_common::EpochId;
use nym_sphinx_addressing::nodes::NodeIdentity;
use nym_sphinx_types::Node as SphinxNode;
use rand::prelude::IteratorRandom;
@@ -91,8 +93,39 @@ mod deprecated_network_address_impls {
pub type MixLayer = u8;
#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
pub struct NymTopologyMetadata {
key_rotation_id: u32,
// we have to keep track of key rotation id anyway, so we might as well also include the epoch id
// to keep track of the data staleness
absolute_epoch_id: EpochId,
}
impl NymTopologyMetadata {
pub fn new(key_rotation_id: u32, absolute_epoch_id: EpochId) -> Self {
NymTopologyMetadata {
key_rotation_id,
absolute_epoch_id,
}
}
}
impl Default for NymTopologyMetadata {
fn default() -> Self {
// that's not ideal, but we don't want to break backwards compatibility : /
NymTopologyMetadata {
key_rotation_id: u32::MAX,
absolute_epoch_id: 0,
}
}
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct NymTopology {
// while this is not ideal, use empty values as default to not break backwards compatibility
#[serde(default)]
metadata: NymTopologyMetadata,
// for the purposes of future VRF, everyone will need the same view of the network, regardless of performance filtering
// so we use the same 'master' rewarded set information for that
//
@@ -128,6 +161,14 @@ impl NymRouteProvider {
}
}
pub fn current_key_rotation(&self) -> u32 {
self.topology.metadata.key_rotation_id
}
pub fn absolute_epoch_id(&self) -> EpochId {
self.topology.metadata.absolute_epoch_id
}
pub fn new_empty(ignore_egress_epoch_roles: bool) -> NymRouteProvider {
let this: Self = NymTopology::default().into();
this.with_ignore_egress_epoch_roles(ignore_egress_epoch_roles)
@@ -201,18 +242,22 @@ impl NymRouteProvider {
}
impl NymTopology {
#[deprecated]
pub fn new_empty(rewarded_set: impl Into<CachedEpochRewardedSet>) -> Self {
NymTopology {
metadata: NymTopologyMetadata::default(),
rewarded_set: rewarded_set.into(),
node_details: Default::default(),
}
}
pub fn new(
metadata: NymTopologyMetadata,
rewarded_set: impl Into<CachedEpochRewardedSet>,
node_details: Vec<RoutingNode>,
) -> Self {
NymTopology {
metadata,
rewarded_set: rewarded_set.into(),
node_details: node_details.into_iter().map(|n| (n.node_id, n)).collect(),
}
@@ -228,6 +273,11 @@ impl NymTopology {
self.add_additional_nodes(nodes.iter())
}
pub fn with_skimmed_nodes(mut self, nodes: &[SkimmedNode]) -> Self {
self.add_skimmed_nodes(nodes);
self
}
pub fn add_routing_nodes<B: Borrow<RoutingNode>>(
&mut self,
nodes: impl IntoIterator<Item = B>,
@@ -278,6 +328,12 @@ impl NymTopology {
self.node_details.contains_key(&node_id)
}
pub fn has_node(&self, identity: ed25519::PublicKey) -> bool {
self.node_details
.values()
.any(|node_details| node_details.identity_key == identity)
}
pub fn insert_node_details(&mut self, node_details: RoutingNode) {
self.node_details.insert(node_details.node_id, node_details);
}
+9 -2
View File
@@ -5,7 +5,7 @@
#![allow(clippy::empty_docs)]
use crate::node::{EntryDetails, RoutingNode, RoutingNodeError, SupportedRoles};
use crate::{CachedEpochRewardedSet, NymTopology};
use crate::{CachedEpochRewardedSet, NymTopology, NymTopologyMetadata};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::net::SocketAddr;
@@ -38,6 +38,8 @@ impl From<SerializableTopologyError> for JsValue {
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
pub struct WasmFriendlyNymTopology {
pub metadata: NymTopologyMetadata,
pub rewarded_set: CachedEpochRewardedSet,
pub node_details: HashMap<u32, WasmFriendlyRoutingNode>,
@@ -53,13 +55,18 @@ impl TryFrom<WasmFriendlyNymTopology> for NymTopology {
.map(|details| details.try_into())
.collect::<Result<_, _>>()?;
Ok(NymTopology::new(value.rewarded_set, node_details))
Ok(NymTopology::new(
value.metadata,
value.rewarded_set,
node_details,
))
}
}
impl From<NymTopology> for WasmFriendlyNymTopology {
fn from(value: NymTopology) -> Self {
WasmFriendlyNymTopology {
metadata: value.metadata,
rewarded_set: value.rewarded_set,
node_details: value
.node_details
+5
View File
@@ -84,6 +84,8 @@ pub enum TypesError {
NotADelegationEvent,
#[error("Unknown network - {0}")]
UnknownNetwork(String),
#[error("the response metadata has changed between pages")]
InconsistentPagedMetadata,
}
impl Serialize for TypesError {
@@ -103,6 +105,9 @@ impl From<ValidatorClientError> for TypesError {
ValidatorClientError::NyxdError(e) => e.into(),
ValidatorClientError::NoAPIUrlAvailable => TypesError::NoNymApiUrlConfigured,
ValidatorClientError::TendermintErrorRpc(err) => err.into(),
ValidatorClientError::InconsistentPagedMetadata => {
TypesError::InconsistentPagedMetadata
}
}
}
}
+21 -8
View File
@@ -19,7 +19,7 @@ use nym_client_core::init::{
use nym_sphinx::addressing::clients::Recipient;
use nym_sphinx::anonymous_replies::requests::AnonymousSenderTag;
use nym_topology::wasm_helpers::WasmFriendlyNymTopology;
use nym_topology::{NymTopology, RoutingNode};
use nym_topology::{NymTopology, NymTopologyMetadata, RoutingNode};
use nym_validator_client::client::IdentityKey;
use nym_validator_client::{NymApiClient, UserAgent};
use rand::thread_rng;
@@ -29,7 +29,7 @@ use wasm_bindgen_futures::future_to_promise;
use wasm_utils::error::PromisableResult;
pub use nym_credential_storage::ephemeral_storage::EphemeralStorage as EphemeralCredentialStorage;
use wasm_utils::console_log;
use wasm_utils::{console_log, console_warn};
// don't get too excited about the name, under the hood it's just a big fat placeholder
// with no disk_persistence
@@ -73,14 +73,27 @@ pub async fn current_network_topology_async(
let api_client = NymApiClient::new(url);
let rewarded_set = api_client.get_current_rewarded_set().await?;
let mixnodes = api_client
.get_all_basic_active_mixing_assigned_nodes()
let mixnodes_res = api_client
.get_all_basic_active_mixing_assigned_nodes_with_metadata()
.await?;
let gateways = api_client.get_all_basic_entry_assigned_nodes().await?;
let metadata = mixnodes_res.metadata;
let mixnodes = mixnodes_res.nodes;
let mut topology = NymTopology::new_empty(rewarded_set);
topology.add_skimmed_nodes(&mixnodes);
topology.add_skimmed_nodes(&gateways);
let gateways_res = api_client.get_all_basic_entry_assigned_nodes_v2().await?;
if gateways_res.metadata != metadata {
console_warn!("inconsistent nodes metadata between mixnodes and gateways calls! {metadata:?} and {:?}", gateways_res.metadata);
return Err(WasmCoreError::UnavailableNetworkTopology);
}
let gateways = gateways_res.nodes;
let topology = NymTopology::new(
NymTopologyMetadata::new(metadata.rotation_id, metadata.absolute_epoch_id),
rewarded_set,
Vec::new(),
)
.with_skimmed_nodes(&mixnodes)
.with_skimmed_nodes(&gateways);
Ok(topology.into())
}
+1 -3
View File
@@ -1193,7 +1193,6 @@ version = "1.5.1"
dependencies = [
"anyhow",
"bs58",
"cosmwasm-derive",
"cosmwasm-schema",
"cosmwasm-std",
"cw-controllers",
@@ -1208,8 +1207,6 @@ dependencies = [
"rand_chacha",
"semver",
"serde",
"thiserror 2.0.12",
"time",
]
[[package]]
@@ -1262,6 +1259,7 @@ version = "0.3.0"
dependencies = [
"pem",
"tracing",
"zeroize",
]
[[package]]
+10
View File
@@ -55,3 +55,13 @@ sylvia = "1.3.3"
schemars = "0.8.16"
thiserror = "2.0.11"
[workspace.lints.clippy]
unwrap_used = "deny"
expect_used = "deny"
todo = "deny"
dbg_macro = "deny"
exit = "deny"
panic = "deny"
unimplemented = "deny"
unreachable = "deny"
@@ -34,5 +34,6 @@ pub fn default_mixnet_init_msg() -> nym_mixnet_contract_common::InstantiateMsg {
version_score_params: Default::default(),
profit_margin: Default::default(),
interval_operating_cost: Default::default(),
key_validity_in_epochs: None,
}
}
+3 -3
View File
@@ -33,7 +33,6 @@ nym-contracts-common = { path = "../../common/cosmwasm-smart-contracts/contracts
cosmwasm-schema = { workspace = true, optional = true }
cosmwasm-std = { workspace = true }
cosmwasm-derive = { workspace = true }
cw-controllers = { workspace = true }
cw2 = { workspace = true }
cw-storage-plus = { workspace = true }
@@ -41,8 +40,6 @@ cw-storage-plus = { workspace = true }
bs58 = { workspace = true }
serde = { workspace = true, default-features = false, features = ["derive"] }
semver = { workspace = true }
thiserror = { workspace = true }
time = { version = "0.3", features = ["macros"] }
[dev-dependencies]
anyhow.workspace = true
@@ -55,3 +52,6 @@ easy-addr = { path = "../../common/cosmwasm-smart-contracts/easy_addr" }
default = []
contract-testing = ["mixnet-contract-common/contract-testing"]
schema-gen = ["mixnet-contract-common/schema", "cosmwasm-schema"]
[lints]
workspace = true
@@ -41,6 +41,15 @@
}
]
},
"key_validity_in_epochs": {
"default": null,
"type": [
"integer",
"null"
],
"format": "uint32",
"minimum": 0.0
},
"profit_margin": {
"default": {
"maximum": "1",
@@ -3440,6 +3449,34 @@
}
},
"additionalProperties": false
},
{
"description": "Gets the current state config of the key rotation (i.e. starting epoch id and validity duration)",
"type": "object",
"required": [
"get_key_rotation_state"
],
"properties": {
"get_key_rotation_state": {
"type": "object",
"additionalProperties": false
}
},
"additionalProperties": false
},
{
"description": "Gets the current key rotation id",
"type": "object",
"required": [
"get_key_rotation_id"
],
"properties": {
"get_key_rotation_id": {
"type": "object",
"additionalProperties": false
}
},
"additionalProperties": false
}
],
"definitions": {
@@ -5116,6 +5153,46 @@
}
}
},
"get_key_rotation_id": {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "KeyRotationIdResponse",
"type": "object",
"required": [
"rotation_id"
],
"properties": {
"rotation_id": {
"type": "integer",
"format": "uint32",
"minimum": 0.0
}
},
"additionalProperties": false
},
"get_key_rotation_state": {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "KeyRotationState",
"type": "object",
"required": [
"initial_epoch_id",
"validity_epochs"
],
"properties": {
"initial_epoch_id": {
"description": "Records the initial epoch_id when the key rotation has been introduced (0 for fresh contracts). It is used for determining when rotation is meant to advance.",
"type": "integer",
"format": "uint32",
"minimum": 0.0
},
"validity_epochs": {
"description": "Defines how long each key rotation is valid for (in terms of epochs)",
"type": "integer",
"format": "uint32",
"minimum": 0.0
}
},
"additionalProperties": false
},
"get_mix_node_bonds": {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "PagedMixnodeBondsResponse",
@@ -37,6 +37,15 @@
}
]
},
"key_validity_in_epochs": {
"default": null,
"type": [
"integer",
"null"
],
"format": "uint32",
"minimum": 0.0
},
"profit_margin": {
"default": {
"maximum": "1",
+28
View File
@@ -1486,6 +1486,34 @@
}
},
"additionalProperties": false
},
{
"description": "Gets the current state config of the key rotation (i.e. starting epoch id and validity duration)",
"type": "object",
"required": [
"get_key_rotation_state"
],
"properties": {
"get_key_rotation_state": {
"type": "object",
"additionalProperties": false
}
},
"additionalProperties": false
},
{
"description": "Gets the current key rotation id",
"type": "object",
"required": [
"get_key_rotation_id"
],
"properties": {
"get_key_rotation_id": {
"type": "object",
"additionalProperties": false
}
},
"additionalProperties": false
}
],
"definitions": {
@@ -0,0 +1,16 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "KeyRotationIdResponse",
"type": "object",
"required": [
"rotation_id"
],
"properties": {
"rotation_id": {
"type": "integer",
"format": "uint32",
"minimum": 0.0
}
},
"additionalProperties": false
}
@@ -0,0 +1,24 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "KeyRotationState",
"type": "object",
"required": [
"initial_epoch_id",
"validity_epochs"
],
"properties": {
"initial_epoch_id": {
"description": "Records the initial epoch_id when the key rotation has been introduced (0 for fresh contracts). It is used for determining when rotation is meant to advance.",
"type": "integer",
"format": "uint32",
"minimum": 0.0
},
"validity_epochs": {
"description": "Defines how long each key rotation is valid for (in terms of epochs)",
"type": "integer",
"format": "uint32",
"minimum": 0.0
}
},
"additionalProperties": false
}
+1
View File
@@ -78,6 +78,7 @@ pub const NYMNODE_ROLES_ASSIGNMENT_NAMESPACE: &str = "roles";
pub const NYMNODE_REWARDED_SET_METADATA_NAMESPACE: &str = "roles_metadata";
pub const NYMNODE_ACTIVE_ROLE_ASSIGNMENT_KEY: &str = "active_roles";
pub const KEY_ROTATION_STATE_KEY: &str = "key_rot_state";
pub const NODE_ID_COUNTER_KEY: &str = "nic";
pub const PENDING_MIXNODE_CHANGES_NAMESPACE: &str = "pmc";
pub const MIXNODES_PK_NAMESPACE: &str = "mnn";
+22 -7
View File
@@ -5,6 +5,7 @@ use crate::constants::INITIAL_PLEDGE_AMOUNT;
use crate::interval::storage as interval_storage;
use crate::mixnet_contract_settings::storage as mixnet_params_storage;
use crate::nodes::storage as nymnodes_storage;
use crate::queued_migrations::introduce_key_rotation_id;
use crate::rewards::storage::RewardingStorage;
use cosmwasm_std::{
entry_point, to_json_binary, Addr, Coin, Deps, DepsMut, Env, MessageInfo, QueryResponse,
@@ -82,6 +83,11 @@ pub fn instantiate(
});
}
let key_rotation_validity = msg.key_validity_in_epochs();
if key_rotation_validity < InstantiateMsg::MIN_KEY_ROTATION_VALIDITY {
return Err(MixnetContractError::TooShortRotationInterval);
}
let rewarding_validator_address = deps.api.addr_validate(&msg.rewarding_validator_address)?;
let vesting_contract_address = deps.api.addr_validate(&msg.vesting_contract_address)?;
let state = default_initial_state(
@@ -109,7 +115,7 @@ pub fn instantiate(
msg.current_nym_node_version,
)?;
RewardingStorage::new().initialise(deps.storage, reward_params)?;
nymnodes_storage::initialise_storage(deps.storage)?;
nymnodes_storage::initialise_storage(deps.storage, key_rotation_validity)?;
cw2::set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?;
set_build_information!(deps.storage)?;
@@ -598,6 +604,14 @@ pub fn query(
QueryMsg::GetSigningNonce { address } => to_json_binary(
&crate::signing::queries::query_current_signing_nonce(deps, address)?,
),
// sphinx key rotation-related
QueryMsg::GetKeyRotationState {} => {
to_json_binary(&crate::nodes::queries::query_key_rotation_state(deps)?)
}
QueryMsg::GetKeyRotationId {} => {
to_json_binary(&crate::nodes::queries::query_key_rotation_id(deps)?)
}
};
Ok(query_res?)
@@ -605,18 +619,18 @@ pub fn query(
#[entry_point]
pub fn migrate(
deps: DepsMut<'_>,
mut deps: DepsMut<'_>,
_env: Env,
msg: MigrateMsg,
) -> Result<Response, MixnetContractError> {
set_build_information!(deps.storage)?;
cw2::ensure_from_older_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?;
// let skip_state_updates = msg.unsafe_skip_state_updates.unwrap_or(false);
//
// if !skip_state_updates {
//
// }
let skip_state_updates = msg.unsafe_skip_state_updates.unwrap_or(false);
if !skip_state_updates {
introduce_key_rotation_id(deps.branch())?;
}
// due to circular dependency on contract addresses (i.e. mixnet contract requiring vesting contract address
// and vesting contract requiring the mixnet contract address), if we ever want to deploy any new fresh
@@ -681,6 +695,7 @@ mod tests {
minimum: "1000".parse().unwrap(),
maximum: "10000".parse().unwrap(),
},
key_validity_in_epochs: None,
};
let sender = message_info(&deps.api.addr_make("sender"), &[]);
+15 -1
View File
@@ -6,6 +6,7 @@ use crate::constants::{
NYM_NODE_DETAILS_DEFAULT_RETRIEVAL_LIMIT, NYM_NODE_DETAILS_MAX_RETRIEVAL_LIMIT,
UNBONDED_NYM_NODES_DEFAULT_RETRIEVAL_LIMIT, UNBONDED_NYM_NODES_MAX_RETRIEVAL_LIMIT,
};
use crate::interval::storage as interval_storage;
use crate::nodes::helpers::{
attach_nym_node_details, get_node_details_by_id, get_node_details_by_identity,
get_node_details_by_owner,
@@ -21,7 +22,9 @@ use mixnet_contract_common::nym_node::{
PagedNymNodeDetailsResponse, PagedUnbondedNymNodesResponse, Role, RolesMetadataResponse,
StakeSaturationResponse, UnbondedNodeResponse,
};
use mixnet_contract_common::{NodeId, NymNodeBond, NymNodeDetails};
use mixnet_contract_common::{
KeyRotationIdResponse, KeyRotationState, NodeId, NymNodeBond, NymNodeDetails,
};
use nym_contracts_common::IdentityKey;
pub(crate) fn query_nymnode_bonds_paged(
@@ -257,3 +260,14 @@ pub fn query_stake_saturation(
uncapped_saturation: Some(node_rewarding.uncapped_bond_saturation(&rewarding_params)),
})
}
pub fn query_key_rotation_state(deps: Deps<'_>) -> StdResult<KeyRotationState> {
storage::KEY_ROTATION_STATE.load(deps.storage)
}
pub fn query_key_rotation_id(deps: Deps<'_>) -> StdResult<KeyRotationIdResponse> {
let interval = interval_storage::current_interval(deps.storage)?;
let rotation_state = storage::KEY_ROTATION_STATE.load(deps.storage)?;
let rotation_id = rotation_state.key_rotation_id(interval.current_epoch_absolute_id());
Ok(KeyRotationIdResponse { rotation_id })
}
+15 -3
View File
@@ -2,11 +2,11 @@
// SPDX-License-Identifier: Apache-2.0
use crate::nodes::storage::rewarded_set::{ACTIVE_ROLES_BUCKET, ROLES, ROLES_METADATA};
use crate::nodes::storage::{nym_nodes, NYMNODE_ID_COUNTER};
use crate::nodes::storage::{nym_nodes, KEY_ROTATION_STATE, NYMNODE_ID_COUNTER};
use cosmwasm_std::{StdResult, Storage};
use mixnet_contract_common::error::MixnetContractError;
use mixnet_contract_common::nym_node::{RewardedSetMetadata, Role};
use mixnet_contract_common::{EpochId, NodeId, NymNodeBond, RoleAssignment};
use mixnet_contract_common::{EpochId, KeyRotationState, NodeId, NymNodeBond, RoleAssignment};
use serde::{Deserialize, Serialize};
#[derive(Copy, Clone, Default, Debug, Serialize, Deserialize, Eq, PartialEq)]
@@ -103,7 +103,10 @@ pub(crate) fn next_nymnode_id_counter(store: &mut dyn Storage) -> StdResult<Node
Ok(id)
}
pub(crate) fn initialise_storage(storage: &mut dyn Storage) -> Result<(), MixnetContractError> {
pub(crate) fn initialise_storage(
storage: &mut dyn Storage,
key_rotation_validity: u32,
) -> Result<(), MixnetContractError> {
let active_bucket = RoleStorageBucket::default();
let inactive_bucket = active_bucket.other();
@@ -124,6 +127,15 @@ pub(crate) fn initialise_storage(storage: &mut dyn Storage) -> Result<(), Mixnet
ROLES_METADATA.save(storage, active_bucket as u8, &Default::default())?;
ROLES_METADATA.save(storage, inactive_bucket as u8, &Default::default())?;
// since we're initialising fresh storage, the current epoch_id is 0
KEY_ROTATION_STATE.save(
storage,
&KeyRotationState {
validity_epochs: key_rotation_validity,
initial_epoch_id: 0,
},
)?;
Ok(())
}
+9 -6
View File
@@ -2,23 +2,26 @@
// SPDX-License-Identifier: Apache-2.0
use crate::constants::{
NODE_ID_COUNTER_KEY, NYMNODE_ACTIVE_ROLE_ASSIGNMENT_KEY, NYMNODE_IDENTITY_IDX_NAMESPACE,
NYMNODE_OWNER_IDX_NAMESPACE, NYMNODE_PK_NAMESPACE, NYMNODE_REWARDED_SET_METADATA_NAMESPACE,
NYMNODE_ROLES_ASSIGNMENT_NAMESPACE, PENDING_NYMNODE_CHANGES_NAMESPACE,
UNBONDED_NYMNODE_IDENTITY_IDX_NAMESPACE, UNBONDED_NYMNODE_OWNER_IDX_NAMESPACE,
UNBONDED_NYMNODE_PK_NAMESPACE,
KEY_ROTATION_STATE_KEY, NODE_ID_COUNTER_KEY, NYMNODE_ACTIVE_ROLE_ASSIGNMENT_KEY,
NYMNODE_IDENTITY_IDX_NAMESPACE, NYMNODE_OWNER_IDX_NAMESPACE, NYMNODE_PK_NAMESPACE,
NYMNODE_REWARDED_SET_METADATA_NAMESPACE, NYMNODE_ROLES_ASSIGNMENT_NAMESPACE,
PENDING_NYMNODE_CHANGES_NAMESPACE, UNBONDED_NYMNODE_IDENTITY_IDX_NAMESPACE,
UNBONDED_NYMNODE_OWNER_IDX_NAMESPACE, UNBONDED_NYMNODE_PK_NAMESPACE,
};
use crate::nodes::storage::helpers::RoleStorageBucket;
use cosmwasm_std::Addr;
use cw_storage_plus::{Index, IndexList, IndexedMap, Item, Map, MultiIndex, UniqueIndex};
use mixnet_contract_common::nym_node::{NymNodeBond, RewardedSetMetadata, Role, UnbondedNymNode};
use mixnet_contract_common::{NodeId, PendingNodeChanges};
use mixnet_contract_common::{KeyRotationState, NodeId, PendingNodeChanges};
use nym_contracts_common::IdentityKey;
pub(crate) mod helpers;
pub(crate) use helpers::*;
/// Item recording the current state of the key rotation setup
pub const KEY_ROTATION_STATE: Item<KeyRotationState> = Item::new(KEY_ROTATION_STATE_KEY);
// IMPORTANT NOTE: we're using the same storage key as we had for MIXNODE_ID_COUNTER,
// so that we could start from the old values
pub const NYMNODE_ID_COUNTER: Item<NodeId> = Item::new(NODE_ID_COUNTER_KEY);
+20 -1
View File
@@ -1,2 +1,21 @@
// Copyright 2022-2024 - Nym Technologies SA <contact@nymtech.net>
// Copyright 2022-2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::interval::storage as interval_storage;
use crate::nodes::storage as nymnodes_storage;
use cosmwasm_std::DepsMut;
use mixnet_contract_common::error::MixnetContractError;
use mixnet_contract_common::KeyRotationState;
pub fn introduce_key_rotation_id(deps: DepsMut) -> Result<(), MixnetContractError> {
let current_epoch_id =
interval_storage::current_interval(deps.storage)?.current_epoch_absolute_id();
nymnodes_storage::KEY_ROTATION_STATE.save(
deps.storage,
&KeyRotationState {
validity_epochs: 24,
initial_epoch_id: current_epoch_id,
},
)?;
Ok(())
}
@@ -313,6 +313,8 @@ pub(crate) fn try_update_rewarding_params(
}
}
#[allow(clippy::panic)]
#[allow(clippy::unreachable)]
#[cfg(test)]
pub mod tests {
use super::*;
@@ -1,6 +1,11 @@
// Copyright 2021-2023 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
// fine in test code
#![allow(clippy::panic)]
#![allow(clippy::unreachable)]
#![allow(clippy::unimplemented)]
#[cfg(test)]
pub mod fixtures;
pub(crate) mod legacy;
@@ -1853,6 +1858,7 @@ pub mod test_helpers {
version_score_params: Default::default(),
profit_margin: Default::default(),
interval_operating_cost: Default::default(),
key_validity_in_epochs: None,
};
let env = mock_env();
let info = sender("creator");
@@ -212,6 +212,7 @@ pub(crate) fn try_migrate_vested_delegation(
)?))
}
#[allow(clippy::panic)]
#[cfg(test)]
mod tests {
use super::*;
@@ -314,7 +314,8 @@ impl<R, S> AuthenticatedHandler<R, S> {
}
Ok(request) => match request {
// currently only a single type exists
BinaryRequest::ForwardSphinx { packet } => {
BinaryRequest::ForwardSphinx { packet }
| BinaryRequest::ForwardSphinxV2 { packet } => {
self.handle_forward_sphinx(packet).await.into_ws_message()
}
_ => RequestHandlingError::UnknownBinaryRequest.into_error_message(),
+2
View File
@@ -0,0 +1,2 @@
#!/bin/sh
sqlite3 -init settings.sql /Users/jedrzej/workspace/nym/target/debug/build/nym-api-84610644ac15a598/out/nym-api-example.sqlite
+1
View File
@@ -23,6 +23,7 @@ thiserror.workspace = true
time = { workspace = true, features = ["serde", "parsing", "formatting"] }
ts-rs = { workspace = true, optional = true }
utoipa.workspace = true
tracing = { workspace = true }
# for serde on secp256k1 signatures
ecdsa = { workspace = true, features = ["serde"] }
+172 -3
View File
@@ -32,6 +32,7 @@ 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;
@@ -39,8 +40,10 @@ 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::KeyRotationState;
pub use nym_node_requests::api::v1::node::models::BinaryBuildInformationOwned;
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
@@ -72,7 +75,9 @@ impl Display for RequestError {
}
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema, ToSchema)]
#[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",
@@ -86,6 +91,7 @@ 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 {
@@ -860,11 +866,17 @@ pub struct HostKeys {
#[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<SphinxKey>,
#[serde(default)]
#[serde(with = "option_bs58_x25519_pubkey")]
#[schemars(with = "Option<String>")]
@@ -872,11 +884,32 @@ pub struct HostKeys {
pub x25519_noise: Option<x25519::PublicKey>,
}
#[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<nym_node_requests::api::v1::node::models::SphinxKey> 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<nym_node_requests::api::v1::node::models::HostKeys> 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_noise: value.x25519_noise,
}
}
@@ -999,7 +1032,39 @@ impl NymNodeDescription {
self.description.host_information.keys.ed25519
}
pub fn to_skimmed_node(&self, role: NodeRole, performance: Performance) -> SkimmedNode {
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())
@@ -1012,7 +1077,7 @@ impl NymNodeDescription {
ed25519_identity_pubkey: keys.ed25519,
ip_addresses: self.description.host_information.ip_address.clone(),
mix_port: self.description.mix_port(),
x25519_sphinx_pubkey: keys.x25519,
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.
@@ -1381,6 +1446,110 @@ impl NodeRefreshBody {
}
}
#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, ToSchema)]
pub struct KeyRotationInfoResponse {
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 KeyRotationInfoResponse {
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) -> u32 {
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<Duration> {
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)]
+72 -14
View File
@@ -8,14 +8,28 @@ use nym_crypto::asymmetric::x25519::serde_helpers::bs58_x25519_pubkey;
use nym_crypto::asymmetric::{ed25519, x25519};
use nym_mixnet_contract_common::nym_node::Role;
use nym_mixnet_contract_common::reward_params::Performance;
use nym_mixnet_contract_common::{Interval, NodeId};
use nym_mixnet_contract_common::{EpochId, Interval, NodeId};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::net::IpAddr;
use time::OffsetDateTime;
use utoipa::ToSchema;
#[derive(Clone, Copy, Debug, Serialize, Deserialize, schemars::JsonSchema, utoipa::ToSchema)]
#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, utoipa::ToSchema)]
pub struct SkimmedNodesWithMetadata {
pub nodes: Vec<SkimmedNode>,
pub metadata: NodesResponseMetadata,
}
impl SkimmedNodesWithMetadata {
pub fn new(nodes: Vec<SkimmedNode>, metadata: NodesResponseMetadata) -> Self {
SkimmedNodesWithMetadata { nodes, metadata }
}
}
#[derive(
Clone, Copy, Debug, Serialize, Deserialize, schemars::JsonSchema, utoipa::ToSchema, PartialEq,
)]
#[serde(rename_all = "kebab-case")]
pub enum TopologyRequestStatus {
NoUpdates,
@@ -43,20 +57,56 @@ impl<T: ToSchema> CachedNodesResponse<T> {
}
}
#[derive(
Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, utoipa::ToSchema, PartialEq,
)]
pub struct NodesResponseMetadata {
pub status: Option<TopologyRequestStatus>,
#[schema(value_type = u32)]
pub absolute_epoch_id: EpochId,
pub rotation_id: u32,
pub refreshed_at: OffsetDateTimeJsonSchemaWrapper,
}
impl NodesResponseMetadata {
pub fn refreshed_at(&self) -> OffsetDateTime {
self.refreshed_at.into()
}
}
#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema)]
pub struct PaginatedCachedNodesResponse<T> {
// can't add any new fields here, even with #[serde(default)] and whatnot,
// because it will break all clients using bincode : (
pub struct PaginatedCachedNodesResponseV1<T> {
pub status: Option<TopologyRequestStatus>,
pub refreshed_at: OffsetDateTimeJsonSchemaWrapper,
pub nodes: PaginatedResponse<T>,
}
impl<T> PaginatedCachedNodesResponse<T> {
#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema)]
pub struct PaginatedCachedNodesResponseV2<T> {
pub metadata: NodesResponseMetadata,
pub nodes: PaginatedResponse<T>,
}
impl<T> From<PaginatedCachedNodesResponseV2<T>> for PaginatedCachedNodesResponseV1<T> {
fn from(res: PaginatedCachedNodesResponseV2<T>) -> Self {
PaginatedCachedNodesResponseV1 {
status: res.metadata.status,
refreshed_at: res.metadata.refreshed_at,
nodes: res.nodes,
}
}
}
impl<T> PaginatedCachedNodesResponseV2<T> {
pub fn new_full(
absolute_epoch_id: EpochId,
rotation_id: u32,
refreshed_at: impl Into<OffsetDateTimeJsonSchemaWrapper>,
nodes: Vec<T>,
) -> Self {
PaginatedCachedNodesResponse {
refreshed_at: refreshed_at.into(),
PaginatedCachedNodesResponseV2 {
nodes: PaginatedResponse {
pagination: Pagination {
total: nodes.len(),
@@ -65,19 +115,22 @@ impl<T> PaginatedCachedNodesResponse<T> {
},
data: nodes,
},
status: None,
metadata: NodesResponseMetadata {
refreshed_at: refreshed_at.into(),
status: None,
absolute_epoch_id,
rotation_id,
},
}
}
pub fn fresh(mut self, interval: Option<Interval>) -> Self {
let iv = interval.map(TopologyRequestStatus::Fresh);
self.status = iv;
pub fn fresh(mut self, interval: Interval) -> Self {
self.metadata.status = Some(TopologyRequestStatus::Fresh(interval));
self
}
pub fn no_updates() -> Self {
PaginatedCachedNodesResponse {
refreshed_at: OffsetDateTime::now_utc().into(),
pub fn no_updates(absolute_epoch_id: EpochId, rotation_id: u32) -> Self {
PaginatedCachedNodesResponseV2 {
nodes: PaginatedResponse {
pagination: Pagination {
total: 0,
@@ -86,7 +139,12 @@ impl<T> PaginatedCachedNodesResponse<T> {
},
data: Vec::new(),
},
status: Some(TopologyRequestStatus::NoUpdates),
metadata: NodesResponseMetadata {
refreshed_at: OffsetDateTime::now_utc().into(),
status: Some(TopologyRequestStatus::NoUpdates),
absolute_epoch_id,
rotation_id,
},
}
}
}
+2
View File
@@ -0,0 +1,2 @@
.mode columns
.headers on
+18 -10
View File
@@ -7,17 +7,19 @@ use crate::ecash::error::{EcashError, Result};
use crate::ecash::keys::KeyPairWithEpoch;
use crate::ecash::state::EcashState;
use crate::network::models::NetworkDetails;
use crate::node_describe_cache::DescribedNodes;
use crate::node_describe_cache::cache::DescribedNodes;
use crate::node_status_api::handlers::unstable;
use crate::node_status_api::NodeStatusCache;
use crate::nym_contract_cache::cache::NymContractCache;
use crate::status::ApiStatusState;
use crate::support::caching::cache::SharedCache;
use crate::support::config;
use crate::support::http::state::{AppState, ChainStatusCache, ForcedRefresh};
use crate::support::http::state::chain_status::ChainStatusCache;
use crate::support::http::state::force_refresh::ForcedRefresh;
use crate::support::http::state::AppState;
use crate::support::nyxd::Client;
use crate::support::storage::NymApiStorage;
use crate::unstable_routes::account::cache::AddressInfoCache;
use crate::unstable_routes::v1::account::cache::AddressInfoCache;
use async_trait::async_trait;
use axum::Router;
use axum_test::http::StatusCode;
@@ -58,8 +60,8 @@ use nym_ecash_contract_common::blacklist::{BlacklistedAccountResponse, Blacklist
use nym_ecash_contract_common::deposit::{Deposit, DepositId, DepositResponse};
use nym_task::TaskClient;
use nym_validator_client::nym_api::routes::{
API_VERSION, ECASH_BLIND_SIGN, ECASH_ISSUED_TICKETBOOKS_CHALLENGE_COMMITMENT,
ECASH_ISSUED_TICKETBOOKS_FOR, ECASH_ROUTES,
ECASH_BLIND_SIGN, ECASH_ISSUED_TICKETBOOKS_CHALLENGE_COMMITMENT, ECASH_ISSUED_TICKETBOOKS_FOR,
ECASH_ROUTES, V1_API_VERSION,
};
use nym_validator_client::nyxd::cosmwasm_client::logs::Log;
use nym_validator_client::nyxd::cosmwasm_client::types::ExecuteResult;
@@ -1421,7 +1423,9 @@ impl TestFixture {
async fn issue_ticketbook(&self, req: BlindSignRequestBody) -> BlindedSignatureResponse {
let response = self
.axum
.post(&format!("/{API_VERSION}/{ECASH_ROUTES}/{ECASH_BLIND_SIGN}"))
.post(&format!(
"/{V1_API_VERSION}/{ECASH_ROUTES}/{ECASH_BLIND_SIGN}"
))
.json(&req)
.await;
@@ -1436,7 +1440,7 @@ impl TestFixture {
let response = self
.axum
.get(&format!(
"/{API_VERSION}/{ECASH_ROUTES}/{ECASH_ISSUED_TICKETBOOKS_FOR}/{expiration_date}"
"/{V1_API_VERSION}/{ECASH_ROUTES}/{ECASH_ISSUED_TICKETBOOKS_FOR}/{expiration_date}"
))
.await;
@@ -1452,7 +1456,7 @@ impl TestFixture {
let dummy_keypair = ed25519::KeyPair::new(&mut OsRng);
self.axum
.post(&format!(
"/{API_VERSION}/{ECASH_ROUTES}/{ECASH_ISSUED_TICKETBOOKS_CHALLENGE_COMMITMENT}"
"/{V1_API_VERSION}/{ECASH_ROUTES}/{ECASH_ISSUED_TICKETBOOKS_CHALLENGE_COMMITMENT}"
))
.json(
&IssuedTicketbooksChallengeCommitmentRequestBody {
@@ -1519,7 +1523,9 @@ mod credential_tests {
let response = test_fixture
.axum
.post(&format!("/{API_VERSION}/{ECASH_ROUTES}/{ECASH_BLIND_SIGN}"))
.post(&format!(
"/{V1_API_VERSION}/{ECASH_ROUTES}/{ECASH_BLIND_SIGN}"
))
.json(&request_body)
.await;
@@ -1703,7 +1709,9 @@ mod credential_tests {
let response = test
.axum
.post(&format!("/{API_VERSION}/{ECASH_ROUTES}/{ECASH_BLIND_SIGN}"))
.post(&format!(
"/{V1_API_VERSION}/{ECASH_ROUTES}/{ECASH_BLIND_SIGN}"
))
.json(&request_body)
.await;
-3
View File
@@ -56,9 +56,6 @@ pub enum RewardingError {
source: rand::distributions::WeightedError,
},
#[error("could not obtain the current interval rewarding parameters")]
RewardingParamsRetrievalFailure,
#[error("{0}")]
GenericError(#[from] anyhow::Error),
}
+5 -59
View File
@@ -12,7 +12,7 @@
// 3. Eventually this whole procedure is going to get expanded to allow for distribution of rewarded set generation
// and hence this might be a good place for it.
use crate::node_describe_cache::DescribedNodes;
use crate::node_describe_cache::cache::DescribedNodes;
use crate::node_status_api::{NodeStatusCache, ONE_DAY};
use crate::nym_contract_cache::cache::NymContractCache;
use crate::support::caching::cache::SharedCache;
@@ -22,7 +22,6 @@ use error::RewardingError;
pub(crate) use helpers::RewardedNodeWithParams;
use nym_mixnet_contract_common::{CurrentIntervalResponse, Interval};
use nym_task::{TaskClient, TaskManager};
use std::collections::HashSet;
use std::time::Duration;
use tokio::time::sleep;
use tracing::{error, info, trace, warn};
@@ -165,58 +164,6 @@ impl EpochAdvancer {
Ok(())
}
// this purposely does not deal with nym-nodes as they don't have a concept of a blacklist.
// instead clients are meant to be filtering out them themselves based on the provided scores.
async fn update_legacy_node_blacklist(
&mut self,
interval: &Interval,
) -> Result<(), RewardingError> {
info!("Updating blacklists");
let mut mix_blacklist_add = HashSet::new();
let mut mix_blacklist_remove = HashSet::new();
let mut gate_blacklist_add = HashSet::new();
let mut gate_blacklist_remove = HashSet::new();
let mixnodes = self
.storage
.get_all_avg_mix_reliability_in_last_24hr(interval.current_epoch_end_unix_timestamp())
.await?;
let gateways = self
.storage
.get_all_avg_gateway_reliability_in_last_24hr(
interval.current_epoch_end_unix_timestamp(),
)
.await?;
// TODO: Make thresholds configurable
for mix in mixnodes {
if mix.value() <= 50.0 {
mix_blacklist_add.insert(mix.mix_id());
} else {
mix_blacklist_remove.insert(mix.mix_id());
}
}
self.nym_contract_cache
.update_mixnodes_blacklist(mix_blacklist_add, mix_blacklist_remove)
.await;
for gateway in gateways {
if gateway.value() <= 50.0 {
gate_blacklist_add.insert(gateway.node_id());
} else {
gate_blacklist_remove.insert(gateway.node_id());
}
}
self.nym_contract_cache
.update_gateways_blacklist(gate_blacklist_add, gate_blacklist_remove)
.await;
Ok(())
}
async fn wait_until_epoch_end(&mut self, shutdown: &mut TaskClient) -> Option<Interval> {
const POLL_INTERVAL: Duration = Duration::from_secs(120);
@@ -267,7 +214,9 @@ impl EpochAdvancer {
pub(crate) async fn run(&mut self, mut shutdown: TaskClient) -> Result<(), RewardingError> {
info!("waiting for initial contract cache values before we can start rewarding");
self.nym_contract_cache.wait_for_initial_values().await;
self.nym_contract_cache
.naive_wait_for_initial_values()
.await;
info!("waiting for initial self-described cache values before we can start rewarding");
self.described_cache.naive_wait_for_initial_values().await;
@@ -278,10 +227,7 @@ impl EpochAdvancer {
None => return Ok(()),
Some(interval) => interval,
};
if let Err(err) = self.update_legacy_node_blacklist(&interval_details).await {
error!("failed to update the node blacklist - {err}");
continue;
}
if let Err(err) = self.perform_epoch_operations(interval_details).await {
error!("failed to perform epoch operations - {err}");
sleep(Duration::from_secs(30)).await;
+7 -7
View File
@@ -70,6 +70,8 @@ impl EpochAdvancer {
Ok(())
}
// SAFETY: `EpochAdvancer` is not started until cache is properly initialised
#[allow(clippy::unwrap_used)]
pub(crate) async fn nodes_to_reward(
&self,
) -> Result<Vec<RewardedNodeWithParams>, RewardingError> {
@@ -82,22 +84,20 @@ impl EpochAdvancer {
self.nym_contract_cache
.rewarded_set_owned()
.await
.into_inner()
.unwrap()
.into()
}
};
// we only need reward parameters for active set work factor and rewarded/active set sizes;
// we do not need exact values of reward pool, staking supply, etc., so it's fine if it's slightly out of sync
let Some(reward_params) = self
// SAFETY: `EpochAdvancer` is not started until cache is properly initialised
let reward_params = self
.nym_contract_cache
.interval_reward_params()
.await
.into_inner()
else {
error!("failed to obtain the current interval rewarding parameters. can't determine rewards without them");
return Err(RewardingError::RewardingParamsRetrievalFailure);
};
.unwrap();
Ok(self
.load_nodes_for_rewarding(&rewarded_set, reward_params)
+178
View File
@@ -0,0 +1,178 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
use crate::nym_contract_cache::cache::NymContractCache;
use crate::support::caching::refresher::{CacheUpdateWatcher, RefreshRequester};
use nym_mixnet_contract_common::{Interval, KeyRotationState};
use nym_task::TaskClient;
use time::OffsetDateTime;
use tracing::{debug, error, info, trace};
#[derive(Debug)]
struct ContractData {
interval: Interval,
key_rotation_state: KeyRotationState,
}
impl ContractData {
fn rotation_id(&self) -> u32 {
self.key_rotation_state
.key_rotation_id(self.interval.current_epoch_absolute_id())
}
fn upcoming_rotation_id(&self) -> u32 {
self.rotation_id() + 1
}
fn current_epoch_progress(&self, now: OffsetDateTime) -> f32 {
let elapsed = (now - self.interval.current_epoch_start()).as_seconds_f32();
elapsed / self.interval.epoch_length().as_secs_f32()
}
fn epochs_until_next_rotation(&self) -> Option<f32> {
let current_epoch_progress = self.current_epoch_progress(OffsetDateTime::now_utc());
if !(0. ..=1.).contains(&current_epoch_progress) {
error!("epoch seems to be stuck (current progress is at {:.1}%) - can't progress key rotation!", current_epoch_progress * 100.);
return None;
}
let next_rotation_epoch = self
.key_rotation_state
.next_rotation_starting_epoch_id(self.interval.current_epoch_absolute_id());
let Some(full_epochs) =
(next_rotation_epoch - self.interval.current_epoch_absolute_id()).checked_sub(1)
else {
error!("CRITICAL FAILURE: invalid epoch calculation");
return None;
};
Some((1. - current_epoch_progress) + full_epochs as f32)
}
}
// 'simple' task responsible for making sure nym-api refreshes its self-described cache
// just before the next key rotation so it would have all the keys available
pub(crate) struct KeyRotationController {
pub(crate) last_described_refreshed_for: Option<u32>,
pub(crate) describe_cache_refresher: RefreshRequester,
pub(crate) contract_cache_watcher: CacheUpdateWatcher,
pub(crate) contract_cache: NymContractCache,
}
impl KeyRotationController {
pub(crate) fn new(
describe_cache_refresher: RefreshRequester,
contract_cache_watcher: CacheUpdateWatcher,
contract_cache: NymContractCache,
) -> KeyRotationController {
KeyRotationController {
last_described_refreshed_for: None,
describe_cache_refresher,
contract_cache_watcher,
contract_cache,
}
}
// SAFETY: this function is only called after cache has already been initialised
#[allow(clippy::unwrap_used)]
async fn get_contract_data(&self) -> ContractData {
let key_rotation_state = self.contract_cache.get_key_rotation_state().await.unwrap();
let interval = self.contract_cache.current_interval().await.unwrap();
ContractData {
interval,
key_rotation_state,
}
}
async fn handle_contract_cache_update(&mut self) {
let updated = self.get_contract_data().await;
info!(
"current rotation: {}",
updated
.key_rotation_state
.key_rotation_id(updated.interval.current_epoch_absolute_id())
);
// if we're only 1/4 epoch away from the next rotation, and we haven't yet performed the refresh,
// update the self-described cache, as all nodes should have already pre-announced their new sphinx keys
if let Some(remaining) = updated.epochs_until_next_rotation() {
debug!("{remaining} epoch(s) remaining until next key rotation");
let expected = Some(updated.upcoming_rotation_id());
if remaining < 0.25 && self.last_described_refreshed_for != expected {
info!("{remaining} epoch(s) remaining until next key rotation - requesting full refresh of self-described cache");
self.describe_cache_refresher.request_cache_refresh();
self.last_described_refreshed_for = expected;
}
}
}
async fn run(&mut self, mut task_client: TaskClient) {
self.contract_cache.naive_wait_for_initial_values().await;
self.handle_contract_cache_update().await;
while !task_client.is_shutdown() {
tokio::select! {
biased;
_ = task_client.recv() => {
trace!("KeyRotationController: Received shutdown");
}
_ = self.contract_cache_watcher.changed() => {
self.handle_contract_cache_update().await
}
}
}
trace!("KeyRotationController: exiting")
}
pub(crate) fn start(mut self, task_client: TaskClient) {
tokio::spawn(async move { self.run(task_client).await });
}
}
#[cfg(test)]
mod tests {
use super::*;
use cosmwasm_std::testing::mock_env;
use cosmwasm_std::Timestamp;
use std::time::Duration;
// Sun Jun 15 2025 15:06:40 GMT+0000
const DUMMY_TIMESTAMP: i64 = 1750000000;
fn dummy_contract_data() -> ContractData {
let mut env = mock_env();
env.block.time = Timestamp::from_seconds(DUMMY_TIMESTAMP as u64);
ContractData {
interval: Interval::init_interval(24, Duration::from_secs(60 * 60), &env),
key_rotation_state: KeyRotationState {
validity_epochs: 0,
initial_epoch_id: 0,
},
}
}
#[test]
fn current_epoch_progress() {
let dummy_data = dummy_contract_data();
let epoch_start = OffsetDateTime::from_unix_timestamp(DUMMY_TIMESTAMP).unwrap();
let quarter_in = OffsetDateTime::from_unix_timestamp(DUMMY_TIMESTAMP + 15 * 60).unwrap();
let half_in = OffsetDateTime::from_unix_timestamp(DUMMY_TIMESTAMP + 30 * 60).unwrap();
let next = OffsetDateTime::from_unix_timestamp(DUMMY_TIMESTAMP + 60 * 60).unwrap();
let one_and_half = OffsetDateTime::from_unix_timestamp(DUMMY_TIMESTAMP + 90 * 60).unwrap();
let past_value = OffsetDateTime::from_unix_timestamp(DUMMY_TIMESTAMP - 30 * 60).unwrap();
assert_eq!(dummy_data.current_epoch_progress(epoch_start), 0.);
assert_eq!(dummy_data.current_epoch_progress(quarter_in), 0.25);
assert_eq!(dummy_data.current_epoch_progress(half_in), 0.5);
assert_eq!(dummy_data.current_epoch_progress(next), 1.);
assert_eq!(dummy_data.current_epoch_progress(one_and_half), 1.5);
assert_eq!(dummy_data.current_epoch_progress(past_value), -0.5);
}
}
+1
View File
@@ -18,6 +18,7 @@ use tracing::{info, trace};
mod circulating_supply_api;
mod ecash;
mod epoch_operations;
mod key_rotation;
pub(crate) mod network;
mod network_monitor;
pub(crate) mod node_describe_cache;
+1 -1
View File
@@ -11,7 +11,7 @@ use crate::network_monitor::monitor::receiver::{
use crate::network_monitor::monitor::sender::PacketSender;
use crate::network_monitor::monitor::summary_producer::SummaryProducer;
use crate::network_monitor::monitor::Monitor;
use crate::node_describe_cache::DescribedNodes;
use crate::node_describe_cache::cache::DescribedNodes;
use crate::node_status_api::NodeStatusCache;
use crate::nym_contract_cache::cache::NymContractCache;
use crate::storage::NymApiStorage;
+50 -18
View File
@@ -3,7 +3,8 @@
use crate::network_monitor::monitor::sender::GatewayPackets;
use crate::network_monitor::test_route::TestRoute;
use crate::node_describe_cache::{DescribedNodes, NodeDescriptionTopologyExt};
use crate::node_describe_cache::cache::DescribedNodes;
use crate::node_describe_cache::NodeDescriptionTopologyExt;
use crate::node_status_api::NodeStatusCache;
use crate::nym_contract_cache::cache::NymContractCache;
use crate::support::caching::cache::SharedCache;
@@ -155,16 +156,9 @@ impl PacketPreparer {
pub(crate) async fn wait_for_validator_cache_initial_values(&self, minimum_full_routes: usize) {
// wait for the caches to get initialised
self.contract_cache.wait_for_initial_values().await;
self.contract_cache.naive_wait_for_initial_values().await;
self.described_cache.naive_wait_for_initial_values().await;
#[allow(clippy::expect_used)]
let described_nodes = self
.described_cache
.get()
.await
.expect("the self-describe cache should have been initialised!");
// now wait for at least `minimum_full_routes` mixnodes per layer and `minimum_full_routes` gateway to be online
info!("Waiting for minimal topology to be online");
let initialisation_backoff = Duration::from_secs(30);
@@ -173,6 +167,13 @@ impl PacketPreparer {
let mixnodes = self.contract_cache.legacy_mixnodes_all_basic().await;
let nym_nodes = self.contract_cache.nym_nodes().await;
#[allow(clippy::expect_used)]
let described_nodes = self
.described_cache
.get()
.await
.expect("the self-describe cache should have been initialised!");
let mut gateways_count = gateways.len();
let mut mixnodes_count = mixnodes.len();
@@ -285,13 +286,16 @@ impl PacketPreparer {
fn to_legacy_layered_mixes<'a, R: Rng>(
&self,
rng: &mut R,
current_rotation_id: u32,
node_statuses: &HashMap<NodeId, NodeAnnotation>,
mixing_nym_nodes: impl Iterator<Item = &'a NymNodeDescription> + 'a,
) -> HashMap<LegacyMixLayer, Vec<(RoutingNode, f64)>> {
let mut layered_mixes = HashMap::new();
for mixing_nym_node in mixing_nym_nodes {
let Some(parsed_node) = self.nym_node_to_routing_node(mixing_nym_node) else {
let Some(parsed_node) =
self.nym_node_to_routing_node(current_rotation_id, mixing_nym_node)
else {
continue;
};
// if the node is not present, default to 0.5
@@ -309,13 +313,16 @@ impl PacketPreparer {
fn to_legacy_gateway_nodes<'a>(
&self,
current_rotation_id: u32,
node_statuses: &HashMap<NodeId, NodeAnnotation>,
gateway_capable_nym_nodes: impl Iterator<Item = &'a NymNodeDescription> + 'a,
) -> Vec<(RoutingNode, f64)> {
let mut gateways = Vec::new();
for gateway_capable_node in gateway_capable_nym_nodes {
let Some(parsed_node) = self.nym_node_to_routing_node(gateway_capable_node) else {
let Some(parsed_node) =
self.nym_node_to_routing_node(current_rotation_id, gateway_capable_node)
else {
continue;
};
// if the node is not present, default to 0.5
@@ -341,11 +348,21 @@ impl PacketPreparer {
// last I checked `gatewaying` wasn't a word : )
let gateway_capable_nym_nodes = descriptions.entry_capable_nym_nodes();
// SAFETY: cache has already been initialised
#[allow(clippy::unwrap_used)]
let current_rotation_id = self.contract_cache.current_key_rotation_id().await.unwrap();
let mut rng = thread_rng();
// separate mixes into layers for easier selection alongside the selection weights
let layered_mixes = self.to_legacy_layered_mixes(&mut rng, &statuses, mixing_nym_nodes);
let gateways = self.to_legacy_gateway_nodes(&statuses, gateway_capable_nym_nodes);
let layered_mixes = self.to_legacy_layered_mixes(
&mut rng,
current_rotation_id,
&statuses,
mixing_nym_nodes,
);
let gateways =
self.to_legacy_gateway_nodes(current_rotation_id, &statuses, gateway_capable_nym_nodes);
// get all nodes from each layer...
let l1 = layered_mixes.get(&LegacyMixLayer::One)?;
@@ -399,7 +416,14 @@ impl PacketPreparer {
let node_3 = rand_l3[i].clone();
let gateway = rand_gateways[i].clone();
routes.push(TestRoute::new(rng.gen(), node_1, node_2, node_3, gateway))
routes.push(TestRoute::new(
rng.gen(),
current_rotation_id,
node_1,
node_2,
node_3,
gateway,
))
}
info!("The following routes will be used for testing: {routes:#?}");
Some(routes)
@@ -482,8 +506,12 @@ impl PacketPreparer {
(parsed_nodes, invalid_nodes)
}
fn nym_node_to_routing_node(&self, description: &NymNodeDescription) -> Option<RoutingNode> {
description.try_to_topology_node().ok()
fn nym_node_to_routing_node(
&self,
current_rotation_id: u32,
description: &NymNodeDescription,
) -> Option<RoutingNode> {
description.try_to_topology_node(current_rotation_id).ok()
}
pub(super) async fn prepare_test_packets(
@@ -495,6 +523,10 @@ impl PacketPreparer {
) -> PreparedPackets {
let (mixnodes, gateways) = self.all_legacy_mixnodes_and_gateways().await;
// SAFETY: cache has already been initialised
#[allow(clippy::unwrap_used)]
let current_rotation_id = self.contract_cache.current_key_rotation_id().await.unwrap();
#[allow(clippy::expect_used)]
let descriptions = self
.described_cache
@@ -521,7 +553,7 @@ impl PacketPreparer {
// try to add nym-nodes into the fold
for mix in mixing_nym_nodes {
if let Some(parsed) = self.nym_node_to_routing_node(mix) {
if let Some(parsed) = self.nym_node_to_routing_node(current_rotation_id, mix) {
mixnodes_under_test.push(TestableNode::new_routing(&parsed, NodeType::Mixnode));
mixnodes_to_test_details.push(parsed);
}
@@ -535,7 +567,7 @@ impl PacketPreparer {
.collect::<Vec<_>>();
for gateway in gateway_capable_nym_nodes {
if let Some(parsed) = self.nym_node_to_routing_node(gateway) {
if let Some(parsed) = self.nym_node_to_routing_node(current_rotation_id, gateway) {
gateways_under_test.push(TestableNode::new_routing(&parsed, NodeType::Gateway));
gateways_to_test_details.push(parsed);
}
@@ -7,7 +7,7 @@ use nym_crypto::asymmetric::ed25519;
use nym_mixnet_contract_common::nym_node::Role;
use nym_mixnet_contract_common::{EpochId, EpochRewardedSet, RewardedSet};
use nym_topology::node::RoutingNode;
use nym_topology::{NymRouteProvider, NymTopology};
use nym_topology::{NymRouteProvider, NymTopology, NymTopologyMetadata};
use std::fmt::{Debug, Formatter};
#[derive(Clone)]
@@ -19,6 +19,7 @@ pub(crate) struct TestRoute {
impl TestRoute {
pub(crate) fn new(
id: u64,
key_rotation_id: u32,
l1_mix: RoutingNode,
l2_mix: RoutingNode,
l3_mix: RoutingNode,
@@ -40,7 +41,11 @@ impl TestRoute {
TestRoute {
id,
nodes: NymTopology::new(fake_rewarded_set, nodes),
nodes: NymTopology::new(
NymTopologyMetadata::new(key_rotation_id, 0),
fake_rewarded_set,
nodes,
),
}
}
+65
View File
@@ -0,0 +1,65 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
use nym_api_requests::models::{DescribedNodeType, NymNodeData, NymNodeDescription};
use nym_mixnet_contract_common::NodeId;
use std::collections::HashMap;
use std::net::IpAddr;
#[derive(Debug, Clone)]
pub struct DescribedNodes {
pub(crate) nodes: HashMap<NodeId, NymNodeDescription>,
pub(crate) addresses_cache: HashMap<IpAddr, NodeId>,
}
impl DescribedNodes {
pub fn force_update(&mut self, node: NymNodeDescription) {
for ip in &node.description.host_information.ip_address {
self.addresses_cache.insert(*ip, node.node_id);
}
self.nodes.insert(node.node_id, node);
}
pub fn get_description(&self, node_id: &NodeId) -> Option<&NymNodeData> {
self.nodes.get(node_id).map(|n| &n.description)
}
pub fn get_node(&self, node_id: &NodeId) -> Option<&NymNodeDescription> {
self.nodes.get(node_id)
}
pub fn all_nodes(&self) -> impl Iterator<Item = &NymNodeDescription> {
self.nodes.values()
}
pub fn all_nym_nodes(&self) -> impl Iterator<Item = &NymNodeDescription> {
self.nodes
.values()
.filter(|n| n.contract_node_type == DescribedNodeType::NymNode)
}
pub fn mixing_nym_nodes(&self) -> impl Iterator<Item = &NymNodeDescription> {
self.nodes
.values()
.filter(|n| n.contract_node_type == DescribedNodeType::NymNode)
.filter(|n| n.description.declared_role.mixnode)
}
pub fn entry_capable_nym_nodes(&self) -> impl Iterator<Item = &NymNodeDescription> {
self.nodes
.values()
.filter(|n| n.contract_node_type == DescribedNodeType::NymNode)
.filter(|n| n.description.declared_role.entry)
}
pub fn exit_capable_nym_nodes(&self) -> impl Iterator<Item = &NymNodeDescription> {
self.nodes
.values()
.filter(|n| n.contract_node_type == DescribedNodeType::NymNode)
.filter(|n| n.description.declared_role.can_operate_exit_gateway())
}
pub fn node_with_address(&self, address: IpAddr) -> Option<NodeId> {
self.addresses_cache.get(&address).copied()
}
}
+19 -398
View File
@@ -1,28 +1,19 @@
// Copyright 2023-2024 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
use crate::node_describe_cache::query_helpers::query_for_described_data;
use crate::nym_contract_cache::cache::NymContractCache;
use crate::support::caching::cache::{SharedCache, UninitialisedCache};
use crate::support::caching::refresher::{CacheItemProvider, CacheRefresher};
use crate::support::config;
use crate::support::config::DEFAULT_NODE_DESCRIBE_BATCH_SIZE;
use async_trait::async_trait;
use futures::{stream, StreamExt};
use nym_api_requests::legacy::{LegacyGatewayBondWithId, LegacyMixNodeDetailsWithLayer};
use nym_api_requests::models::{DescribedNodeType, NymNodeData, NymNodeDescription};
use crate::support::caching::cache::UninitialisedCache;
use nym_api_requests::models::NymNodeDescription;
use nym_config::defaults::DEFAULT_NYM_NODE_HTTP_PORT;
use nym_crypto::asymmetric::ed25519;
use nym_mixnet_contract_common::{NodeId, NymNodeDetails};
use nym_node_requests::api::client::{NymNodeApiClientError, NymNodeApiClientExt};
use nym_topology::node::{RoutingNode, RoutingNodeError};
use std::collections::HashMap;
use std::net::IpAddr;
use std::time::Duration;
use nym_mixnet_contract_common::NodeId;
use nym_node_requests::api::client::NymNodeApiClientError;
use nym_topology::node::RoutingNodeError;
use nym_topology::RoutingNode;
use thiserror::Error;
use tracing::{debug, error, info};
pub(crate) mod cache;
pub(crate) mod provider;
mod query_helpers;
pub(crate) mod refresh;
#[derive(Debug, Error)]
pub enum NodeDescribeCacheError {
@@ -71,390 +62,20 @@ pub enum NodeDescribeCacheError {
// this exists because I've been moving things around quite a lot and now the place that holds the type
// doesn't have relevant dependencies for proper impl
pub(crate) trait NodeDescriptionTopologyExt {
fn try_to_topology_node(&self) -> Result<RoutingNode, RoutingNodeError>;
fn try_to_topology_node(
&self,
current_rotation_id: u32,
) -> Result<RoutingNode, RoutingNodeError>;
}
impl NodeDescriptionTopologyExt for NymNodeDescription {
fn try_to_topology_node(&self) -> Result<RoutingNode, RoutingNodeError> {
fn try_to_topology_node(
&self,
current_rotation_id: u32,
) -> Result<RoutingNode, RoutingNodeError> {
// for the purposes of routing, performance is completely ignored,
// so add dummy value and piggyback on existing conversion
(&self.to_skimmed_node(Default::default(), Default::default())).try_into()
(&self.to_skimmed_node(current_rotation_id, Default::default(), Default::default()))
.try_into()
}
}
#[derive(Debug, Clone)]
pub struct DescribedNodes {
nodes: HashMap<NodeId, NymNodeDescription>,
addresses_cache: HashMap<IpAddr, NodeId>,
}
impl DescribedNodes {
pub fn force_update(&mut self, node: NymNodeDescription) {
for ip in &node.description.host_information.ip_address {
self.addresses_cache.insert(*ip, node.node_id);
}
self.nodes.insert(node.node_id, node);
}
pub fn get_description(&self, node_id: &NodeId) -> Option<&NymNodeData> {
self.nodes.get(node_id).map(|n| &n.description)
}
pub fn get_node(&self, node_id: &NodeId) -> Option<&NymNodeDescription> {
self.nodes.get(node_id)
}
pub fn all_nodes(&self) -> impl Iterator<Item = &NymNodeDescription> {
self.nodes.values()
}
pub fn all_nym_nodes(&self) -> impl Iterator<Item = &NymNodeDescription> {
self.nodes
.values()
.filter(|n| n.contract_node_type == DescribedNodeType::NymNode)
}
pub fn mixing_nym_nodes(&self) -> impl Iterator<Item = &NymNodeDescription> {
self.nodes
.values()
.filter(|n| n.contract_node_type == DescribedNodeType::NymNode)
.filter(|n| n.description.declared_role.mixnode)
}
pub fn entry_capable_nym_nodes(&self) -> impl Iterator<Item = &NymNodeDescription> {
self.nodes
.values()
.filter(|n| n.contract_node_type == DescribedNodeType::NymNode)
.filter(|n| n.description.declared_role.entry)
}
pub fn exit_capable_nym_nodes(&self) -> impl Iterator<Item = &NymNodeDescription> {
self.nodes
.values()
.filter(|n| n.contract_node_type == DescribedNodeType::NymNode)
.filter(|n| n.description.declared_role.can_operate_exit_gateway())
}
pub fn node_with_address(&self, address: IpAddr) -> Option<NodeId> {
self.addresses_cache.get(&address).copied()
}
}
pub struct NodeDescriptionProvider {
contract_cache: NymContractCache,
allow_all_ips: bool,
batch_size: usize,
}
impl NodeDescriptionProvider {
pub(crate) fn new(
contract_cache: NymContractCache,
allow_all_ips: bool,
) -> NodeDescriptionProvider {
NodeDescriptionProvider {
contract_cache,
allow_all_ips,
batch_size: DEFAULT_NODE_DESCRIBE_BATCH_SIZE,
}
}
#[must_use]
pub(crate) fn with_batch_size(mut self, batch_size: usize) -> Self {
self.batch_size = batch_size;
self
}
}
async fn try_get_client(
host: &str,
node_id: NodeId,
custom_port: Option<u16>,
) -> Result<nym_node_requests::api::Client, NodeDescribeCacheError> {
// first try the standard port in case the operator didn't put the node behind the proxy,
// then default https (443)
// finally default http (80)
let mut addresses_to_try = vec![
format!("http://{host}:{DEFAULT_NYM_NODE_HTTP_PORT}"), // 'standard' nym-node
format!("https://{host}"), // node behind https proxy (443)
format!("http://{host}"), // node behind http proxy (80)
];
// note: I removed 'standard' legacy mixnode port because it should now be automatically pulled via
// the 'custom_port' since it should have been present in the contract.
if let Some(port) = custom_port {
addresses_to_try.insert(0, format!("http://{host}:{port}"));
}
for address in addresses_to_try {
// if provided host was malformed, no point in continuing
let client = match nym_node_requests::api::Client::builder(address).and_then(|b| {
b.with_timeout(Duration::from_secs(5))
.no_hickory_dns()
.with_user_agent("nym-api-describe-cache")
.build()
}) {
Ok(client) => client,
Err(err) => {
return Err(NodeDescribeCacheError::MalformedHost {
host: host.to_string(),
node_id,
source: err,
});
}
};
if let Ok(health) = client.get_health().await {
if health.status.is_up() {
return Ok(client);
}
}
}
Err(NodeDescribeCacheError::NoHttpPortsAvailable {
host: host.to_string(),
node_id,
})
}
async fn try_get_description(
data: RefreshData,
allow_all_ips: bool,
) -> Result<NymNodeDescription, NodeDescribeCacheError> {
let client = try_get_client(&data.host, data.node_id, data.port).await?;
let map_query_err = |err| NodeDescribeCacheError::ApiFailure {
node_id: data.node_id,
source: err,
};
let host_info = client.get_host_information().await.map_err(map_query_err)?;
// check if the identity key matches the information provided during bonding
if data.expected_identity != host_info.keys.ed25519_identity {
return Err(NodeDescribeCacheError::MismatchedIdentity {
node_id: data.node_id,
expected: data.expected_identity.to_base58_string(),
got: host_info.keys.ed25519_identity.to_base58_string(),
});
}
if !host_info.verify_host_information() {
return Err(NodeDescribeCacheError::MissignedHostInformation {
node_id: data.node_id,
});
}
if !allow_all_ips && !host_info.data.check_ips() {
return Err(NodeDescribeCacheError::IllegalIpAddress {
node_id: data.node_id,
});
}
let node_info = query_for_described_data(&client, data.node_id).await?;
let description = node_info.into_node_description(host_info.data);
Ok(NymNodeDescription {
node_id: data.node_id,
contract_node_type: data.node_type,
description,
})
}
#[derive(Debug)]
pub(crate) struct RefreshData {
host: String,
node_id: NodeId,
expected_identity: ed25519::PublicKey,
node_type: DescribedNodeType,
port: Option<u16>,
}
impl<'a> TryFrom<&'a LegacyMixNodeDetailsWithLayer> for RefreshData {
type Error = ed25519::Ed25519RecoveryError;
fn try_from(node: &'a LegacyMixNodeDetailsWithLayer) -> Result<Self, Self::Error> {
Ok(RefreshData::new(
&node.bond_information.mix_node.host,
node.bond_information.identity().parse()?,
DescribedNodeType::LegacyMixnode,
node.mix_id(),
Some(node.bond_information.mix_node.http_api_port),
))
}
}
impl<'a> TryFrom<&'a LegacyGatewayBondWithId> for RefreshData {
type Error = ed25519::Ed25519RecoveryError;
fn try_from(node: &'a LegacyGatewayBondWithId) -> Result<Self, Self::Error> {
Ok(RefreshData::new(
&node.bond.gateway.host,
node.bond.identity().parse()?,
DescribedNodeType::LegacyGateway,
node.node_id,
None,
))
}
}
impl<'a> TryFrom<&'a NymNodeDetails> for RefreshData {
type Error = ed25519::Ed25519RecoveryError;
fn try_from(node: &'a NymNodeDetails) -> Result<Self, Self::Error> {
Ok(RefreshData::new(
&node.bond_information.node.host,
node.bond_information.identity().parse()?,
DescribedNodeType::NymNode,
node.node_id(),
node.bond_information.node.custom_http_port,
))
}
}
impl RefreshData {
pub fn new(
host: impl Into<String>,
expected_identity: ed25519::PublicKey,
node_type: DescribedNodeType,
node_id: NodeId,
port: Option<u16>,
) -> Self {
RefreshData {
host: host.into(),
node_id,
expected_identity,
node_type,
port,
}
}
pub(crate) fn node_id(&self) -> NodeId {
self.node_id
}
pub(crate) async fn try_refresh(self, allow_all_ips: bool) -> Option<NymNodeDescription> {
match try_get_description(self, allow_all_ips).await {
Ok(description) => Some(description),
Err(err) => {
debug!("failed to obtain node self-described data: {err}");
None
}
}
}
}
#[async_trait]
impl CacheItemProvider for NodeDescriptionProvider {
type Item = DescribedNodes;
type Error = NodeDescribeCacheError;
async fn wait_until_ready(&self) {
self.contract_cache.wait_for_initial_values().await
}
async fn try_refresh(&self) -> Result<Self::Item, Self::Error> {
// we need to query:
// - legacy mixnodes (because they might already be running nym-nodes, but haven't updated contract info)
// - legacy gateways (because they might already be running nym-nodes, but haven't updated contract info)
// - nym-nodes
let mut nodes_to_query: Vec<RefreshData> = Vec::new();
match self.contract_cache.all_cached_legacy_mixnodes().await {
None => error!("failed to obtain mixnodes information from the cache"),
Some(legacy_mixnodes) => {
for node in &**legacy_mixnodes {
if let Ok(data) = node.try_into() {
nodes_to_query.push(data);
}
}
}
}
match self.contract_cache.all_cached_legacy_gateways().await {
None => error!("failed to obtain gateways information from the cache"),
Some(legacy_gateways) => {
for node in &**legacy_gateways {
if let Ok(data) = node.try_into() {
nodes_to_query.push(data);
}
}
}
}
match self.contract_cache.all_cached_nym_nodes().await {
None => error!("failed to obtain nym-nodes information from the cache"),
Some(nym_nodes) => {
for node in &**nym_nodes {
if let Ok(data) = node.try_into() {
nodes_to_query.push(data);
}
}
}
}
let nodes = stream::iter(
nodes_to_query
.into_iter()
.map(|n| n.try_refresh(self.allow_all_ips)),
)
.buffer_unordered(self.batch_size)
.filter_map(|x| async move { x.map(|d| (d.node_id, d)) })
.collect::<HashMap<_, _>>()
.await;
let mut addresses_cache = HashMap::new();
for node in nodes.values() {
for ip in &node.description.host_information.ip_address {
addresses_cache.insert(*ip, node.node_id);
}
}
info!("refreshed self described data for {} nodes", nodes.len());
info!("with {} unique ip addresses", addresses_cache.len());
Ok(DescribedNodes {
nodes,
addresses_cache,
})
}
}
// currently dead code : (
#[allow(dead_code)]
pub(crate) fn new_refresher(
config: &config::TopologyCacher,
contract_cache: NymContractCache,
) -> CacheRefresher<DescribedNodes, NodeDescribeCacheError> {
CacheRefresher::new(
Box::new(
NodeDescriptionProvider::new(
contract_cache,
config.debug.node_describe_allow_illegal_ips,
)
.with_batch_size(config.debug.node_describe_batch_size),
),
config.debug.node_describe_caching_interval,
)
}
pub(crate) fn new_refresher_with_initial_value(
config: &config::TopologyCacher,
contract_cache: NymContractCache,
initial: SharedCache<DescribedNodes>,
) -> CacheRefresher<DescribedNodes, NodeDescribeCacheError> {
CacheRefresher::new_with_initial_value(
Box::new(
NodeDescriptionProvider::new(
contract_cache,
config.debug.node_describe_allow_illegal_ips,
)
.with_batch_size(config.debug.node_describe_batch_size),
),
config.debug.node_describe_caching_interval,
initial,
)
}
+154
View File
@@ -0,0 +1,154 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
use crate::node_describe_cache::cache::DescribedNodes;
use crate::node_describe_cache::refresh::RefreshData;
use crate::node_describe_cache::NodeDescribeCacheError;
use crate::nym_contract_cache::cache::NymContractCache;
use crate::support::caching::cache::SharedCache;
use crate::support::caching::refresher::{CacheItemProvider, CacheRefresher};
use crate::support::config;
use crate::support::config::DEFAULT_NODE_DESCRIBE_BATCH_SIZE;
use async_trait::async_trait;
use futures::{stream, StreamExt};
use std::collections::HashMap;
use tracing::{error, info};
pub struct NodeDescriptionProvider {
contract_cache: NymContractCache,
allow_all_ips: bool,
batch_size: usize,
}
impl NodeDescriptionProvider {
pub(crate) fn new(
contract_cache: NymContractCache,
allow_all_ips: bool,
) -> NodeDescriptionProvider {
NodeDescriptionProvider {
contract_cache,
allow_all_ips,
batch_size: DEFAULT_NODE_DESCRIBE_BATCH_SIZE,
}
}
#[must_use]
pub(crate) fn with_batch_size(mut self, batch_size: usize) -> Self {
self.batch_size = batch_size;
self
}
}
#[async_trait]
impl CacheItemProvider for NodeDescriptionProvider {
type Item = DescribedNodes;
type Error = NodeDescribeCacheError;
async fn wait_until_ready(&self) {
self.contract_cache.naive_wait_for_initial_values().await
}
async fn try_refresh(&self) -> Result<Self::Item, Self::Error> {
// we need to query:
// - legacy mixnodes (because they might already be running nym-nodes, but haven't updated contract info)
// - legacy gateways (because they might already be running nym-nodes, but haven't updated contract info)
// - nym-nodes
let mut nodes_to_query: Vec<RefreshData> = Vec::new();
match self.contract_cache.all_cached_legacy_mixnodes().await {
None => error!("failed to obtain mixnodes information from the cache"),
Some(legacy_mixnodes) => {
for node in &**legacy_mixnodes {
if let Ok(data) = node.try_into() {
nodes_to_query.push(data);
}
}
}
}
match self.contract_cache.all_cached_legacy_gateways().await {
None => error!("failed to obtain gateways information from the cache"),
Some(legacy_gateways) => {
for node in &**legacy_gateways {
if let Ok(data) = node.try_into() {
nodes_to_query.push(data);
}
}
}
}
match self.contract_cache.all_cached_nym_nodes().await {
None => error!("failed to obtain nym-nodes information from the cache"),
Some(nym_nodes) => {
for node in &**nym_nodes {
if let Ok(data) = node.try_into() {
nodes_to_query.push(data);
}
}
}
}
let nodes = stream::iter(
nodes_to_query
.into_iter()
.map(|n| n.try_refresh(self.allow_all_ips)),
)
.buffer_unordered(self.batch_size)
.filter_map(|x| async move { x.map(|d| (d.node_id, d)) })
.collect::<HashMap<_, _>>()
.await;
let mut addresses_cache = HashMap::new();
for node in nodes.values() {
for ip in &node.description.host_information.ip_address {
addresses_cache.insert(*ip, node.node_id);
}
}
info!("refreshed self described data for {} nodes", nodes.len());
info!("with {} unique ip addresses", addresses_cache.len());
Ok(DescribedNodes {
nodes,
addresses_cache,
})
}
}
// currently dead code : (
#[allow(dead_code)]
pub(crate) fn new_refresher(
config: &config::TopologyCacher,
contract_cache: NymContractCache,
) -> CacheRefresher<DescribedNodes, NodeDescribeCacheError> {
CacheRefresher::new(
Box::new(
NodeDescriptionProvider::new(
contract_cache,
config.debug.node_describe_allow_illegal_ips,
)
.with_batch_size(config.debug.node_describe_batch_size),
),
config.debug.node_describe_caching_interval,
)
}
pub(crate) fn new_provider_with_initial_value(
config: &config::TopologyCacher,
contract_cache: NymContractCache,
initial: SharedCache<DescribedNodes>,
) -> CacheRefresher<DescribedNodes, NodeDescribeCacheError> {
CacheRefresher::new_with_initial_value(
Box::new(
NodeDescriptionProvider::new(
contract_cache,
config.debug.node_describe_allow_illegal_ips,
)
.with_batch_size(config.debug.node_describe_batch_size),
),
config.debug.node_describe_caching_interval,
initial,
)
}
+195
View File
@@ -0,0 +1,195 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
use crate::node_describe_cache::query_helpers::query_for_described_data;
use crate::node_describe_cache::NodeDescribeCacheError;
use nym_api_requests::legacy::{LegacyGatewayBondWithId, LegacyMixNodeDetailsWithLayer};
use nym_api_requests::models::{DescribedNodeType, NymNodeDescription};
use nym_bin_common::bin_info;
use nym_config::defaults::DEFAULT_NYM_NODE_HTTP_PORT;
use nym_crypto::asymmetric::ed25519;
use nym_mixnet_contract_common::{NodeId, NymNodeDetails};
use nym_node_requests::api::client::NymNodeApiClientExt;
use nym_validator_client::UserAgent;
use std::time::Duration;
use tracing::debug;
#[derive(Debug)]
pub(crate) struct RefreshData {
host: String,
node_id: NodeId,
expected_identity: ed25519::PublicKey,
node_type: DescribedNodeType,
port: Option<u16>,
}
impl<'a> TryFrom<&'a LegacyMixNodeDetailsWithLayer> for RefreshData {
type Error = ed25519::Ed25519RecoveryError;
fn try_from(node: &'a LegacyMixNodeDetailsWithLayer) -> Result<Self, Self::Error> {
Ok(RefreshData::new(
&node.bond_information.mix_node.host,
node.bond_information.identity().parse()?,
DescribedNodeType::LegacyMixnode,
node.mix_id(),
Some(node.bond_information.mix_node.http_api_port),
))
}
}
impl<'a> TryFrom<&'a LegacyGatewayBondWithId> for RefreshData {
type Error = ed25519::Ed25519RecoveryError;
fn try_from(node: &'a LegacyGatewayBondWithId) -> Result<Self, Self::Error> {
Ok(RefreshData::new(
&node.bond.gateway.host,
node.bond.identity().parse()?,
DescribedNodeType::LegacyGateway,
node.node_id,
None,
))
}
}
impl<'a> TryFrom<&'a NymNodeDetails> for RefreshData {
type Error = ed25519::Ed25519RecoveryError;
fn try_from(node: &'a NymNodeDetails) -> Result<Self, Self::Error> {
Ok(RefreshData::new(
&node.bond_information.node.host,
node.bond_information.identity().parse()?,
DescribedNodeType::NymNode,
node.node_id(),
node.bond_information.node.custom_http_port,
))
}
}
impl RefreshData {
pub fn new(
host: impl Into<String>,
expected_identity: ed25519::PublicKey,
node_type: DescribedNodeType,
node_id: NodeId,
port: Option<u16>,
) -> Self {
RefreshData {
host: host.into(),
node_id,
expected_identity,
node_type,
port,
}
}
pub(crate) fn node_id(&self) -> NodeId {
self.node_id
}
pub(crate) async fn try_refresh(self, allow_all_ips: bool) -> Option<NymNodeDescription> {
match try_get_description(self, allow_all_ips).await {
Ok(description) => Some(description),
Err(err) => {
debug!("failed to obtain node self-described data: {err}");
None
}
}
}
}
async fn try_get_client(
host: &str,
node_id: NodeId,
custom_port: Option<u16>,
) -> Result<nym_node_requests::api::Client, NodeDescribeCacheError> {
// first try the standard port in case the operator didn't put the node behind the proxy,
// then default https (443)
// finally default http (80)
let mut addresses_to_try = vec![
format!("http://{host}:{DEFAULT_NYM_NODE_HTTP_PORT}"), // 'standard' nym-node
format!("https://{host}"), // node behind https proxy (443)
format!("http://{host}"), // node behind http proxy (80)
];
// note: I removed 'standard' legacy mixnode port because it should now be automatically pulled via
// the 'custom_port' since it should have been present in the contract.
if let Some(port) = custom_port {
addresses_to_try.insert(0, format!("http://{host}:{port}"));
}
for address in addresses_to_try {
// if provided host was malformed, no point in continuing
let client = match nym_node_requests::api::Client::builder(address).and_then(|b| {
b.with_timeout(Duration::from_secs(5))
.no_hickory_dns()
.with_user_agent(UserAgent::from(bin_info!()))
.build()
}) {
Ok(client) => client,
Err(err) => {
return Err(NodeDescribeCacheError::MalformedHost {
host: host.to_string(),
node_id,
source: err,
});
}
};
if let Ok(health) = client.get_health().await {
if health.status.is_up() {
return Ok(client);
}
}
}
Err(NodeDescribeCacheError::NoHttpPortsAvailable {
host: host.to_string(),
node_id,
})
}
async fn try_get_description(
data: RefreshData,
allow_all_ips: bool,
) -> Result<NymNodeDescription, NodeDescribeCacheError> {
let client = try_get_client(&data.host, data.node_id, data.port).await?;
let map_query_err = |err| NodeDescribeCacheError::ApiFailure {
node_id: data.node_id,
source: err,
};
let host_info = client.get_host_information().await.map_err(map_query_err)?;
// check if the identity key matches the information provided during bonding
if data.expected_identity != host_info.keys.ed25519_identity {
return Err(NodeDescribeCacheError::MismatchedIdentity {
node_id: data.node_id,
expected: data.expected_identity.to_base58_string(),
got: host_info.keys.ed25519_identity.to_base58_string(),
});
}
if !host_info.verify_host_information() {
return Err(NodeDescribeCacheError::MissignedHostInformation {
node_id: data.node_id,
});
}
if !allow_all_ips && !host_info.data.check_ips() {
return Err(NodeDescribeCacheError::IllegalIpAddress {
node_id: data.node_id,
});
}
let node_info = query_for_described_data(&client, data.node_id).await?;
let description = node_info.into_node_description(host_info.data);
Ok(NymNodeDescription {
node_id: data.node_id,
contract_node_type: data.node_type,
description,
})
}
+18 -70
View File
@@ -5,14 +5,9 @@
use nym_api_requests::legacy::LegacyMixNodeDetailsWithLayer;
use nym_api_requests::models::InclusionProbability;
use nym_contracts_common::truncate_decimal;
use nym_mixnet_contract_common::{NodeId, RewardingParams};
use nym_mixnet_contract_common::NodeId;
use serde::Serialize;
use std::time::Duration;
use tracing::error;
const MAX_SIMULATION_SAMPLES: u64 = 5000;
const MAX_SIMULATION_TIME_SEC: u64 = 15;
#[deprecated]
#[derive(Clone, Default, Serialize, schemars::JsonSchema)]
@@ -25,11 +20,24 @@ pub(crate) struct InclusionProbabilities {
}
impl InclusionProbabilities {
pub(crate) fn compute(
pub(crate) fn legacy_zero(
mixnodes: &[LegacyMixNodeDetailsWithLayer],
params: RewardingParams,
) -> Option<InclusionProbabilities> {
compute_inclusion_probabilities(mixnodes, params)
) -> InclusionProbabilities {
// (all legacy mixnodes have 0% chance of being selected)
InclusionProbabilities {
inclusion_probabilities: mixnodes
.iter()
.map(|m| InclusionProbability {
mix_id: m.mix_id(),
in_active: 0.0,
in_reserve: 0.0,
})
.collect(),
samples: 0,
elapsed: Default::default(),
delta_max: 0.0,
delta_l2: 0.0,
}
}
pub(crate) fn node(&self, mix_id: NodeId) -> Option<&InclusionProbability> {
@@ -38,63 +46,3 @@ impl InclusionProbabilities {
.find(|x| x.mix_id == mix_id)
}
}
#[deprecated]
fn compute_inclusion_probabilities(
mixnodes: &[LegacyMixNodeDetailsWithLayer],
params: RewardingParams,
) -> Option<InclusionProbabilities> {
let active_set_size = params.active_set_size();
let standby_set_size = params.rewarded_set.standby;
// Unzip list of total bonds into ids and bonds.
// We need to go through this zip/unzip procedure to make sure we have matching identities
// for the input to the simulator, which assumes the identity is the position in the vec
let (ids, mixnode_total_bonds) = unzip_into_mixnode_ids_and_total_bonds(mixnodes);
// Compute inclusion probabilitites and keep track of how long time it took.
let mut rng = rand::thread_rng();
let results = nym_inclusion_probability::simulate_selection_probability_mixnodes(
&mixnode_total_bonds,
active_set_size as usize,
standby_set_size as usize,
MAX_SIMULATION_SAMPLES,
Duration::from_secs(MAX_SIMULATION_TIME_SEC),
&mut rng,
)
.inspect_err(|err| error!("{err}"))
.ok()?;
Some(InclusionProbabilities {
inclusion_probabilities: zip_ids_together_with_results(&ids, &results),
samples: results.samples,
elapsed: results.time,
delta_max: results.delta_max,
delta_l2: results.delta_l2,
})
}
fn unzip_into_mixnode_ids_and_total_bonds(
mixnodes: &[LegacyMixNodeDetailsWithLayer],
) -> (Vec<NodeId>, Vec<u128>) {
mixnodes
.iter()
.map(|m| (m.mix_id(), truncate_decimal(m.total_stake()).u128()))
.unzip()
}
#[deprecated]
fn zip_ids_together_with_results(
ids: &[NodeId],
results: &nym_inclusion_probability::SelectionProbability,
) -> Vec<InclusionProbability> {
ids.iter()
.zip(results.active_set_probability.iter())
.zip(results.reserve_set_probability.iter())
.map(|((&mix_id, a), r)| InclusionProbability {
mix_id,
in_active: *a,
in_reserve: *r,
})
.collect()
}
+7 -3
View File
@@ -2,6 +2,7 @@
// SPDX-License-Identifier: GPL-3.0-only
use self::data::NodeStatusCacheData;
use crate::support::caching::cache::UninitialisedCache;
use crate::support::caching::Cache;
use nym_api_requests::models::{GatewayBondAnnotated, MixNodeBondAnnotated, NodeAnnotation};
use nym_contracts_common::IdentityKey;
@@ -22,9 +23,6 @@ pub mod refresher;
#[derive(Debug, Error)]
enum NodeStatusCacheError {
#[error("failed to simulate selection probabilities for mixnodes, not updating cache")]
SimulationFailed,
#[error("the current interval information is not available at the moment")]
SourceDataMissing,
@@ -32,6 +30,12 @@ enum NodeStatusCacheError {
UnavailableDescribedCache,
}
impl From<UninitialisedCache> for NodeStatusCacheError {
fn from(_: UninitialisedCache) -> Self {
NodeStatusCacheError::SourceDataMissing
}
}
/// A node status cache suitable for caching values computed in one sweep, such as active set
/// inclusion probabilities that are computed for all mixnodes at the same time.
///
+27 -12
View File
@@ -1,7 +1,7 @@
// Copyright 2023 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
use crate::node_describe_cache::DescribedNodes;
use crate::node_describe_cache::cache::DescribedNodes;
use crate::node_status_api::helpers::RewardedSetStatus;
use crate::node_status_api::models::Uptime;
use crate::node_status_api::reward_estimate::{compute_apy_from_reward, compute_reward_estimate};
@@ -9,7 +9,6 @@ use crate::nym_contract_cache::cache::data::ConfigScoreData;
use crate::support::legacy_helpers::legacy_host_to_ips_and_hostname;
use crate::support::storage::NymApiStorage;
use nym_api_requests::legacy::{LegacyGatewayBondWithId, LegacyMixNodeDetailsWithLayer};
use nym_api_requests::models::DescribedNodeType::{LegacyGateway, LegacyMixnode, NymNode};
use nym_api_requests::models::{
ConfigScore, DescribedNodeType, DetailedNodePerformance, GatewayBondAnnotated,
MixNodeBondAnnotated, NodeAnnotation, NodePerformance, NymNodeDescription, RoutingScore,
@@ -18,7 +17,7 @@ use nym_contracts_common::NaiveFloat;
use nym_mixnet_contract_common::{Interval, NodeId, VersionScoreFormulaParams};
use nym_mixnet_contract_common::{NymNodeDetails, RewardingParams};
use nym_topology::CachedEpochRewardedSet;
use std::collections::{HashMap, HashSet};
use std::collections::HashMap;
use tracing::trace;
pub(super) async fn get_mixnode_reliability_from_storage(
@@ -166,7 +165,6 @@ pub(super) async fn annotate_legacy_mixnodes_nodes_with_details(
interval_reward_params: RewardingParams,
current_interval: Interval,
rewarded_set: &CachedEpochRewardedSet,
blacklist: &HashSet<NodeId>,
) -> HashMap<NodeId, MixNodeBondAnnotated> {
let mut annotated = HashMap::new();
for mixnode in mixnodes {
@@ -216,7 +214,8 @@ pub(super) async fn annotate_legacy_mixnodes_nodes_with_details(
annotated.insert(
mixnode.mix_id(),
MixNodeBondAnnotated {
blacklisted: blacklist.contains(&mixnode.mix_id()),
// all legacy nodes are always blacklisted
blacklisted: true,
mixnode_details: mixnode,
stake_saturation,
uncapped_stake_saturation,
@@ -236,7 +235,6 @@ pub(crate) async fn annotate_legacy_gateways_with_details(
storage: &NymApiStorage,
gateway_bonds: Vec<LegacyGatewayBondWithId>,
current_interval: Interval,
blacklist: &HashSet<NodeId>,
) -> HashMap<NodeId, GatewayBondAnnotated> {
let mut annotated = HashMap::new();
for gateway_bond in gateway_bonds {
@@ -263,7 +261,8 @@ pub(crate) async fn annotate_legacy_gateways_with_details(
annotated.insert(
gateway_bond.node_id,
GatewayBondAnnotated {
blacklisted: blacklist.contains(&gateway_bond.node_id),
// all legacy nodes are always blacklisted
blacklisted: true,
gateway_bond,
self_described: None,
performance,
@@ -291,8 +290,13 @@ pub(crate) async fn produce_node_annotations(
for legacy_mix in legacy_mixnodes {
let node_id = legacy_mix.mix_id();
let routing_score =
get_routing_score(storage, node_id, LegacyMixnode, current_interval).await;
let routing_score = get_routing_score(
storage,
node_id,
DescribedNodeType::LegacyMixnode,
current_interval,
)
.await;
let config_score =
calculate_config_score(config_score_data, described_nodes.get_node(&node_id));
@@ -317,8 +321,13 @@ pub(crate) async fn produce_node_annotations(
for legacy_gateway in legacy_gateways {
let node_id = legacy_gateway.node_id;
let routing_score =
get_routing_score(storage, node_id, LegacyGateway, current_interval).await;
let routing_score = get_routing_score(
storage,
node_id,
DescribedNodeType::LegacyGateway,
current_interval,
)
.await;
let config_score =
calculate_config_score(config_score_data, described_nodes.get_node(&node_id));
@@ -343,7 +352,13 @@ pub(crate) async fn produce_node_annotations(
for nym_node in nym_nodes {
let node_id = nym_node.node_id();
let routing_score = get_routing_score(storage, node_id, NymNode, current_interval).await;
let routing_score = get_routing_score(
storage,
node_id,
DescribedNodeType::NymNode,
current_interval,
)
.await;
let config_score =
calculate_config_score(config_score_data, described_nodes.get_node(&node_id));

Some files were not shown because too many files have changed in this diff Show More