Compare commits

..

1 Commits

Author SHA1 Message Date
Bogdan-Ștefan Neacşu 407725f697 Introduce event backchannel (#6119)
* Introduce even backchannel

* Rust fmt

* Rename Event to MixnetClientEvent

* Use unbounded_send for events

* Remove unused file

* Remove mut borrow

* Event hierarchy and mixnet client intermediary

* Export MixTrafficEvent in sdk
2025-10-17 08:47:43 +03:00
80 changed files with 1020 additions and 2581 deletions
@@ -11,7 +11,7 @@ jobs:
strategy:
fail-fast: false
matrix:
platform: [ arc-linux-latest-dind ]
platform: [ arc-ubuntu-22.04 ]
runs-on: ${{ matrix.platform }}
env:
@@ -28,11 +28,18 @@ jobs:
mkdir -p $OUTPUT_DIR
echo $OUTPUT_DIR
- name: Build contracts
run: make optimize-contracts
- name: Install Rust stable
uses: actions-rs/toolchain@v1
with:
toolchain: stable
target: wasm32-unknown-unknown
override: true
- name: Check optimized contracts
run: make docker-check-contracts
- name: Install cosmwasm-check
run: cargo install cosmwasm-check
- name: Build release contracts
run: make publish-contracts
- name: Prepare build output
shell: bash
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
build:
# since it's going to be compiled into wasm, there's absolutely
# no point in running CI on different OS-es
runs-on: arc-linux-latest
runs-on: ubuntu-22.04
env:
CARGO_TERM_COLOR: always
RUSTUP_PERMIT_COPY_RENAME: 1
+1 -5
View File
@@ -21,7 +21,7 @@ jobs:
fail-fast: false
matrix:
include:
- os: arc-linux-latest
- os: arc-ubuntu-22.04
target: x86_64-unknown-linux-gnu
runs-on: ${{ matrix.os }}
@@ -30,13 +30,11 @@ jobs:
release_date: ${{ fromJSON(steps.create-release.outputs.assets)[0].published_at }}
client_hash: ${{ steps.binary-hashes.outputs.client_hash }}
nymvisor_hash: ${{ steps.binary-hashes.outputs.nymvisor_hash }}
nymnode_hash: ${{ steps.binary-hashes.outputs.nymnode_hash }}
socks5_hash: ${{ steps.binary-hashes.outputs.socks5_hash }}
netreq_hash: ${{ steps.binary-hashes.outputs.netreq_hash }}
cli_hash: ${{ steps.binary-hashes.outputs.cli_hash }}
client_version: ${{ steps.binary-versions.outputs.client_version }}
nymvisor_version: ${{ steps.binary-versions.outputs.nymvisor_version }}
nymnode_version: ${{ steps.binary-versions.outputs.nymnode_version }}
socks5_version: ${{ steps.binary-versions.outputs.socks5_version }}
netreq_version: ${{ steps.binary-versions.outputs.netreq_version }}
cli_version: ${{ steps.binary-versions.outputs.cli_version }}
@@ -76,7 +74,6 @@ jobs:
target/release/nym-network-requester
target/release/nym-cli
target/release/nymvisor
target/release/nym-node
retention-days: 30
- id: create-release
@@ -91,7 +88,6 @@ jobs:
target/release/nym-network-requester
target/release/nym-cli
target/release/nymvisor
target/release/nym-node
push-release-data-client:
if: ${{ (startsWith(github.ref, 'refs/tags/nym-binaries-') && github.event_name == 'release') || github.event_name == 'workflow_dispatch' }}
+1 -1
View File
@@ -8,7 +8,7 @@ env:
jobs:
build-container:
runs-on: arc-linux-latest-dind
runs-on: arc-ubuntu-22.04-dind
steps:
- name: Login to Harbor
uses: docker/login-action@v3
@@ -8,7 +8,7 @@ env:
jobs:
build-container:
runs-on: arc-linux-latest-dind
runs-on: arc-ubuntu-22.04-dind
steps:
- name: Login to Harbor
uses: docker/login-action@v3
-78
View File
@@ -4,84 +4,6 @@ Post 1.0.0 release, the changelog format is based on [Keep a Changelog](https://
## [Unreleased]
## [2025.17-isabirra] (2025-09-29)
- Bugfix | Fix the registration handshake ([#6062])
- Convenience for ShutdownTracker ([#6038])
- chore: made http-api-client-macro doctest compile ([#6037])
- feat: refresh mixnet contract on epoch progression ([#6023])
- chore: remove legacy nodes from nym api [and kinda-ish from node status api] ([#6021])
- Feature/credential proxy crate ([#6018])
- Moving clients crate from vpn-client repo to here ([#6015])
- Feature/cancellation migration ([#6014])
- Use default value for the ports until api is deployed ([#6007])
- bugfix: return from MixTrafficController if client request channel has closed ([#6002])
- Revert "Create an axum_test client for more integrated unit testing (… ([#5999])
- chore: upgraded syn to 2.0 and removed nym-execute ([#5998])
- feat: use `ShutdownToken` (`CancellationToken` inside) for nym-api ([#5997])
- bugfix: Recipient deserialisation for deserialisers missing bytes specialisation ([#5991])
- chore: use updated version of simulate endpoint ([#5988])
- chore: purge temp databases on build ([#5984])
- Bump sha.js from 2.4.11 to 2.4.12 ([#5983])
- Feature: Delegation program stake checker and adjuster ([#5980])
- build(deps): bump actions/setup-java from 4 to 5 ([#5975])
- Domain fronting integration ([#5974])
- chore: internal hidden command to force advance nyx epoch ([#5964])
- Create an axum_test client for more integrated unit testing ([#5956])
- feat: shared library for attempting to retrieve update mode attestation ([#5954])
- Bump slab from 0.4.10 to 0.4.11 ([#5952])
- build(deps): bump actions/first-interaction from 1 to 3 ([#5950])
- fix: use WASM compatible time API in client ([#5948])
- feat: credential proxy deposit pool ([#5945])
- build(deps): bump actions/download-artifact from 4 to 5 ([#5939])
- feat: nym signers monitor ([#5933])
- Bump console from 0.15.11 to 0.16.0 ([#5931])
- Bump mock_instant from 0.5.3 to 0.6.0 ([#5930])
- Bump tokio from 1.46.1 to 1.47.1 ([#5929])
- Bump defguard_wireguard_rs from v0.4.7 to v0.7.5 ([#5928])
- Bump indicatif from 0.17.11 to 0.18.0 ([#5924])
- Feature: Nym node autorun CLI ([#5916])
- build(deps): bump mikefarah/yq from 4.45.4 to 4.47.1 ([#5911])
- build(deps): bump pbkdf2 from 3.1.2 to 3.1.3 ([#5869])
[#6062]: https://github.com/nymtech/nym/pull/6062
[#6038]: https://github.com/nymtech/nym/pull/6038
[#6037]: https://github.com/nymtech/nym/pull/6037
[#6023]: https://github.com/nymtech/nym/pull/6023
[#6021]: https://github.com/nymtech/nym/pull/6021
[#6018]: https://github.com/nymtech/nym/pull/6018
[#6015]: https://github.com/nymtech/nym/pull/6015
[#6014]: https://github.com/nymtech/nym/pull/6014
[#6007]: https://github.com/nymtech/nym/pull/6007
[#6002]: https://github.com/nymtech/nym/pull/6002
[#5999]: https://github.com/nymtech/nym/pull/5999
[#5998]: https://github.com/nymtech/nym/pull/5998
[#5997]: https://github.com/nymtech/nym/pull/5997
[#5991]: https://github.com/nymtech/nym/pull/5991
[#5988]: https://github.com/nymtech/nym/pull/5988
[#5984]: https://github.com/nymtech/nym/pull/5984
[#5983]: https://github.com/nymtech/nym/pull/5983
[#5980]: https://github.com/nymtech/nym/pull/5980
[#5975]: https://github.com/nymtech/nym/pull/5975
[#5974]: https://github.com/nymtech/nym/pull/5974
[#5964]: https://github.com/nymtech/nym/pull/5964
[#5956]: https://github.com/nymtech/nym/pull/5956
[#5954]: https://github.com/nymtech/nym/pull/5954
[#5952]: https://github.com/nymtech/nym/pull/5952
[#5950]: https://github.com/nymtech/nym/pull/5950
[#5948]: https://github.com/nymtech/nym/pull/5948
[#5945]: https://github.com/nymtech/nym/pull/5945
[#5939]: https://github.com/nymtech/nym/pull/5939
[#5933]: https://github.com/nymtech/nym/pull/5933
[#5931]: https://github.com/nymtech/nym/pull/5931
[#5930]: https://github.com/nymtech/nym/pull/5930
[#5929]: https://github.com/nymtech/nym/pull/5929
[#5928]: https://github.com/nymtech/nym/pull/5928
[#5924]: https://github.com/nymtech/nym/pull/5924
[#5916]: https://github.com/nymtech/nym/pull/5916
[#5911]: https://github.com/nymtech/nym/pull/5911
[#5869]: https://github.com/nymtech/nym/pull/5869
## [2025.16-halloumi] (2025-09-16)
- Backport metadata endpoint ([#6010])
Generated
+3 -1
View File
@@ -4926,11 +4926,13 @@ dependencies = [
"nym-bandwidth-controller",
"nym-credentials-interface",
"nym-crypto",
"nym-pemstore",
"nym-registration-common",
"nym-sdk",
"nym-service-provider-requests-common",
"nym-validator-client",
"nym-wireguard-types",
"rand 0.8.5",
"semver 1.0.26",
"thiserror 2.0.12",
"tokio",
@@ -5351,7 +5353,6 @@ dependencies = [
"cosmwasm-std",
"cw-multi-test",
"cw-storage-plus",
"nym-contracts-common",
"rand 0.8.5",
"rand_chacha 0.3.1",
"serde",
@@ -6671,6 +6672,7 @@ dependencies = [
name = "nym-registration-client"
version = "0.1.0"
dependencies = [
"futures",
"nym-authenticator-client",
"nym-bandwidth-controller",
"nym-credential-storage",
-8
View File
@@ -154,7 +154,6 @@ CONTRACTS_OUT_DIR = contracts/artifacts
#
COSMWASM_OPTIMIZER_IMAGE ?= cosmwasm/optimizer:0.17.0
COSMWASM_OPTIMIZER_PLATFORM ?= linux/amd64
COSMWASM_CHECK_IMAGE ?= rust:1.88
# Ensure clean build environment and run the optimizer
optimize-contracts:
@@ -180,13 +179,6 @@ optimize-contracts:
# Cleanup temporary artefacts directory
@rm -rf artifacts 2>/dev/null || true
# Check artifacts with cosmwasm-check inside the optimizer image
docker-check-contracts:
@docker run --rm --platform $(COSMWASM_OPTIMIZER_PLATFORM) \
-v $(CURDIR):/code --workdir /code \
--entrypoint /bin/sh \
$(COSMWASM_CHECK_IMAGE) -lc 'apt-get update && apt-get install -y --no-install-recommends llvm-dev libclang-dev pkg-config && export PATH="/usr/local/cargo/bin:/usr/local/rustup/bin:$$PATH" && cargo install cosmwasm-check --locked && WASMER_ENGINE=universal WASMER_COMPILER=singlepass cosmwasm-check contracts/artifacts/*.wasm'
wasm-opt-contracts:
@for WASM in $(WASM_CONTRACT_DIR)/*.wasm; do \
echo "Running wasm-opt on $$WASM"; \
@@ -7,11 +7,12 @@ use super::statistics_control::StatisticsControl;
use crate::client::base_client::storage::helpers::store_client_keys;
use crate::client::base_client::storage::MixnetClientStorage;
use crate::client::cover_traffic_stream::LoopCoverTrafficStream;
use crate::client::event_control::EventControl;
use crate::client::inbound_messages::{InputMessage, InputMessageReceiver, InputMessageSender};
use crate::client::key_manager::persistence::KeyStore;
use crate::client::key_manager::ClientKeys;
use crate::client::mix_traffic::transceiver::{GatewayReceiver, GatewayTransceiver, RemoteGateway};
use crate::client::mix_traffic::{BatchMixMessageSender, MixTrafficController};
use crate::client::mix_traffic::{BatchMixMessageSender, MixTrafficController, MixTrafficEvent};
use crate::client::real_messages_control;
use crate::client::real_messages_control::RealMessagesController;
use crate::client::received_buffer::{
@@ -66,7 +67,6 @@ use std::path::Path;
use std::sync::Arc;
use time::OffsetDateTime;
use tokio::sync::mpsc::Sender;
use tracing::*;
use url::Url;
#[cfg(all(
@@ -79,6 +79,23 @@ pub mod non_wasm_helpers;
pub mod helpers;
pub mod storage;
#[derive(Clone, Copy, Debug)]
pub enum MixnetClientEvent {
Traffic(MixTrafficEvent),
}
pub type EventReceiver = mpsc::UnboundedReceiver<MixnetClientEvent>;
#[derive(Clone)]
pub struct EventSender(pub mpsc::UnboundedSender<MixnetClientEvent>);
impl EventSender {
pub fn send(&self, event: MixnetClientEvent) {
if let Err(err) = self.0.unbounded_send(event) {
tracing::warn!("Failed to send error event. The caller event reader was closed: {err}");
}
}
}
#[derive(Clone)]
pub struct ClientInput {
pub connection_command_sender: ConnectionCommandSender,
@@ -194,6 +211,7 @@ pub struct BaseClientBuilder<C, S: MixnetClientStorage> {
custom_topology_provider: Option<Box<dyn TopologyProvider + Send + Sync>>,
custom_gateway_transceiver: Option<Box<dyn GatewayTransceiver + Send>>,
shutdown: Option<ShutdownTracker>,
event_tx: Option<EventSender>,
user_agent: Option<UserAgent>,
setup_method: GatewaySetup,
@@ -222,6 +240,7 @@ where
custom_topology_provider: None,
custom_gateway_transceiver: None,
shutdown: None,
event_tx: None,
user_agent: None,
setup_method: GatewaySetup::MustLoad { gateway_id: None },
#[cfg(unix)]
@@ -284,6 +303,12 @@ where
self
}
#[must_use]
pub fn with_event_tx(mut self, event_tx: EventSender) -> Self {
self.event_tx = Some(event_tx);
self
}
#[must_use]
pub fn with_user_agent(mut self, user_agent: UserAgent) -> Self {
self.user_agent = Some(user_agent);
@@ -314,6 +339,18 @@ where
details.client_address()
}
fn start_event_control(
parent_event_tx: Option<EventSender>,
children_event_rx: EventReceiver,
shutdown_tracker: &ShutdownTracker,
) {
let event_control = EventControl::new(parent_event_tx, children_event_rx);
shutdown_tracker.try_spawn_named_with_shutdown(
async move { event_control.run().await },
"EventControl",
);
}
// future constantly pumping loop cover traffic at some specified average rate
// the pumped traffic goes to the MixTrafficController
fn start_cover_traffic_stream(
@@ -325,7 +362,7 @@ where
stats_tx: ClientStatsSender,
shutdown_tracker: &ShutdownTracker,
) {
info!("Starting loop cover traffic stream...");
tracing::info!("Starting loop cover traffic stream...");
let mut stream = LoopCoverTrafficStream::new(
ack_key,
@@ -357,7 +394,7 @@ where
stats_tx: ClientStatsSender,
shutdown_tracker: &ShutdownTracker,
) {
info!("Starting real traffic stream...");
tracing::info!("Starting real traffic stream...");
let real_messages_controller = RealMessagesController::new(
controller_config,
@@ -442,7 +479,7 @@ where
metrics_reporter: ClientStatsSender,
shutdown_tracker: &ShutdownTracker,
) {
info!("Starting received messages buffer controller...");
tracing::info!("Starting received messages buffer controller...");
let controller = ReceivedMessagesBufferController::<SphinxMessageReceiver>::new(
local_encryption_keypair,
query_receiver,
@@ -553,7 +590,7 @@ where
details_store
.upgrade_stored_remote_gateway_key(gateway_client.gateway_identity(), &updated_key)
.await.map_err(|err| {
error!("failed to store upgraded gateway key! this connection might be forever broken now: {err}");
tracing::error!("failed to store upgraded gateway key! this connection might be forever broken now: {err}");
ClientCoreError::GatewaysDetailsStoreError { source: Box::new(err) }
})?
}
@@ -650,7 +687,7 @@ where
if topology_config.disable_refreshing {
// if we're not spawning the refresher, don't cause shutdown immediately
info!("The background topology refresher is not going to be started");
tracing::info!("The background topology refresher is not going to be started");
}
let mut topology_refresher = TopologyRefresher::new(
@@ -660,7 +697,7 @@ where
);
// before returning, block entire runtime to refresh the current network view so that any
// components depending on topology would see a non-empty view
info!("Obtaining initial network topology");
tracing::info!("Obtaining initial network topology");
topology_refresher.try_refresh().await;
if let Err(err) = topology_refresher.ensure_topology_is_routable().await {
@@ -686,13 +723,13 @@ where
.wait_for_gateway(local_gateway, waiting_timeout)
.await
{
error!(
tracing::error!(
"the gateway did not come back online within the specified timeout: {err}"
);
return Err(err.into());
}
} else {
error!("the gateway we're supposedly connected to does not exist. We'll not be able to send any packets to ourselves: {err}");
tracing::error!("the gateway we're supposedly connected to does not exist. We'll not be able to send any packets to ourselves: {err}");
return Err(err.into());
}
}
@@ -700,7 +737,7 @@ where
if !topology_config.disable_refreshing {
// don't spawn the refresher if we don't want to be refreshing the topology.
// only use the initial values obtained
info!("Starting topology refresher...");
tracing::info!("Starting topology refresher...");
shutdown_tracker.try_spawn_named_with_shutdown(
async move { topology_refresher.run().await },
"TopologyRefresher",
@@ -717,7 +754,7 @@ where
input_sender: Sender<InputMessage>,
shutdown_tracker: &ShutdownTracker,
) -> ClientStatsSender {
info!("Starting statistics control...");
tracing::info!("Starting statistics control...");
StatisticsControl::create_and_start(
config.debug.stats_reporting,
user_agent
@@ -732,10 +769,14 @@ where
fn start_mix_traffic_controller(
gateway_transceiver: Box<dyn GatewayTransceiver + Send>,
shutdown_tracker: &ShutdownTracker,
event_tx: EventSender,
) -> (BatchMixMessageSender, ClientRequestSender) {
info!("Starting mix traffic controller...");
let (mut mix_traffic_controller, mix_tx, client_tx) =
MixTrafficController::new(gateway_transceiver, shutdown_tracker.clone_shutdown_token());
tracing::info!("Starting mix traffic controller...");
let (mut mix_traffic_controller, mix_tx, client_tx) = MixTrafficController::new(
gateway_transceiver,
shutdown_tracker.clone_shutdown_token(),
event_tx,
);
shutdown_tracker.try_spawn_named(
async move { mix_traffic_controller.run().await },
@@ -799,7 +840,7 @@ where
{
// if client keys do not exist already, create and persist them
if key_store.load_keys().await.is_err() {
info!("could not find valid client keys - a new set will be generated");
tracing::info!("could not find valid client keys - a new set will be generated");
let mut rng = OsRng;
let keys = if let Some(derivation_material) = derivation_material {
ClientKeys::from_master_key(&mut rng, &derivation_material)
@@ -846,7 +887,7 @@ where
<S::CredentialStore as CredentialStorage>::StorageError: Send + Sync + 'static,
<S::GatewaysDetailsStore as GatewaysDetailsStore>::StorageError: Sync + Send,
{
info!("Starting nym client");
tracing::info!("Starting nym client");
// derive (or load) client keys and gateway configuration
let init_res = Self::initialise_keys_and_gateway(
@@ -875,6 +916,9 @@ where
// channels responsible for controlling real messages
let (input_sender, input_receiver) = tokio::sync::mpsc::channel::<InputMessage>(1);
// channels responsible for event management
let (event_sender, event_receiver) = mpsc::unbounded();
// channels responsible for controlling ack messages
let (ack_sender, ack_receiver) = mpsc::unbounded();
let shared_topology_accessor =
@@ -887,6 +931,8 @@ where
None => nym_task::get_sdk_shutdown_tracker()?,
};
Self::start_event_control(self.event_tx, event_receiver, &shutdown_tracker);
// channels responsible for dealing with reply-related fun
let (reply_controller_sender, reply_controller_receiver) =
reply_controller::requests::new_control_channels();
@@ -977,6 +1023,7 @@ where
let (message_sender, client_request_sender) = Self::start_mix_traffic_controller(
gateway_transceiver,
&shutdown_tracker.child_tracker(),
EventSender(event_sender),
);
// Channels that the websocket listener can use to signal downstream to the real traffic
@@ -1026,8 +1073,8 @@ where
);
}
debug!("Core client startup finished!");
debug!("The address of this client is: {self_address}");
tracing::debug!("Core client startup finished!");
tracing::debug!("The address of this client is: {self_address}");
Ok(BaseClient {
address: self_address,
@@ -0,0 +1,40 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use futures::StreamExt;
use crate::client::base_client::{EventReceiver, EventSender, MixnetClientEvent};
/// Launches and manages task events, propagating upwards what is not strictly internal.
pub(crate) struct EventControl {
parent_event_tx: Option<EventSender>,
children_event_rx: EventReceiver,
}
impl EventControl {
pub(crate) fn new(
parent_event_tx: Option<EventSender>,
children_event_rx: EventReceiver,
) -> Self {
EventControl {
parent_event_tx,
children_event_rx,
}
}
fn is_internal(event: MixnetClientEvent) -> bool {
match event {
MixnetClientEvent::Traffic(_) => false,
}
}
pub(crate) async fn run(mut self) {
while let Some(event) = self.children_event_rx.next().await {
if let Some(parent_event_tx) = &self.parent_event_tx {
if !Self::is_internal(event) {
parent_event_tx.send(event);
}
}
}
}
}
@@ -1,7 +1,10 @@
// Copyright 2021 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::client::mix_traffic::transceiver::GatewayTransceiver;
use crate::client::{
base_client::{EventSender, MixnetClientEvent},
mix_traffic::transceiver::GatewayTransceiver,
};
use nym_gateway_requests::ClientRequest;
use nym_sphinx::forwarding::packet::MixPacket;
use nym_task::ShutdownToken;
@@ -22,6 +25,11 @@ const MAX_FAILURE_COUNT: usize = 100;
// that's also disgusting.
pub struct Empty;
#[derive(Clone, Copy, Debug)]
pub enum MixTrafficEvent {
FailedSendingSphinx,
}
pub struct MixTrafficController {
gateway_transceiver: Box<dyn GatewayTransceiver + Send>,
@@ -33,12 +41,14 @@ pub struct MixTrafficController {
consecutive_gateway_failure_count: usize,
shutdown_token: ShutdownToken,
event_tx: EventSender,
}
impl MixTrafficController {
pub fn new<T>(
gateway_transceiver: T,
shutdown_token: ShutdownToken,
event_tx: EventSender,
) -> (
MixTrafficController,
BatchMixMessageSender,
@@ -59,6 +69,7 @@ impl MixTrafficController {
client_rx: client_receiver,
consecutive_gateway_failure_count: 0,
shutdown_token,
event_tx,
},
message_sender,
client_sender,
@@ -68,6 +79,7 @@ impl MixTrafficController {
pub fn new_dynamic(
gateway_transceiver: Box<dyn GatewayTransceiver + Send>,
shutdown_token: ShutdownToken,
event_tx: EventSender,
) -> (
MixTrafficController,
BatchMixMessageSender,
@@ -83,6 +95,7 @@ impl MixTrafficController {
client_rx: client_receiver,
consecutive_gateway_failure_count: 0,
shutdown_token,
event_tx,
},
message_sender,
client_sender,
@@ -155,6 +168,7 @@ impl MixTrafficController {
error!("Failed to send sphinx packet to the gateway {MAX_FAILURE_COUNT} times in a row - assuming the gateway is dead");
// Do we need to handle the embedded mixnet client case
// separately?
self.event_tx.send(MixnetClientEvent::Traffic(MixTrafficEvent::FailedSendingSphinx));
break;
}
}
+1
View File
@@ -3,6 +3,7 @@
pub mod base_client;
pub mod cover_traffic_stream;
pub(crate) mod event_control;
pub(crate) mod helpers;
pub mod inbound_messages;
pub mod key_manager;
@@ -136,27 +136,6 @@ pub trait DkgSigningClient {
self.execute_dkg_contract(fee, req, "trigger DKG resharing".to_string(), vec![])
.await
}
async fn transfer_ownership(
&self,
transfer_to: String,
fee: Option<Fee>,
) -> Result<ExecuteResult, NyxdError> {
let req = DkgExecuteMsg::TransferOwnership { transfer_to };
self.execute_dkg_contract(fee, req, "".to_string(), vec![])
.await
}
async fn update_announce_address(
&self,
new_address: String,
fee: Option<Fee>,
) -> Result<ExecuteResult, NyxdError> {
let req = DkgExecuteMsg::UpdateAnnounceAddress { new_address };
self.execute_dkg_contract(fee, req, "".to_string(), vec![])
.await
}
}
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
@@ -189,7 +168,6 @@ where
mod tests {
use super::*;
use crate::nyxd::contract_traits::tests::IgnoreValue;
use nym_coconut_dkg_common::msg::ExecuteMsg;
// it's enough that this compiles and clippy is happy about it
#[allow(dead_code)]
@@ -232,12 +210,6 @@ mod tests {
DkgExecuteMsg::AdvanceEpochState {} => client.advance_dkg_epoch_state(None).ignore(),
DkgExecuteMsg::TriggerReset {} => client.trigger_dkg_reset(None).ignore(),
DkgExecuteMsg::TriggerResharing {} => client.trigger_dkg_resharing(None).ignore(),
ExecuteMsg::TransferOwnership { transfer_to } => {
client.transfer_ownership(transfer_to, None).ignore()
}
ExecuteMsg::UpdateAnnounceAddress { new_address } => {
client.update_announce_address(new_address, None).ignore()
}
};
}
}
@@ -5,16 +5,6 @@ use crate::types::{EncodedBTEPublicKeyWithProof, NodeIndex};
use cosmwasm_schema::cw_serde;
use cosmwasm_std::Addr;
pub type BlockHeight = u64;
pub type TransactionIndex = u32;
#[cw_serde]
pub struct OwnershipTransfer {
pub node_index: NodeIndex,
pub from: Addr,
pub to: Addr,
}
#[cw_serde]
pub struct DealerDetails {
pub address: Addr,
@@ -73,17 +73,6 @@ pub enum ExecuteMsg {
TriggerReset {},
TriggerResharing {},
/// Transfers ownership of the epoch dealer to another address.
/// This assumes off-chain hand-over of keys
TransferOwnership {
transfer_to: String,
},
/// Update announce address of this signer
UpdateAnnounceAddress {
new_address: String,
},
}
#[cw_serde]
@@ -20,7 +20,5 @@ rand_chacha = { workspace = true }
rand = { workspace = true }
cw-multi-test = { workspace = true }
nym-contracts-common = { path = "../contracts-common" }
[lints]
workspace = true
@@ -3,98 +3,18 @@
use cosmwasm_std::testing::{message_info, MockApi, MockQuerier, MockStorage};
use cosmwasm_std::{
coins, Addr, BankMsg, CosmosMsg, Decimal, Empty, Env, MemoryStorage, MessageInfo, Order,
OwnedDeps, Response, StdResult, Storage,
coins, Addr, BankMsg, CosmosMsg, Empty, Env, MemoryStorage, MessageInfo, Order, OwnedDeps,
Response, StdResult, Storage,
};
use cw_storage_plus::{KeyDeserialize, Map, Prefix, PrimaryKey};
use nym_contracts_common::events::may_find_attribute;
use rand::{RngCore, SeedableRng};
use rand_chacha::ChaCha20Rng;
use serde::de::DeserializeOwned;
use serde::Serialize;
use std::fmt::Debug;
use std::str::FromStr;
pub const TEST_DENOM: &str = "unym";
pub const TEST_PREFIX: &str = "n";
pub trait FindAttribute {
fn attribute<E, S>(&self, event_type: E, attribute: &str) -> String
where
E: Into<Option<S>>,
S: Into<String>;
fn any_attribute(&self, attribute: &str) -> String {
self.attribute::<_, String>(None, attribute)
}
fn any_parsed_attribute<T>(&self, attribute: &str) -> T
where
T: FromStr,
<T as FromStr>::Err: Debug,
{
self.parsed_attribute::<_, String, T>(None, attribute)
}
fn parsed_attribute<E, S, T>(&self, event_type: E, attribute: &str) -> T
where
E: Into<Option<S>>,
S: Into<String>,
T: FromStr,
<T as FromStr>::Err: Debug;
fn decimal<E, S>(&self, event_type: E, attribute: &str) -> Decimal
where
E: Into<Option<S>>,
S: Into<String>,
{
self.parsed_attribute(event_type, attribute)
}
}
#[track_caller]
pub fn find_attribute<S: Into<String>>(
event_type: Option<S>,
attribute: &str,
response: &Response,
) -> String {
let event_type = event_type.map(Into::into);
for event in &response.events {
if let Some(typ) = &event_type {
if &event.ty != typ {
continue;
}
}
if let Some(attr) = may_find_attribute(event, attribute) {
return attr;
}
}
// this is only used in tests so panic here is fine
panic!("did not find the attribute")
}
impl FindAttribute for Response {
fn attribute<E, S>(&self, event_type: E, attribute: &str) -> String
where
E: Into<Option<S>>,
S: Into<String>,
{
find_attribute(event_type.into(), attribute, self)
}
fn parsed_attribute<E, S, T>(&self, event_type: E, attribute: &str) -> T
where
E: Into<Option<S>>,
S: Into<String>,
T: FromStr,
<T as FromStr>::Err: Debug,
{
find_attribute(event_type.into(), attribute, self)
.parse()
.unwrap()
}
}
pub fn mock_api() -> MockApi {
MockApi::default().with_prefix(TEST_PREFIX)
}
@@ -4,8 +4,8 @@
use crate::{ContractTester, TestableNymContract};
use cosmwasm_std::testing::{message_info, mock_env};
use cosmwasm_std::{
from_json, Addr, BlockInfo, Coin, ContractInfo, Deps, DepsMut, Env, MessageInfo, Response,
StdResult, Storage, Timestamp,
from_json, Addr, Coin, ContractInfo, Deps, DepsMut, Env, MessageInfo, Response, StdResult,
Storage, Timestamp,
};
use cw_multi_test::{next_block, AppResponse, Executor};
use serde::de::DeserializeOwned;
@@ -62,8 +62,6 @@ pub trait ContractOpts {
coins: &[Coin],
message: Self::ExecuteMsg,
) -> Result<Response, Self::ContractError>;
fn unchecked_contract_address<D: TestableNymContract>(&self) -> Addr;
}
impl<C> ContractOpts for ContractTester<C>
@@ -132,47 +130,14 @@ where
C::execute()(self.deps_mut(), env, info, message)
}
fn unchecked_contract_address<D: TestableNymContract>(&self) -> Addr {
self.unchecked_contract_address::<D>()
}
}
pub trait ChainOpts: ContractOpts {
fn set_contract_balance(&mut self, balance: Coin);
fn update_block<F: Fn(&mut BlockInfo)>(&mut self, action: F);
fn set_to_epoch(&mut self) {
self.set_block_time(Timestamp::from_seconds(0))
}
fn next_block(&mut self);
fn set_to_genesis(&mut self) {
self.update_block(|block| {
block.height = 1;
})
}
fn next_block(&mut self) {
self.update_block(next_block)
}
fn advance_day_of_blocks(&mut self) {
self.update_block(|block| {
block.time = block.time.plus_seconds(24 * 60 * 60);
block.height += 17280;
})
}
fn advance_time_by(&mut self, delta_secs: u64) {
self.update_block(|block| {
block.time = block.time.plus_seconds(delta_secs);
block.height += 1
})
}
fn set_block_time(&mut self, time: Timestamp) {
self.update_block(|b| b.time = time)
}
fn set_block_time(&mut self, time: Timestamp);
fn execute_msg(
&mut self,
@@ -221,9 +186,12 @@ where
)
.unwrap();
}
fn next_block(&mut self) {
self.app.update_block(next_block)
}
fn update_block<F: Fn(&mut BlockInfo)>(&mut self, action: F) {
self.app.update_block(action)
fn set_block_time(&mut self, time: Timestamp) {
self.app.update_block(|b| b.time = time)
}
fn execute_msg(
@@ -11,14 +11,13 @@ use cosmwasm_std::{
};
use cw_multi_test::Executor;
use cw_storage_plus::{Key, Path, PrimaryKey};
use rand::RngCore;
use rand_chacha::ChaCha20Rng;
use serde::de::DeserializeOwned;
use serde::Serialize;
use std::any::type_name;
use std::ops::Deref;
pub use rand::prelude::*;
pub trait StorageReader {
fn common_key(&self, key: CommonStorageKeys) -> Option<&[u8]>;
@@ -47,11 +47,27 @@ pub trait TestableNymContract {
fn query() -> QueryFn<Self::QueryMsg, Self::ContractError>;
fn migrate() -> PermissionedFn<Self::MigrateMsg, Self::ContractError>;
// not all instances will require default init message, some will always have to provide customised values
#[allow(clippy::unimplemented)]
fn base_init_msg() -> Self::InitMsg {
unimplemented!("attempted to instantiate contract without defining instantiate message")
}
fn base_init_msg() -> Self::InitMsg;
// // for now we don't care about custom queriers
// fn contract_wrapper() -> ContractWrapper<
// Self::ExecuteMsg,
// Self::InitMsg,
// Self::QueryMsg,
// Self::ContractError,
// anyhow::Error,
// anyhow::Error,
// Empty,
// Empty,
// Empty,
// Self::ContractError,
// Self::ContractError,
// Self::MigrateMsg,
// Self::ContractError,
// > {
// ContractWrapper::new(Self::execute(), Self::instantiate(), Self::query())
// .with_migrate(Self::migrate())
// }
fn dyn_contract() -> Box<dyn Contract<Empty>> {
Box::new(
@@ -76,7 +92,6 @@ pub struct ContractTesterBuilder<C> {
app: App<BankKeeper, MockApi, StorageWrapper>,
storage: StorageWrapper,
pub well_known_contracts: HashMap<&'static str, Addr>,
code_ids: HashMap<&'static str, u64>,
}
impl<C> ContractTesterBuilder<C> {
@@ -110,33 +125,20 @@ impl<C> ContractTesterBuilder<C> {
app,
storage,
well_known_contracts: Default::default(),
code_ids: Default::default(),
}
}
pub fn master_address(&self) -> Addr {
self.master_address.clone()
}
pub fn instantiate<D: TestableNymContract>(
mut self,
custom_init_msg: Option<D::InitMsg>,
) -> ContractTesterBuilder<C> {
self.instantiate_contract::<D>(custom_init_msg);
self
}
pub fn instantiate_contract<D: TestableNymContract>(
&mut self,
custom_init_msg: Option<D::InitMsg>,
) {
let code_id = self.app.store_code(D::dyn_contract());
let contract_address = self
.app
.instantiate_contract(
code_id,
self.master_address.clone(),
&custom_init_msg.unwrap_or_else(|| D::base_init_msg()),
&custom_init_msg.unwrap_or(D::base_init_msg()),
&[],
D::NAME,
Some(self.master_address.to_string()),
@@ -152,28 +154,8 @@ impl<C> ContractTesterBuilder<C> {
)
.unwrap();
self.code_ids.insert(D::NAME, code_id);
self.well_known_contracts.insert(D::NAME, contract_address);
}
// uses the SAME code
pub fn migrate_contract<D: TestableNymContract>(&mut self, migrate_msg: &D::MigrateMsg) {
self.app
.migrate_contract(
self.master_address.clone(),
self.unchecked_contract_address::<D>(),
migrate_msg,
self.unchecked_contract_code_id::<D>(),
)
.unwrap();
}
pub fn unchecked_contract_address<D: TestableNymContract>(&self) -> Addr {
self.well_known_contracts.get(D::NAME).unwrap().clone()
}
fn unchecked_contract_code_id<D: TestableNymContract>(&self) -> u64 {
*self.code_ids.get(D::NAME).unwrap()
self
}
pub fn build(self) -> ContractTester<C>
@@ -247,10 +229,6 @@ where
self.insert_common_storage_key(key, value);
self
}
pub fn unchecked_contract_address<D: TestableNymContract>(&self) -> Addr {
self.well_known_contracts.get(D::NAME).unwrap().clone()
}
}
impl<C> Storage for ContractTester<C>
@@ -1,53 +0,0 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use cosmwasm_std::{from_json, Binary, CustomQuery, QuerierWrapper, StdResult};
use serde::de::DeserializeOwned;
use serde::Serialize;
// re-expose methods from QuerierWrapper as traits so that we could more easily define extension traits
pub trait ContractQuerier {
fn query_contract<T: DeserializeOwned>(
&self,
address: impl Into<String>,
msg: &impl Serialize,
) -> StdResult<T>;
fn query_contract_storage(
&self,
address: impl Into<String>,
key: impl Into<Binary>,
) -> StdResult<Option<Vec<u8>>>;
fn query_contract_storage_value<T: DeserializeOwned>(
&self,
address: impl Into<String>,
key: impl Into<Binary>,
) -> StdResult<Option<T>> {
match self.query_contract_storage(address, key)? {
None => Ok(None),
Some(value) => Ok(Some(from_json(&value)?)),
}
}
}
impl<C> ContractQuerier for QuerierWrapper<'_, C>
where
C: CustomQuery,
{
fn query_contract<T: DeserializeOwned>(
&self,
address: impl Into<String>,
msg: &impl Serialize,
) -> StdResult<T> {
self.query_wasm_smart(address, msg)
}
fn query_contract_storage(
&self,
address: impl Into<String>,
key: impl Into<Binary>,
) -> StdResult<Option<Vec<u8>>> {
self.query_wasm_raw(address, key)
}
}
@@ -10,7 +10,6 @@ pub mod events;
pub mod signing;
pub mod types;
pub mod contract_querier;
pub mod helpers;
pub use types::*;
+5
View File
@@ -8,6 +8,11 @@ use nym_crypto::asymmetric::x25519::PublicKey;
use nym_ip_packet_requests::IpPair;
use nym_sphinx::addressing::{NodeIdentity, Recipient};
pub const DEFAULT_PRIVATE_ENTRY_WIREGUARD_KEY_FILENAME: &str = "free_private_entry_wireguard.pem";
pub const DEFAULT_PUBLIC_ENTRY_WIREGUARD_KEY_FILENAME: &str = "free_public_entry_wireguard.pem";
pub const DEFAULT_PRIVATE_EXIT_WIREGUARD_KEY_FILENAME: &str = "free_private_exit_wireguard.pem";
pub const DEFAULT_PUBLIC_EXIT_WIREGUARD_KEY_FILENAME: &str = "free_public_exit_wireguard.pem";
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct NymNode {
pub identity: NodeIdentity,
-5
View File
@@ -633,7 +633,6 @@ dependencies = [
"cw4-group",
"easy-addr",
"nym-contracts-common",
"nym-contracts-common-testing",
"nym-group-contract-common",
"nym-multisig-contract-common",
]
@@ -664,7 +663,6 @@ dependencies = [
"cw4",
"easy-addr",
"nym-contracts-common",
"nym-contracts-common-testing",
"nym-group-contract-common",
"schemars",
"serde",
@@ -1100,13 +1098,11 @@ dependencies = [
"cw-multi-test",
"cw-storage-plus",
"cw2",
"cw3-flex-multisig",
"cw4",
"cw4-group",
"easy-addr",
"nym-coconut-dkg-common",
"nym-contracts-common",
"nym-contracts-common-testing",
"nym-group-contract-common",
"thiserror 2.0.12",
]
@@ -1146,7 +1142,6 @@ dependencies = [
"cosmwasm-std",
"cw-multi-test",
"cw-storage-plus",
"nym-contracts-common",
"rand",
"rand_chacha",
"serde",
+1 -9
View File
@@ -18,8 +18,6 @@ crate-type = ["cdylib", "rlib"]
[dependencies]
nym-coconut-dkg-common = { path = "../../common/cosmwasm-smart-contracts/coconut-dkg" }
nym-contracts-common = { path = "../../common/cosmwasm-smart-contracts/contracts-common" }
nym-contracts-common-testing = { path = "../../common/cosmwasm-smart-contracts/contracts-common-testing", optional = true }
nym-group-contract-common = { path = "../../common/cosmwasm-smart-contracts/group-contract", optional = true }
cosmwasm-schema = { workspace = true, optional = true }
cosmwasm-std = { workspace = true }
@@ -30,19 +28,13 @@ cw2 = { workspace = true }
cw4 = { workspace = true }
thiserror = { workspace = true }
cw3-flex-multisig = { path = "../multisig/cw3-flex-multisig", features = ["testable-cw3-contract"], optional = true }
cw4-group = { path = "../multisig/cw4-group", features = ["testable-cw4-contract"], optional = true }
[dev-dependencies]
anyhow = { workspace = true }
easy-addr = { path = "../../common/cosmwasm-smart-contracts/easy_addr" }
nym-group-contract-common = { path = "../../common/cosmwasm-smart-contracts/group-contract" }
cw-multi-test = { workspace = true }
cw4-group = { path = "../multisig/cw4-group" }
nym-group-contract-common = { path = "../../common/cosmwasm-smart-contracts/group-contract" }
[features]
schema-gen = ["nym-coconut-dkg-common/schema", "cosmwasm-schema"]
testable-dkg-contract = ["nym-contracts-common-testing", "cw3-flex-multisig/testable-cw3-contract", "nym-group-contract-common", "cw4-group/testable-cw4-contract"]
[lints]
workspace = true
@@ -280,50 +280,6 @@
}
},
"additionalProperties": false
},
{
"description": "Transfers ownership of the epoch dealer to another address. This assumes off-chain hand-over of keys",
"type": "object",
"required": [
"transfer_ownership"
],
"properties": {
"transfer_ownership": {
"type": "object",
"required": [
"transfer_to"
],
"properties": {
"transfer_to": {
"type": "string"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
},
{
"description": "Update announce address of this signer",
"type": "object",
"required": [
"update_announce_address"
],
"properties": {
"update_announce_address": {
"type": "object",
"required": [
"new_address"
],
"properties": {
"new_address": {
"type": "string"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
}
],
"definitions": {
@@ -191,50 +191,6 @@
}
},
"additionalProperties": false
},
{
"description": "Transfers ownership of the epoch dealer to another address. This assumes off-chain hand-over of keys",
"type": "object",
"required": [
"transfer_ownership"
],
"properties": {
"transfer_ownership": {
"type": "object",
"required": [
"transfer_to"
],
"properties": {
"transfer_to": {
"type": "string"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
},
{
"description": "Update announce address of this signer",
"type": "object",
"required": [
"update_announce_address"
],
"properties": {
"update_announce_address": {
"type": "object",
"required": [
"new_address"
],
"properties": {
"new_address": {
"type": "string"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
}
],
"definitions": {
+5 -10
View File
@@ -6,9 +6,7 @@ use crate::dealers::queries::{
query_epoch_dealers_addresses_paged, query_epoch_dealers_paged,
query_registered_dealer_details,
};
use crate::dealers::transactions::{
try_add_dealer, try_transfer_ownership, try_update_announce_address,
};
use crate::dealers::transactions::try_add_dealer;
use crate::dealings::queries::{
query_dealer_dealings_status, query_dealing_chunk, query_dealing_chunk_status,
query_dealing_metadata, query_dealing_status,
@@ -23,6 +21,7 @@ use crate::epoch_state::transactions::{
try_advance_epoch_state, try_initiate_dkg, try_trigger_reset, try_trigger_resharing,
};
use crate::error::ContractError;
use crate::queued_migrations::introduce_historical_epochs;
use crate::state::queries::query_state;
use crate::state::storage::{DKG_ADMIN, MULTISIG, STATE};
use crate::verification_key_shares::queries::{query_vk_share, query_vk_shares_paged};
@@ -128,12 +127,6 @@ pub fn execute(
ExecuteMsg::AdvanceEpochState {} => try_advance_epoch_state(deps, env),
ExecuteMsg::TriggerReset {} => try_trigger_reset(deps, env, info),
ExecuteMsg::TriggerResharing {} => try_trigger_resharing(deps, env, info),
ExecuteMsg::TransferOwnership { transfer_to } => {
try_transfer_ownership(deps, env, info, transfer_to)
}
ExecuteMsg::UpdateAnnounceAddress { new_address } => {
try_update_announce_address(deps, info, new_address)
}
}
}
@@ -255,10 +248,12 @@ pub fn query(deps: Deps<'_>, env: Env, msg: QueryMsg) -> Result<QueryResponse, C
}
#[entry_point]
pub fn migrate(deps: DepsMut<'_>, _env: Env, _msg: MigrateMsg) -> Result<Response, ContractError> {
pub fn migrate(deps: DepsMut<'_>, env: Env, _msg: MigrateMsg) -> Result<Response, ContractError> {
set_build_information!(deps.storage)?;
cw2::ensure_from_older_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?;
introduce_historical_epochs(deps, env)?;
Ok(Response::new())
}
@@ -5,7 +5,6 @@ use crate::error::ContractError;
use crate::Dealer;
use cosmwasm_std::{StdResult, Storage};
use cw_storage_plus::{Item, Map};
use nym_coconut_dkg_common::dealer::{BlockHeight, OwnershipTransfer, TransactionIndex};
use nym_coconut_dkg_common::types::{DealerDetails, DealerRegistrationDetails, EpochId, NodeIndex};
pub(crate) const DEALER_INDICES_PAGE_MAX_LIMIT: u32 = 80;
@@ -24,9 +23,6 @@ pub(crate) const DEALERS_INDICES: Map<Dealer, NodeIndex> = Map::new("dealer_inde
pub(crate) const EPOCH_DEALERS_MAP: Map<(EpochId, Dealer), DealerRegistrationDetails> =
Map::new("epoch_dealers");
pub const OWNERSHIP_TRANSFER_LOG: Map<(Dealer, BlockHeight, TransactionIndex), OwnershipTransfer> =
Map::new("transfer_log");
/// Attempts to retrieve a pre-assign node index associated with given dealer.
/// If one doesn't exist, a new one is assigned.
pub(crate) fn get_or_assign_index(
@@ -2,16 +2,15 @@
// SPDX-License-Identifier: Apache-2.0
use crate::dealers::storage::{
ensure_dealer, get_or_assign_index, is_dealer, save_dealer_details_if_not_a_dealer,
DEALERS_INDICES, EPOCH_DEALERS_MAP, OWNERSHIP_TRANSFER_LOG,
get_or_assign_index, is_dealer, save_dealer_details_if_not_a_dealer,
};
use crate::epoch_state::storage::{load_current_epoch, save_epoch};
use crate::epoch_state::utils::check_epoch_state;
use crate::error::ContractError;
use crate::state::storage::STATE;
use crate::Dealer;
use cosmwasm_std::{Deps, DepsMut, Env, Event, MessageInfo, Response};
use nym_coconut_dkg_common::dealer::{DealerRegistrationDetails, OwnershipTransfer};
use cosmwasm_std::{Deps, DepsMut, Env, MessageInfo, Response};
use nym_coconut_dkg_common::dealer::DealerRegistrationDetails;
use nym_coconut_dkg_common::types::{EncodedBTEPublicKeyWithProof, EpochState};
fn ensure_group_member(deps: Deps, dealer: Dealer) -> Result<(), ContractError> {
@@ -58,8 +57,7 @@ pub fn try_add_dealer(
)?;
// check if it's a resharing dealer
// SAFETY: resharing isn't allowed on 0th epoch
#[allow(clippy::expect_used)]
let is_resharing_dealer = resharing
&& is_dealer(
deps.storage,
@@ -85,90 +83,6 @@ pub fn try_add_dealer(
Ok(Response::new().add_attribute("node_index", node_index.to_string()))
}
pub fn try_transfer_ownership(
deps: DepsMut<'_>,
env: Env,
info: MessageInfo,
transfer_to: String,
) -> Result<Response, ContractError> {
let transfer_to = deps.api.addr_validate(&transfer_to)?;
let epoch = load_current_epoch(deps.storage)?;
// make sure we're not mid-exchange
check_epoch_state(deps.storage, EpochState::InProgress)?;
// make sure the requester is actually a dealer for this epoch
ensure_dealer(deps.storage, &info.sender, epoch.epoch_id)?;
// make sure the new target dealer actually belong to the group
ensure_group_member(deps.as_ref(), &transfer_to)?;
// update the index information
let current_index = DEALERS_INDICES.load(deps.storage, &info.sender)?;
DEALERS_INDICES.save(deps.storage, &transfer_to, &current_index)?;
DEALERS_INDICES.remove(deps.storage, &info.sender);
// update registration detail for every epoch the current dealer has participated in the protocol
// ideally, we'd have only updated the current epoch, but the way the contract is constructed
// forbids that otherwise we'd have introduced inconsistency
for epoch_id in 0..=epoch.epoch_id {
if let Some(details) = EPOCH_DEALERS_MAP.may_load(deps.storage, (epoch_id, &info.sender))? {
EPOCH_DEALERS_MAP.remove(deps.storage, (epoch_id, &info.sender));
EPOCH_DEALERS_MAP.save(deps.storage, (epoch_id, &transfer_to), &details)?;
}
}
let Some(transaction_info) = env.transaction else {
return Err(ContractError::ExecutedOutsideTransaction);
};
// save information about the transfer for more convenient history rebuilding
OWNERSHIP_TRANSFER_LOG.save(
deps.storage,
(&info.sender, env.block.height, transaction_info.index),
&OwnershipTransfer {
node_index: current_index,
from: info.sender.clone(),
to: transfer_to.clone(),
},
)?;
Ok(Response::new().add_event(
Event::new("dkg-ownership-transfer")
.add_attribute("from", info.sender)
.add_attribute("to", transfer_to)
.add_attribute("node_index", current_index.to_string()),
))
}
pub fn try_update_announce_address(
deps: DepsMut<'_>,
info: MessageInfo,
new_address: String,
) -> Result<Response, ContractError> {
let epoch = load_current_epoch(deps.storage)?;
// make sure we're not mid-exchange
check_epoch_state(deps.storage, EpochState::InProgress)?;
// make sure the requester is actually a dealer for this epoch
ensure_dealer(deps.storage, &info.sender, epoch.epoch_id)?;
let mut details = EPOCH_DEALERS_MAP.load(deps.storage, (epoch.epoch_id, &info.sender))?;
let old_address = details.announce_address;
details.announce_address = new_address.clone();
EPOCH_DEALERS_MAP.save(deps.storage, (epoch.epoch_id, &info.sender), &details)?;
Ok(Response::new().add_event(
Event::new("dkg-announce-address-update")
.add_attribute("dealer", info.sender)
.add_attribute("old_address", old_address)
.add_attribute("new_address", new_address),
))
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
@@ -223,222 +137,3 @@ pub(crate) mod tests {
);
}
}
#[cfg(test)]
#[cfg(feature = "testable-dkg-contract")]
mod tests_with_mock {
use super::*;
use crate::testable_dkg_contract::{init_contract_tester, DkgContractTesterExt};
use cosmwasm_std::testing::message_info;
use nym_contracts_common_testing::ContractOpts;
#[test]
fn transferring_ownership() -> anyhow::Result<()> {
let mut contract = init_contract_tester();
let group_member = contract.random_group_member();
// sanity check, pre-dkg
assert!(DEALERS_INDICES
.may_load(&contract, &group_member)?
.is_none());
assert!(EPOCH_DEALERS_MAP
.may_load(&contract, (0, &group_member))?
.is_none());
contract.run_initial_dummy_dkg();
let old_index = DEALERS_INDICES.load(&contract, &group_member)?;
let old_details = EPOCH_DEALERS_MAP.load(&contract, (0, &group_member))?;
let not_group_member = contract.addr_make("not_group_member");
let (deps, env) = contract.deps_mut_env();
assert!(try_transfer_ownership(
deps,
env,
message_info(&group_member, &[]),
not_group_member.to_string()
)
.is_err());
let new_group_member = contract.addr_make("new_group_member");
contract.add_group_member(new_group_member.clone());
let (deps, env) = contract.deps_mut_env();
assert!(try_transfer_ownership(
deps,
env.clone(),
message_info(&group_member, &[]),
new_group_member.to_string()
)
.is_ok());
// data under old key doesn't exist anymore
assert!(DEALERS_INDICES
.may_load(&contract, &group_member)?
.is_none());
assert!(EPOCH_DEALERS_MAP
.may_load(&contract, (0, &group_member))?
.is_none());
let new_index = DEALERS_INDICES.load(&contract, &new_group_member)?;
let new_details = EPOCH_DEALERS_MAP.load(&contract, (0, &new_group_member))?;
// the underlying info hasn't changed
assert_eq!(old_index, new_index);
assert_eq!(old_details, new_details);
assert_eq!(
OWNERSHIP_TRANSFER_LOG.load(
&contract,
(
&group_member,
env.block.height,
env.transaction.unwrap().index
)
)?,
OwnershipTransfer {
node_index: new_index,
from: group_member,
to: new_group_member,
}
);
Ok(())
}
#[test]
fn transferring_ownership_in_next_epochs() -> anyhow::Result<()> {
let mut contract = init_contract_tester();
let group_member = contract.random_group_member();
contract.run_initial_dummy_dkg(); // => epoch 0
contract.run_reset_dkg(); // => epoch 1
// LEAVE DKG MEMBERSHIP
contract.remove_group_member(group_member.clone());
contract.run_reset_dkg(); // => epoch 2
// COME BACK
contract.add_group_member(group_member.clone());
contract.run_reset_dkg(); // => epoch 3
let old_index = DEALERS_INDICES.load(&contract, &group_member)?;
let old_details0 = EPOCH_DEALERS_MAP.load(&contract, (0, &group_member))?;
let old_details1 = EPOCH_DEALERS_MAP.load(&contract, (1, &group_member))?;
let old_details2 = EPOCH_DEALERS_MAP.may_load(&contract, (2, &group_member))?;
assert!(old_details2.is_none());
let old_details3 = EPOCH_DEALERS_MAP.load(&contract, (3, &group_member))?;
// sanity check because we haven't changed our registration details:
assert_eq!(old_details0, old_details1);
assert_eq!(old_details1, old_details3);
let new_group_member = contract.addr_make("new_group_member");
contract.add_group_member(new_group_member.clone());
let (deps, env) = contract.deps_mut_env();
assert!(try_transfer_ownership(
deps,
env.clone(),
message_info(&group_member, &[]),
new_group_member.to_string()
)
.is_ok());
// data under old key doesn't exist anymore
assert!(DEALERS_INDICES
.may_load(&contract, &group_member)?
.is_none());
assert!(EPOCH_DEALERS_MAP
.may_load(&contract, (0, &group_member))?
.is_none());
assert!(EPOCH_DEALERS_MAP
.may_load(&contract, (1, &group_member))?
.is_none());
assert!(EPOCH_DEALERS_MAP
.may_load(&contract, (2, &group_member))?
.is_none());
assert!(EPOCH_DEALERS_MAP
.may_load(&contract, (3, &group_member))?
.is_none());
let new_index = DEALERS_INDICES.load(&contract, &new_group_member)?;
let new_details0 = EPOCH_DEALERS_MAP.load(&contract, (0, &new_group_member))?;
let new_details1 = EPOCH_DEALERS_MAP.load(&contract, (1, &new_group_member))?;
let new_details2 = EPOCH_DEALERS_MAP.may_load(&contract, (2, &new_group_member))?;
let new_details3 = EPOCH_DEALERS_MAP.load(&contract, (3, &new_group_member))?;
// the underlying info hasn't changed
assert_eq!(old_index, new_index);
assert_eq!(old_details0, new_details0);
assert_eq!(old_details1, new_details1);
assert_eq!(old_details2, new_details2);
assert_eq!(old_details3, new_details3);
assert_eq!(
OWNERSHIP_TRANSFER_LOG.load(
&contract,
(
&group_member,
env.block.height,
env.transaction.unwrap().index
)
)?,
OwnershipTransfer {
node_index: new_index,
from: group_member,
to: new_group_member,
}
);
Ok(())
}
#[test]
fn updating_announce_address() -> anyhow::Result<()> {
let mut contract = init_contract_tester();
let group_member = contract.random_group_member();
contract.run_initial_dummy_dkg(); // => epoch 0
contract.run_reset_dkg(); // => epoch 1
// LEAVE DKG MEMBERSHIP
contract.remove_group_member(group_member.clone());
contract.run_reset_dkg(); // => epoch 2
// COME BACK
contract.add_group_member(group_member.clone());
contract.run_reset_dkg(); // => epoch 3
let old_details0 = EPOCH_DEALERS_MAP.load(&contract, (0, &group_member))?;
let old_details1 = EPOCH_DEALERS_MAP.load(&contract, (1, &group_member))?;
let old_details2 = EPOCH_DEALERS_MAP.may_load(&contract, (2, &group_member))?;
assert!(old_details2.is_none());
let old_details3 = EPOCH_DEALERS_MAP.load(&contract, (3, &group_member))?;
// sanity check because we haven't changed our registration details:
assert_eq!(old_details0, old_details1);
assert_eq!(old_details1, old_details3);
let new_address = "https://new-address.com".to_string();
try_update_announce_address(
contract.deps_mut(),
message_info(&group_member, &[]),
new_address.clone(),
)?;
let new_details0 = EPOCH_DEALERS_MAP.load(&contract, (0, &group_member))?;
let new_details1 = EPOCH_DEALERS_MAP.load(&contract, (1, &group_member))?;
let new_details2 = EPOCH_DEALERS_MAP.may_load(&contract, (2, &group_member))?;
assert!(new_details2.is_none());
let new_details3 = EPOCH_DEALERS_MAP.load(&contract, (3, &group_member))?;
// old epoch data is unchanged
assert_eq!(old_details0, new_details0);
assert_eq!(old_details1, new_details1);
assert_eq!(old_details2, new_details2);
// most recent entry is updated
assert_eq!(new_details3.announce_address, new_address);
Ok(())
}
}
-3
View File
@@ -139,7 +139,4 @@ pub enum ContractError {
#[error("retrieved the maximum allowed number of cw4 members. for more the contracts have to be refactored")]
PossiblyIncompleteGroupMembersQuery,
#[error("this method has been called outside transaction context")]
ExecutedOutsideTransaction,
}
-3
View File
@@ -15,6 +15,3 @@ mod queued_migrations;
mod state;
mod support;
mod verification_key_shares;
#[cfg(feature = "testable-dkg-contract")]
pub mod testable_dkg_contract;
@@ -1,2 +1,21 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::epoch_state::storage::HISTORICAL_EPOCH;
use crate::error::ContractError;
use cosmwasm_std::{DepsMut, Env};
pub fn introduce_historical_epochs(deps: DepsMut, env: Env) -> Result<(), ContractError> {
if HISTORICAL_EPOCH.may_load(deps.storage)?.is_some() {
return Err(ContractError::FailedMigration {
comment: "this migration has already been run before".to_string(),
});
}
#[allow(deprecated)]
let current = crate::epoch_state::storage::CURRENT_EPOCH.load(deps.storage)?;
// we won't have information on intermediate states prior to now, but that's not the end of the world
HISTORICAL_EPOCH.save(deps.storage, &current, env.block.height)?;
Ok(())
}
@@ -1,7 +1,6 @@
// Copyright 2022 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use super::fixtures::TEST_MIX_DENOM;
use crate::contract::instantiate;
use crate::dealers::storage::{DEALERS_INDICES, EPOCH_DEALERS_MAP};
use crate::epoch_state::storage::load_current_epoch;
@@ -18,6 +17,8 @@ use nym_coconut_dkg_common::msg::InstantiateMsg;
use nym_coconut_dkg_common::types::{DealerDetails, EpochId};
use std::sync::Mutex;
use super::fixtures::TEST_MIX_DENOM;
pub const ADMIN_ADDRESS: &str = addr!("admin address");
pub const GROUP_CONTRACT: &str = addr!("group contract address");
pub const MULTISIG_CONTRACT: &str = addr!("multisig contract address");
@@ -73,7 +74,6 @@ pub fn add_fixture_dealer(deps: DepsMut<'_>) {
);
}
#[allow(clippy::panic)]
fn querier_handler(query: &WasmQuery) -> QuerierResult {
let bin = match query {
WasmQuery::Smart { contract_addr, msg } => {
@@ -1,57 +0,0 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::error::ContractError;
use cosmwasm_std::{Addr, QuerierWrapper};
use cw4::Cw4Contract;
pub(crate) fn group_members(
querier_wrapper: &QuerierWrapper,
contract: &Cw4Contract,
) -> Result<Vec<Addr>, ContractError> {
// we shouldn't ever have more group members than the default limit but IN CASE
// something changes down the line, do go through the pagination flow
let mut group_members = Vec::new();
// current max limit
let limit = 30;
let mut start_after = None;
loop {
let members = contract.list_members(querier_wrapper, start_after, Some(limit))?;
start_after = members.last().as_ref().map(|d| d.addr.clone());
for member in &members {
group_members.push(Addr::unchecked(&member.addr));
}
if members.len() < limit as usize {
// we have already exhausted the data
break;
}
}
Ok(group_members)
}
#[cfg(test)]
mod tests {
use crate::testable_dkg_contract::helpers::group_members;
use crate::testable_dkg_contract::init_contract_tester_with_group_members;
use cw4::Cw4Contract;
use cw4_group::testable_cw4_contract::GroupContract;
use nym_contracts_common_testing::ContractOpts;
#[test]
fn getting_group_members() -> anyhow::Result<()> {
for members in [0, 10, 100, 1000] {
let tester = init_contract_tester_with_group_members(members);
let group_contract =
Cw4Contract::new(tester.unchecked_contract_address::<GroupContract>());
let querier = tester.deps().querier;
let addresses = group_members(&querier, &group_contract)?;
assert_eq!(addresses.len(), members);
}
Ok(())
}
}
@@ -1,404 +0,0 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
// fine in test code
#![allow(clippy::unwrap_used)]
#![allow(clippy::expect_used)]
use crate::contract::{execute, instantiate, migrate, query};
use crate::error::ContractError;
use cosmwasm_std::testing::message_info;
use cosmwasm_std::Addr;
use cw4::{Cw4Contract, Member};
use nym_contracts_common_testing::{
AdminExt, ArbitraryContractStorageReader, ArbitraryContractStorageWriter, BankExt, ChainOpts,
CommonStorageKeys, ContractFn, ContractOpts, ContractTester, ContractTesterBuilder, DenomExt,
PermissionedFn, QueryFn, RandExt, SliceRandom, TEST_DENOM,
};
use crate::epoch_state::storage::load_current_epoch;
use crate::state::storage::{MULTISIG, STATE};
use crate::testable_dkg_contract::helpers::group_members;
use nym_coconut_dkg_common::dealing::{DealingChunkInfo, PartialContractDealing};
use nym_coconut_dkg_common::types::{Epoch, EpochState};
use nym_contracts_common::dealings::ContractSafeBytes;
pub use cw3_flex_multisig::testable_cw3_contract::{Duration, MultisigContract, Threshold};
pub use cw4_group::testable_cw4_contract::GroupContract;
pub use nym_coconut_dkg_common::msg::{ExecuteMsg, InstantiateMsg, MigrateMsg, QueryMsg};
pub use nym_contracts_common_testing::TestableNymContract;
pub(crate) mod helpers;
pub struct DkgContract;
const DEFAULT_GROUP_MEMBERS: usize = 15;
impl TestableNymContract for DkgContract {
const NAME: &'static str = "dkg-contract";
type InitMsg = InstantiateMsg;
type ExecuteMsg = ExecuteMsg;
type QueryMsg = QueryMsg;
type MigrateMsg = MigrateMsg;
type ContractError = ContractError;
fn instantiate() -> ContractFn<Self::InitMsg, Self::ContractError> {
instantiate
}
fn execute() -> ContractFn<Self::ExecuteMsg, Self::ContractError> {
execute
}
fn query() -> QueryFn<Self::QueryMsg, Self::ContractError> {
query
}
fn migrate() -> PermissionedFn<Self::MigrateMsg, Self::ContractError> {
migrate
}
fn init() -> ContractTester<Self>
where
Self: Sized,
{
init_contract_tester_with_group_members(DEFAULT_GROUP_MEMBERS)
}
}
pub fn init_contract_tester() -> ContractTester<DkgContract> {
DkgContract::init().with_common_storage_key(CommonStorageKeys::Admin, "dkg-admin")
}
pub fn prepare_contract_tester_builder_with_group_members<C>(
members: usize,
) -> ContractTesterBuilder<C>
where
C: TestableNymContract,
{
let mut builder = ContractTesterBuilder::<C>::new();
let api = builder.api();
// 1. init the CW4 group contract
let group_init_msg = cw4_group::testable_cw4_contract::InstantiateMsg {
admin: Some(builder.master_address().to_string()),
members: (0..members)
.map(|i| Member {
addr: api.addr_make(&format!("group-member-{i}")).to_string(),
weight: 1,
})
.collect(),
};
builder.instantiate_contract::<GroupContract>(Some(group_init_msg));
// we just instantiated it
let group_contract_address = builder.unchecked_contract_address::<GroupContract>();
// 2. init the CW3 multisig contract WITH DUMMY VALUES
let multisig_init_msg = cw3_flex_multisig::testable_cw3_contract::InstantiateMsg {
group_addr: group_contract_address.to_string(),
// \/ PLACEHOLDERS
coconut_bandwidth_contract_address: group_contract_address.to_string(),
coconut_dkg_contract_address: group_contract_address.to_string(),
// /\ PLACEHOLDERS
threshold: Threshold::AbsolutePercentage {
percentage: "0.67".parse().unwrap(),
},
max_voting_period: Duration::Time(3600),
executor: None,
proposal_deposit: None,
};
builder.instantiate_contract::<MultisigContract>(Some(multisig_init_msg));
// we just instantiated it
let multisig_contract_address = builder.unchecked_contract_address::<MultisigContract>();
// 3. init the DKG contract
let dkg_init_msg = InstantiateMsg {
group_addr: group_contract_address.to_string(),
multisig_addr: multisig_contract_address.to_string(),
time_configuration: None,
mix_denom: TEST_DENOM.to_string(),
key_size: 5,
};
builder.instantiate_contract::<DkgContract>(Some(dkg_init_msg));
// we just instantiated it
let dkg_contract_address = builder.unchecked_contract_address::<DkgContract>();
// 4. migrate the multisig contract to hold correct addresses
let multisig_migrate_msg = cw3_flex_multisig::testable_cw3_contract::MigrateMsg {
// \/ STILL A PLACEHOLDER (this contract does not care about interactions with the ecash contract)
coconut_bandwidth_address: dkg_contract_address.to_string(),
// /\ STILL A PLACEHOLDER
coconut_dkg_address: dkg_contract_address.to_string(),
};
builder.migrate_contract::<MultisigContract>(&multisig_migrate_msg);
builder
}
pub fn init_contract_tester_with_group_members(members: usize) -> ContractTester<DkgContract> {
prepare_contract_tester_builder_with_group_members(members)
.build()
.with_common_storage_key(CommonStorageKeys::Admin, "dkg-admin")
}
pub trait DkgContractTesterExt:
ContractOpts<ExecuteMsg = ExecuteMsg, QueryMsg = QueryMsg, ContractError = ContractError>
+ ChainOpts
+ AdminExt
+ DenomExt
+ RandExt
+ BankExt
+ ArbitraryContractStorageReader
+ ArbitraryContractStorageWriter
{
fn epoch(&self) -> Epoch {
load_current_epoch(self.storage()).unwrap()
}
fn multisig_contract(&self) -> Addr {
MULTISIG.get(self.deps()).unwrap().unwrap()
}
fn group_contract_wrapper(&self) -> Cw4Contract {
STATE.load(self.storage()).unwrap().group_addr
}
fn remove_group_member(&mut self, addr: Addr) {
// we have the same admin for all contracts
let admin = self.admin().unwrap();
self.execute_arbitrary_contract(
self.unchecked_contract_address::<GroupContract>(),
message_info(&admin, &[]),
&nym_group_contract_common::msg::ExecuteMsg::UpdateMembers {
remove: vec![addr.to_string()],
add: vec![],
},
)
.unwrap();
}
fn add_group_member(&mut self, addr: Addr) {
let querier = self.deps().querier;
let members = self
.group_contract_wrapper()
.list_members(&querier, None, None)
.unwrap();
let weight = members.first().map(|m| m.weight).unwrap_or(1);
// we have the same admin for all contracts
let admin = self.admin().unwrap();
self.execute_arbitrary_contract(
self.unchecked_contract_address::<GroupContract>(),
message_info(&admin, &[]),
&nym_group_contract_common::msg::ExecuteMsg::UpdateMembers {
remove: vec![],
add: vec![Member {
addr: addr.to_string(),
weight,
}],
},
)
.unwrap();
}
fn group_members(&self) -> Vec<Addr> {
let querier = self.deps().querier;
let group_contract = self.group_contract_wrapper();
group_members(&querier, &group_contract).unwrap()
}
fn random_group_member(&mut self) -> Addr {
let members = self.group_members();
members
.choose(&mut self.raw_rng())
.expect("no group members available")
.clone()
}
fn dummy_dkg_steps(&mut self, resharing: bool) {
let admin = self.admin().unwrap();
let group_members = self.group_members();
// 2. register dealers
for group_member in &group_members {
self.execute_msg(
group_member.clone(),
&ExecuteMsg::RegisterDealer {
bte_key_with_proof: format!("btekey-{group_member}"),
identity_key: format!("identity-{group_member}"),
announce_address: format!("announce-address-{group_member}"),
resharing,
},
)
.unwrap();
}
// PublicKeySubmission => DealingExchange
self.advance_time_by(600);
self.execute_msg(admin.clone(), &ExecuteMsg::AdvanceEpochState {})
.unwrap();
assert_eq!(
self.epoch().state,
EpochState::DealingExchange { resharing }
);
// 3. exchange dealings
for group_member in &group_members {
self.execute_msg(
group_member.clone(),
&ExecuteMsg::CommitDealingsMetadata {
dealing_index: 1,
chunks: vec![DealingChunkInfo { size: 1 }],
resharing,
},
)
.unwrap();
self.execute_msg(
group_member.clone(),
&ExecuteMsg::CommitDealingsChunk {
chunk: PartialContractDealing {
dealing_index: 1,
chunk_index: 0,
data: ContractSafeBytes(vec![0]),
},
},
)
.unwrap();
}
// DealingExchange => VerificationKeySubmission
self.advance_time_by(300);
self.execute_msg(admin.clone(), &ExecuteMsg::AdvanceEpochState {})
.unwrap();
assert_eq!(
self.epoch().state,
EpochState::VerificationKeySubmission { resharing }
);
// 4. derive keypairs
for group_member in &group_members {
self.execute_msg(
group_member.clone(),
&ExecuteMsg::CommitVerificationKeyShare {
share: format!("partial-vk-{group_member}"),
resharing,
},
)
.unwrap();
}
// VerificationKeySubmission => VerificationKeyValidation
self.execute_msg(admin.clone(), &ExecuteMsg::AdvanceEpochState {})
.unwrap();
self.advance_time_by(60);
assert_eq!(
self.epoch().state,
EpochState::VerificationKeyValidation { resharing }
);
// VerificationKeyValidation => VerificationKeyFinalization
self.execute_msg(admin.clone(), &ExecuteMsg::AdvanceEpochState {})
.unwrap();
assert_eq!(
self.epoch().state,
EpochState::VerificationKeyFinalization { resharing }
);
// 5. validate keys
for group_member in &group_members {
self.execute_msg(
self.multisig_contract(),
&ExecuteMsg::VerifyVerificationKeyShare {
owner: group_member.to_string(),
resharing,
},
)
.unwrap();
}
// VerificationKeyFinalization => InProgress
self.execute_msg(admin.clone(), &ExecuteMsg::AdvanceEpochState {})
.unwrap();
assert_eq!(self.epoch().state, EpochState::InProgress)
}
fn run_initial_dummy_dkg(&mut self) {
assert_eq!(self.epoch().state, EpochState::WaitingInitialisation);
// 1. initiate DKG
// WaitingInitialisation => PublicKeySubmission
let admin = self.admin().unwrap();
self.execute_msg(admin.clone(), &ExecuteMsg::InitiateDkg {})
.unwrap();
assert_eq!(
self.epoch().state,
EpochState::PublicKeySubmission { resharing: false }
);
self.dummy_dkg_steps(false)
}
fn run_reset_dkg(&mut self) {
// 1. reset DKG
// InProgress => PublicKeySubmission
let admin = self.admin().unwrap();
self.execute_msg(admin.clone(), &ExecuteMsg::TriggerReset {})
.unwrap();
assert_eq!(
self.epoch().state,
EpochState::PublicKeySubmission { resharing: false }
);
self.dummy_dkg_steps(false)
}
fn run_resharing_dkg(&mut self) {
assert_eq!(self.epoch().state, EpochState::InProgress);
let group_members = self.group_members();
println!(
"epoch: {} members: {}",
self.epoch().epoch_id,
group_members.len()
);
// 1. initiate DKG
// InProgress => PublicKeySubmission
let admin = self.admin().unwrap();
self.execute_msg(admin.clone(), &ExecuteMsg::TriggerResharing {})
.unwrap();
assert_eq!(
self.epoch().state,
EpochState::PublicKeySubmission { resharing: true }
);
self.dummy_dkg_steps(true)
}
}
impl DkgContractTesterExt for ContractTester<DkgContract> {}
#[cfg(test)]
mod tests {
use super::*;
use crate::dealers::storage::EPOCH_DEALERS_MAP;
#[test]
fn dummy_resharing() {
let mut contract = init_contract_tester_with_group_members(10);
contract.run_initial_dummy_dkg();
let dealer = contract.random_group_member();
let details = EPOCH_DEALERS_MAP
.may_load(contract.storage(), (0, &dealer))
.unwrap();
assert!(details.is_some());
assert_eq!(contract.epoch().epoch_id, 0);
contract.run_resharing_dkg();
assert_eq!(contract.epoch().epoch_id, 1);
}
}
+80 -3
View File
@@ -67,7 +67,7 @@ pub mod test_helpers {
use cosmwasm_std::{Env, Response, Timestamp, Uint128};
use mixnet_contract_common::error::MixnetContractError;
use mixnet_contract_common::events::{
MixnetEventType, DELEGATES_REWARD_KEY, OPERATOR_REWARD_KEY,
may_find_attribute, MixnetEventType, DELEGATES_REWARD_KEY, OPERATOR_REWARD_KEY,
};
use mixnet_contract_common::helpers::compare_decimals;
use mixnet_contract_common::mixnode::{NodeRewarding, UnbondedMixnode};
@@ -100,8 +100,8 @@ pub mod test_helpers {
use rand_chacha::ChaCha20Rng;
use serde::Serialize;
use std::collections::HashMap;
pub(crate) use nym_contracts_common_testing::helpers::{find_attribute, FindAttribute};
use std::fmt::Debug;
use std::str::FromStr;
pub(crate) fn sorted_addresses(n: usize) -> Vec<Addr> {
let mut rng = test_rng();
@@ -1592,6 +1592,83 @@ pub mod test_helpers {
None
}
#[track_caller]
pub fn find_attribute<S: Into<String>>(
event_type: Option<S>,
attribute: &str,
response: &Response,
) -> String {
let event_type = event_type.map(Into::into);
for event in &response.events {
if let Some(typ) = &event_type {
if &event.ty != typ {
continue;
}
}
if let Some(attr) = may_find_attribute(event, attribute) {
return attr;
}
}
// this is only used in tests so panic here is fine
panic!("did not find the attribute")
}
pub(crate) trait FindAttribute {
fn attribute<E, S>(&self, event_type: E, attribute: &str) -> String
where
E: Into<Option<S>>,
S: Into<String>;
fn any_attribute(&self, attribute: &str) -> String {
self.attribute::<_, String>(None, attribute)
}
fn any_parsed_attribute<T>(&self, attribute: &str) -> T
where
T: FromStr,
<T as FromStr>::Err: Debug,
{
self.parsed_attribute::<_, String, T>(None, attribute)
}
fn parsed_attribute<E, S, T>(&self, event_type: E, attribute: &str) -> T
where
E: Into<Option<S>>,
S: Into<String>,
T: FromStr,
<T as FromStr>::Err: Debug;
fn decimal<E, S>(&self, event_type: E, attribute: &str) -> Decimal
where
E: Into<Option<S>>,
S: Into<String>,
{
self.parsed_attribute(event_type, attribute)
}
}
impl FindAttribute for Response {
fn attribute<E, S>(&self, event_type: E, attribute: &str) -> String
where
E: Into<Option<S>>,
S: Into<String>,
{
find_attribute(event_type.into(), attribute, self)
}
fn parsed_attribute<E, S, T>(&self, event_type: E, attribute: &str) -> T
where
E: Into<Option<S>>,
S: Into<String>,
T: FromStr,
<T as FromStr>::Err: Debug,
{
find_attribute(event_type.into(), attribute, self)
.parse()
.unwrap()
}
}
// using floats in tests is fine
// (what it does is converting % value, like 12.34 into `Performance` (`Percent`)
// which internally is represented by decimal `0.1234`
@@ -16,6 +16,10 @@ required-features = ["cosmwasm-schema"]
[lib]
crate-type = ["cdylib", "rlib"]
[features]
# use library feature to disable all instantiate/execute/query exports
library = []
[dependencies]
cw-utils = { workspace = true }
cw2 = { workspace = true }
@@ -30,15 +34,9 @@ cosmwasm-std = { workspace = true }
nym-group-contract-common = { path = "../../../common/cosmwasm-smart-contracts/group-contract" }
nym-multisig-contract-common = { path = "../../../common/cosmwasm-smart-contracts/multisig-contract" }
nym-contracts-common = { path = "../../../common/cosmwasm-smart-contracts/contracts-common" }
nym-contracts-common-testing = { path = "../../../common/cosmwasm-smart-contracts/contracts-common-testing", optional = true }
[dev-dependencies]
easy-addr = { path = "../../../common/cosmwasm-smart-contracts/easy_addr" }
cw4-group = { path = "../cw4-group" }
cw-multi-test = { workspace = true }
cw20-base = { workspace = true }
[features]
# use library feature to disable all instantiate/execute/query exports
library = []
testable-cw3-contract = ["nym-contracts-common-testing"]
@@ -23,6 +23,3 @@ For more information on this contract, please check out the
*/
pub mod contract;
#[cfg(feature = "testable-cw3-contract")]
pub mod testable_cw3_contract;
@@ -1,41 +0,0 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::contract::{execute, instantiate, migrate, query};
use nym_contracts_common_testing::{ContractFn, PermissionedFn, QueryFn};
use nym_multisig_contract_common::error::ContractError;
pub use cw_utils::{Duration, Threshold};
pub use nym_contracts_common_testing::TestableNymContract;
pub use nym_multisig_contract_common::msg::{ExecuteMsg, InstantiateMsg, MigrateMsg, QueryMsg};
pub struct MultisigContract;
impl TestableNymContract for MultisigContract {
const NAME: &'static str = "cw3-flex-multisig-contract";
type InitMsg = InstantiateMsg;
type ExecuteMsg = ExecuteMsg;
type QueryMsg = QueryMsg;
type MigrateMsg = MigrateMsg;
type ContractError = ContractError;
fn instantiate() -> ContractFn<Self::InitMsg, Self::ContractError> {
instantiate
}
fn execute() -> ContractFn<Self::ExecuteMsg, Self::ContractError> {
execute
}
fn query() -> QueryFn<Self::QueryMsg, Self::ContractError> {
|deps, env, msg| query(deps, env, msg).map_err(Into::into)
}
fn migrate() -> PermissionedFn<Self::MigrateMsg, Self::ContractError> {
migrate
}
fn base_init_msg() -> Self::InitMsg {
unimplemented!()
}
}
+8 -9
View File
@@ -22,10 +22,14 @@ name = "schema"
[lib]
crate-type = ["cdylib", "rlib"]
[features]
# use library feature to disable all instantiate/execute/query exports
library = []
[dependencies]
nym-group-contract-common = { path = "../../../common/cosmwasm-smart-contracts/group-contract" }
nym-contracts-common = { path = "../../../common/cosmwasm-smart-contracts/contracts-common" }
nym-contracts-common-testing = { path = "../../../common/cosmwasm-smart-contracts/contracts-common-testing", optional = true }
cw-utils = { workspace = true }
cw2 = { workspace = true }
@@ -34,14 +38,9 @@ cw-controllers = { workspace = true }
cw-storage-plus = { workspace = true }
cosmwasm-schema = { workspace = true }
cosmwasm-std = { workspace = true }
schemars = { workspace = true }
serde = { workspace = true, default-features = false, features = ["derive"] }
schemars = "0.8.1"
serde = { version = "1.0.103", default-features = false, features = ["derive"] }
thiserror = { workspace = true }
[dev-dependencies]
easy-addr = { path = "../../../common/cosmwasm-smart-contracts/easy_addr" }
[features]
# use library feature to disable all instantiate/execute/query exports
library = []
testable-cw4-contract = ["nym-contracts-common-testing"]
easy-addr = { path = "../../../common/cosmwasm-smart-contracts/easy_addr" }
-3
View File
@@ -23,6 +23,3 @@ pub use crate::error::ContractError;
#[cfg(test)]
mod tests;
#[cfg(feature = "testable-cw4-contract")]
pub mod testable_cw4_contract;
@@ -1,40 +0,0 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::contract::{execute, instantiate, migrate, query};
use crate::error::ContractError;
use nym_contracts_common_testing::{ContractFn, PermissionedFn, QueryFn};
pub use nym_contracts_common_testing::TestableNymContract;
pub use nym_group_contract_common::msg::{ExecuteMsg, InstantiateMsg, MigrateMsg, QueryMsg};
pub struct GroupContract;
impl TestableNymContract for GroupContract {
const NAME: &'static str = "cw4-group-contract";
type InitMsg = InstantiateMsg;
type ExecuteMsg = ExecuteMsg;
type QueryMsg = QueryMsg;
type MigrateMsg = MigrateMsg;
type ContractError = ContractError;
fn instantiate() -> ContractFn<Self::InitMsg, Self::ContractError> {
instantiate
}
fn execute() -> ContractFn<Self::ExecuteMsg, Self::ContractError> {
execute
}
fn query() -> QueryFn<Self::QueryMsg, Self::ContractError> {
|deps, env, msg| query(deps, env, msg).map_err(Into::into)
}
fn migrate() -> PermissionedFn<Self::MigrateMsg, Self::ContractError> {
migrate
}
fn base_init_msg() -> Self::InitMsg {
unimplemented!()
}
}
@@ -1,14 +1,13 @@
Open the needed ports for `nym-node` by running these commands:
```sh
ufw allow 22/tcp # SSH - you're in control of these ports
ufw allow 80/tcp # HTTP
ufw allow 443/tcp # HTTPS
ufw allow 1789/tcp # Nym specific - Mixnet
ufw allow 1790/tcp # Nym specific - Verloc
ufw allow 8080/tcp # Nym specific - nym-node-api
ufw allow 9000/tcp # Nym Specific - clients port
ufw allow 9001/tcp # Nym specific - wss port
ufw allow 51822/udp # WireGuard
ufw allow in on nymwg to any port 51830 proto tcp # bandwidth queries/topup - inside the tunnel
ufw allow 22/tcp # SSH - you're in control of these ports
ufw allow 80/tcp # HTTP
ufw allow 443/tcp # HTTPS
ufw allow 1789/tcp # Nym specific
ufw allow 1790/tcp # Nym specific
ufw allow 8080/tcp # Nym specific - nym-node-api
ufw allow 9000/tcp # Nym Specific - clients port
ufw allow 9001/tcp # Nym specific - wss port
ufw allow 51822/udp # WireGuard
```
@@ -5,7 +5,7 @@
},
"mixmining_reserve": {
"denom": "unym",
"amount": "180875972213757"
"amount": "182883243257647"
},
"vesting_tokens": {
"denom": "unym",
@@ -13,6 +13,6 @@
},
"circulating_supply": {
"denom": "unym",
"amount": "819124027786243"
"amount": "817116756742353"
}
}
@@ -1 +1 @@
819_124_027
817_116_756
@@ -1 +1 @@
60_147_392
60_000_000
@@ -1 +1 @@
60_147_391
60_000_000
@@ -1,7 +1,7 @@
| **Item** | **Description** | **Amount in NYM** |
|:-------------------|:------------------------------------------------------|--------------------:|
| Total Supply | Maximum amount of NYM token in existence | 1_000_000_000 |
| Mixmining Reserve | Tokens releasing for operators rewards | 180_875_972 |
| Mixmining Reserve | Tokens releasing for operators rewards | 182_883_243 |
| Vesting Tokens | Tokens locked outside of cicrulation for future claim | 0 |
| Circulating Supply | Amount of unlocked tokens | 819_124_027 |
| Stake Saturation | Optimal size of node self-bond + delegation | 250_614 |
| Circulating Supply | Amount of unlocked tokens | 817_116_756 |
| Stake Saturation | Optimal size of node self-bond + delegation | 250_000 |
@@ -1,10 +1,10 @@
{
"interval": {
"reward_pool": "180875972213757.135840914936141948",
"staking_supply": "60147391744900.170789956043539529",
"reward_pool": "182883243257647.891553460395608456",
"staking_supply": "60000000000000",
"staking_supply_scale_factor": "0.07342892",
"epoch_reward_budget": "5024332561.493253773358748226",
"stake_saturation_point": "250614132270.417378291483514748",
"epoch_reward_budget": "5080090090.490219209818344322",
"stake_saturation_point": "250000000000",
"sybil_resistance": "0.3",
"active_set_work_factor": "10",
"interval_pool_emission": "0.02"
@@ -1 +1 @@
Wednesday, October 1st 2025, 11:16:15 UTC
Tuesday, September 16th 2025, 11:07:26 UTC
@@ -58,8 +58,8 @@ Options:
Specifies whether the wireguard service is enabled on this node [env: NYMNODE_WG_ENABLED=] [possible values: true, false]
--wireguard-bind-address <WIREGUARD_BIND_ADDRESS>
Socket address this node will use for binding its wireguard interface. default: `[::]:51822` [env: NYMNODE_WG_BIND_ADDRESS=]
--wireguard-tunnel-announced-port <WIREGUARD_TUNNEL_ANNOUNCED_PORT>
Tunnel port announced to external clients wishing to connect to the wireguard interface. Useful in the instances where the node is behind a proxy [env: NYMNODE_WG_ANNOUNCED_PORT=]
--wireguard-announced-port <WIREGUARD_ANNOUNCED_PORT>
Port announced to external clients wishing to connect to the wireguard interface. Useful in the instances where the node is behind a proxy [env: NYMNODE_WG_ANNOUNCED_PORT=]
--wireguard-private-network-prefix <WIREGUARD_PRIVATE_NETWORK_PREFIX>
The prefix denoting the maximum number of the clients that can be connected via Wireguard. The maximum value for IPv4 is 32 and for IPv6 is 128 [env: NYMNODE_WG_PRIVATE_NETWORK_PREFIX=]
--verloc-bind-address <VERLOC_BIND_ADDRESS>
@@ -6,7 +6,6 @@
"sandbox": "Sandbox Testnet",
"binaries": "Binaries",
"nodes": "Nodes & Validators Guides",
"tools": "Tools",
"troubleshooting": "Troubleshooting",
"tokenomics": "Tokenomics",
"faq": "FAQ",
@@ -48,174 +48,6 @@ This page displays a full list of all the changes during our release cycle from
<VarInfo />
## `v2025.17-isabirra`
- [Release Binaries](https://github.com/nymtech/nym/releases/tag/nym-binaries-v2025.17-isabirra)
- [`nym-node`](nodes/nym-node.mdx) version `1.18.0`
```sh
nym-node
Binary Name: nym-node
Build Timestamp: 2025-10-01T10:42:58.647419869Z
Build Version: 1.18.0
Commit SHA: bbea2ff9e913f49cb7bf6c7bafa9d9b158c80de5
Commit Date: 2025-10-01T12:06:07.000000000+02:00
Commit Branch: HEAD
rustc Version: 1.88.0
rustc Channel: stable
cargo Profile: release
```
### Operators Updates & Tools
<Callout type="info" emoji="️">
**With `nym-node` version `1.18.0` operators need to make `tcp/51830` reachable on the WG interface (for bandwidth queries/topup).**
This is inside the tunnel - no need to expose it publicly unless you want an external test.
**Run this command to expose it:**
```
ufw allow in on nymwg to any port 51830 proto tcp
```
</Callout>
With this platform upgrade we are releasing several tools to improve operator experience. Check them out and let us know what you think.
- **New program for `nym-node` automated installation**: [`nym-node-cli.py`](tools#nym-node-cli)
- **New node reward tracker**: [`node_rewards_tracker.py`](tools#cmd-reward-tracker)
- **New server ping testing diagnostic tool**: [`test-nodes-pings.sh`](tools#node-ping-tester)
- **New [Tools page](tools)**
- **New Gateway landing page [template](https://raw.githubusercontent.com/nymtech/nym/refs/heads/develop/scripts/nym-node-setup/landing-page.html)** and simplified [documentation for reverse proxy configuration](nodes/nym-node/configuration/proxy-configuration#html-file-customization)
- SpectreDAO Explorer has a new [Delegation Wizzard](https://explorer.nym.spectredao.net/delegate): Allowing for a smart multi-delegation with optimal node selection form Keplr Wallet
### API Changes
Please read carefully below to follow up with API removal and changes to ensure that your development is up to date.
#### Nym API Removed Routes
All of the routes removed had already been deprecated over a year ago. This is mostly due to the fact that either they were returning only data on legacy nodes (that could never be used anyway) or required some non-sense conversions. Open the dropdown below to see removed routes
<br/>
<AccordionTemplate name="Removed API routes">
### Legacy mixnodes related:
- `/v1/mixnodes`
- `/v1/mixnodes/active`
- `/v1/mixnodes/active/detailed`
- `/v1/mixnodes/described`
- `/v1/mixnodes/rewarded`
- `/v1/mixnodes/rewarded/detailed`
- `/v1/mixnodes/detailed`
- `/v1/mixnodes/blacklisted`
- `/v1/status/mixnodes/active/detailed`
- `/v1/status/mixnodes/rewarded/detailed`
- `/v1/status/mixnodes/detailed`
- `/v1/status/mixnodes/inclusion-probability`
- `/v1/status/mixnodes/inclusion-probability`
- `/v1/status/mixnode/{mix_id}/inclusion-probability`
- `/v1/status/mixnode/{mix_id}/stake-saturation`
- `/v1/status/mixnode/{mix_id}/status`
- `/v1/status/mixnode/{mix_id}/reward-estimation`
- `/v1/status/mixnode/{mix_id}/compute-reward-estimation`
- `/v1/status/mixnodes/detailed-unfiltered`
- `/v1/status/mixnode/{mix_id}/report`
- `/v1/status/mixnode/{mix_id}/avg_uptime`
### Legacy gateways related:
- `/v1/gateways`
- `/v1/gateways/described`
- `/v1/gateways/blacklisted`
- `/v1/status/gateways/detailed`
- `/v1/status/gateways/detailed-unfiltered`
- `/v1/status/gateway/{identity}/report`
- `/v1/status/gateway/{identity}/avg_uptime`
</AccordionTemplate>
#### Structs changes:
- `MixnodeUptimeHistoryResponse` no longer has `owner` field
- `GatewayUptimeHistoryResponse` no longer has `owner` field
#### New Routes Added
- `/v1/nym-nodes/stake-saturation/{node_id}` - as a better replacement for `/v1/status/mixnode/{mix_id}/stake-saturation` as this information might be potentially useful and can be applied to any nym-node, not just a legacy mixnode.
- `/v1/legacy/mixnodes` - returns a list of bonded legacy mixnodes that haven't migrated to nym-nodes
- `/v1/legacy/gateways` - returns a list of bonded legacy gateways that haven't migrated to nym-nodes
#### Node Status API
Furthermore the changes remove all scraping of legacy mixnodes from NS and the following routes are removed:
- `/v2/mixnodes/{mix_id}`
- `/v2/mixnodes`
### Features
- [Refresh mixnet contract on epoch progression](https://github.com/nymtech/nym/pull/6023): Currently when an epoch has advanced, `nym-api` might still be serving data from the previous iteration. This PR makes sure the data is refreshed as soon as possible so new role assignment would be available quickly after.
- [Explorer-v2: Replace recommended servers with automated selection](https://github.com/nymtech/nym/pull/6019): Use params to get 10 nodes, no more manual paste.
- [Credential proxy crate](https://github.com/nymtech/nym/pull/6018): This PR moves 90% of the credential proxy functionalities into a common crate instead.
- [Moving clients crate from vpn-client repo to here](https://github.com/nymtech/nym/pull/6015): As part of the registration-client work, moved `nym-authenticator-client`, `nym-ip-packet-client` and `nym-wg-gateway-client` from the vpn-client repo into this repository.
- [Cancellation migration](https://github.com/nymtech/nym/pull/6014): Migrates shutdown watchers from `TaskManager` into the new `ShutdownManager`, which relies on tokios `CancellationToken` and `TaskTracker`.
- [Use `ShutdownToken` for nym-api](https://github.com/nymtech/nym/pull/5997): Makes `nym-api` use `ShutdownToken` (tokio `CancellationToken` inside) for cancellation; groundwork for migrating clients to the same approach.
- [Delegation program stake checker and adjuster](https://github.com/nymtech/nym/pull/5980): Adds a script to generate a CSV for delegation adjustments according to Delegation Program rules, including node/stake/uptime/version and other fields.
- [Domain fronting integration](https://github.com/nymtech/nym/pull/5974): Completes migration to `nym_http_api_client::Client`, enabling domain fronting and unifying HTTP client usage across the monorepo.
- [Shared library for attempting to retrieve update mode attestation](https://github.com/nymtech/nym/pull/5954): Part of NET-341, provides a shared library to attempt retrieving update mode attestation.
- [Credential proxy deposit pool](https://github.com/nymtech/nym/pull/5945): Changes the credential proxy to hold a pool of available deposits and monitor quorum state, reducing wastage and improving reliability.
- [Nym signers monitor](https://github.com/nymtech/nym/pull/5933): Independent tool for checking the status of network signers and sending notifications if “upgrade” mode is detected.
- [Nym node autorun CLI](https://github.com/nymtech/nym/pull/5916): Adds an interactive CLI to install, set up, and configure `nym-node` as a systemd service, improving operator experience.
### Bugfix
- [Fix the registration handshake](https://github.com/nymtech/nym/pull/6062): Resolves issues in the registration handshake process.
- [Return from MixTrafficController if client request channel has closed](https://github.com/nymtech/nym/pull/6002): Ensures the MixTrafficController exits properly when the client request channel closes.
- [Use default value for the ports until api is deployed](https://github.com/nymtech/nym/pull/6007): Fixes VPN querying mainnet for the API and not finding some of the newly added values by using default port values until the API is deployed.
- [Recipient deserialisation for deserialisers missing bytes specialisation](https://github.com/nymtech/nym/pull/5991): Fixes deserialization where formats like TOML/JSON defaulted incorrectly, ignoring bytes-related optimisations.
- [Use WASM compatible time API in client](https://github.com/nymtech/nym/pull/5948): Prevents client crashes in WASM by replacing time APIs unavailable in that environment.
### Refactors & Maintenance
- [Convenience for ShutdownTracker](https://github.com/nymtech/nym/pull/6038): Adds a method to create a `ShutdownToken` from a `CancellationToken`. Exposes `ShutdownTracker` in the SDK.
- [Made http-api-client-macro doctest compile](https://github.com/nymtech/nym/pull/6037): Adjusts doctests in `http-api-client-macro` so they compile.
- [Remove legacy nodes from nym api](https://github.com/nymtech/nym/pull/6021): Removes nearly all references to legacy nodes from `nym-api` and node status API; cleans up deprecated endpoints and adjusts structs.
- [Upgraded syn to 2.0 and removed nym-execute](https://github.com/nymtech/nym/pull/5998): Updates the `syn` dependency and removes `nym-execute`.
- [Use updated version of simulate endpoint](https://github.com/nymtech/nym/pull/5988): Switches to the updated simulate endpoint.
- [Purge temp databases on build](https://github.com/nymtech/nym/pull/5984): Prevents build failures when switching to branches without DB migrations by cleaning temp databases during builds.
- [Internal hidden command to force advance nyx epoch](https://github.com/nymtech/nym/pull/5964): Adds a hidden developer command to advance the Nyx epoch manually.
- [Create an axum_test client for more integrated unit testing](https://github.com/nymtech/nym/pull/5956): Adds an `axum_test` client to support more integrated unit tests.
- [Revert "Create an axum_test client for more integrated unit testing"](https://github.com/nymtech/nym/pull/5999): Reverts PR #5956 due to OpenSSL dependency issues.
## `v2025.16-halloumi`
- **[`nym-node`](nodes/nym-node.mdx) is not part of the release, the latest stays on version `1.16.0`**
@@ -29,13 +29,14 @@ The commands in this setup need to be run with root permission. Either add a pre
## Reverse Proxy Setup
Operators running nodes facing open internet may benefit from having a landing page. This page serves as a source of useful information about Nym network and the node when they try to search node IP or hostname.
The following snippet needs be modified as described below according to the public identity that you may want to show on this public notice, i.e. your graphics and your email.
It would allow you to serve it as a landing page resembling the one proposed by [Tor](https://gitlab.torproject.org/tpo/core/tor/-/raw/HEAD/contrib/operator-tools/tor-exit-notice.html) but with all the changes needed to adhere to the Nym's operators case.
### HTML File Customization
File for html configuration are by convention located at `/var/www/<HOSTNAME>` directory and it's sub-directories. We refer to this directory as `<LANDING_PAGE_ASSETS_PATH>`.
File for html configuration are by convention located at `/var/www/<HOSTNAME>` directory and it's subdirectories. We refer to this directory as `<LANDING_PAGE_ASSETS_PATH>`.
<Steps>
@@ -46,21 +47,264 @@ mkdir -p /var/www/<HOSTNAME>
###### 2. Create html landing page
- Use your own html code (check this [markdown to html tool](https://markdowntohtml.com/)) or use [this template](https://raw.githubusercontent.com/nymtech/nym/refs/heads/develop/scripts/nym-node-setup/landing-page.html)
- Use your own html code (check this [markdown to html tool](https://markdowntohtml.com/)) or copy the template below to a new file called `index.html` located in `/var/www/<HOSTNAME>` directory.
- Copy the [template](https://raw.githubusercontent.com/nymtech/nym/refs/heads/develop/scripts/nym-node-setup/landing-page.html) a new file called `index.html` located in `/var/www/<HOSTNAME>` directory.
###### 3. If you used the template above - before you save and close the file, make sure to edit the email address:
<AccordionTemplate name={<IndexPage/>}>
```html
<!DOCTYPE html>
<html lang="en-US">
<head>
<meta charset="UTF-8">
<title>This is a NYM Exit Gateway</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/png" href="">
<style>
:root {
font-family: Consolas, "Ubuntu Mono", Menlo, "DejaVu Sans Mono", monospace;
}
:root{
--background-color: #121726;
--text-color: #f2f2f2;
--link-color: #fb6e4e;
}
html{
background: var(--background-color);
}
body{
margin-left: auto;
margin-right: auto;
padding-left: 5vw;
padding-right: 5vw;
max-width: 1000px;
}
h1{
font-size: 55px;
text-align: center;
color: var(--title-color)
}
p{
color: var(--text-color);
}
p, a{
font-size: 20px;
}
a{
color: var(--link-color);
text-decoration: none;
}
a:hover{
filter: brightness(.8);
text-decoration: underline;
}
.links{
display: flex;
flex-wrap: wrap;
justify-content: space-evenly;
}
.links > a{
margin: 10px;
white-space: nowrap;
}
</style>
- Change the email address you're willing to use for being contacted.
</head>
<body>
<main>
<h1>This is a NYM Exit Gateway</h1>
<p style="text-align:center">
<img class="logo" src="<FIXME>">
</p>
<p>
You are most likely accessing this website because you've had some issue with
the traffic coming from this IP. This router is part of the <a
href="https://nym.com/">NYM project</a>, which is
dedicated to <a href="https://nym.com/about/mission">create</a> outstanding
privacy software that is legally compliant without sacrificing integrity or
having any backdoors.
This router IP should be generating no other traffic, unless it has been
compromised.</p>
<p>
The Nym mixnet is operated by a decentralised community of node operators
and stakers. The Nym mixnet is trustless, meaning that no parts of the system
nor its operators have access to information that might compromise the privacy
of users. Nym software enacts a strict principle of data minimisation and has
no back doors. The Nym mixnet works by encrypting packets in several layers
and relaying those through a multi-layered network called a mixnet, eventually
letting the traffic exit the Nym mixnet through an exit gateway like this one.
This design makes it very hard for a service to know which user is connecting to it,
since it can only see the IP-address of the Nym exit gateway:</p>
<p style="text-align:center;margin:40px 0">
<svg xmlns="http://www.w3.org/2000/svg" width="500" viewBox="0 0 490.28 293.73" style="width:100%;max-width:600px">
<desc>Illustration showing how a user might connect to a service through the Nym network. The user first sends their data through three daisy-chained encrypted Nym nodes that exist on three different continents. Then the last Nym node in the chain connects to the target service over the normal internet.</desc>
<defs>
<style>
.t{
fill: var(--text-color);
stroke: var(--text-color);
}
</style>
</defs>
<path fill="#6fc8b7" d="M257.89 69.4c-6.61-6.36-10.62-7.73-18.36-8.62-7.97-1.83-20.06-7.99-24.17-.67-3.29 5.85-18.2 12.3-16.87 2.08.92-7.03 11.06-13.28 17-17.37 8.69-5.99 24.97-2.87 26.1-10.28 1.04-6.86-8.33-13.22-8.55-2.3-.38 12.84-19.62 2.24-8.73-6.2 8.92-6.9 16.05-9.02 25.61-6.15 12.37 4.83 25.58-2.05 33.73-.71 12.37-2.01 24.69-5.25 37.39-3.96 13 .43 24.08-.14 37.06.63 9.8 1.58 16.5 2.87 26.37 3.6 6.6.48 17.68-.82 24.3 1.9 8.3 4.24.44 10.94-6.89 11.8-8.79 1.05-23.59-1.19-26.6 1.86-5.8 7.41 10.75 5.68 11.27 14.54.57 9.45-5.42 9.38-8.72 16-2.7 4.2.3 13.93-1.18 18.45-1.85 5.64-19.64 4.47-14.7 14.4 4.16 8.34 1.17 19.14-10.33 12.02-5.88-3.65-9.85-22.04-15.66-21.9-11.06.27-11.37 13.18-12.7 17.52-1.3 4.27-3.79 2.33-6-.63-3.54-4.76-7.75-14.22-12.01-17.32-6.12-4.46-10.75-1.17-15.55 2.83-5.63 4.69-8.78 7.82-7.46 16.5.78 9.1-12.9 15.84-14.98 24.09-2.61 10.32-2.57 22.12-8.81 31.47-4 5.98-14.03 20.12-21.27 14.97-7.5-5.34-7.22-14.6-9.56-23.08-2.5-9.02.6-17.35-2.57-26.2-2.45-6.82-6.23-14.54-13.01-13.24-6.5.92-15.08 1.38-19.23-2.97-5.65-5.93-6-10.1-6.61-18.56 1.65-6.94 5.79-12.64 10.38-18.63 3.4-4.42 17.45-10.39 25.26-7.83 10.35 3.38 17.43 10.5 28.95 8.57 3.12-.53 9.14-4.65 7.1-6.62zm-145.6 37.27c-4.96-1.27-11.57 1.13-11.8 6.94-1.48 5.59-4.82 10.62-5.8 16.32.56 6.42 4.34 12.02 8.18 16.97 3.72 3.85 8.58 7.37 9.3 13.1 1.24 5.88 1.6 11.92 2.28 17.87.34 9.37.95 19.67 7.29 27.16 4.26 3.83 8.4-2.15 6.52-6.3-.54-4.54-.6-9.11 1.01-13.27 4.2-6.7 7.32-10.57 12.44-16.64 5.6-7.16 12.74-11.75 14-20.9.56-4.26 5.72-13.86 1.7-16.72-3.14-2.3-15.83-4-18.86-6.49-2.36-1.71-3.86-9.2-9.86-12.07-4.91-3.1-10.28-6.73-16.4-5.97zm11.16-49.42c6.13-2.93 10.58-4.77 14.61-10.25 3.5-4.28 2.46-12.62-2.59-15.45-7.27-3.22-13.08 5.78-18.81 8.71-5.96 4.2-12.07-5.48-6.44-10.6 5.53-4.13.38-9.2-5.66-8.48-6.12.8-12.48-1.45-18.6-1.73-5.3-.7-10.13-1-15.45-1.37-5.37-.05-16.51-2.23-25.13.87-5.42 1.79-12.5 5.3-16.73 9.06-4.85 4.2.2 7.56 5.54 7.45 5.3-.22 16.8-5.36 20.16.98 3.68 8.13-5.82 18.29-5.2 26.69.1 6.2 3.37 11 4.74 16.98 1.62 5.94 6.17 10.45 10 15.14 4.7 5.06 13.06 6.3 19.53 8.23 7.46.14 3.34-9.23 3.01-14.11 1.77-7.15 8.49-7.82 12.68-13.5 7.14-7.72 16.41-13.4 24.34-18.62zM190.88 3.1c-4.69 0-13.33.04-18.17-.34-7.65.12-13.1-.62-19.48-1.09-3.67.39-9.09 3.34-5.28 7.04 3.8.94 7.32 4.92 7.1 9.31 1.32 4.68 1.2 11.96 6.53 13.88 4.76-.2 7.12-7.6 11.93-8.25 6.85-2.05 12.5-4.58 17.87-9.09 2.48-2.76 7.94-6.38 5.26-10.33-1.55-1.31-2.18-.64-5.76-1.13zm178.81 157.37c-2.66 10.08-5.88 24.97 9.4 15.43 7.97-5.72 12.58-2.02 17.47 1.15.5.43 2.65 9.2 7.19 8.53 5.43-2.1 11.55-5.1 14.96-11.2 2.6-4.62 3.6-12.39 2.76-13.22-3.18-3.43-6.24-11.03-7.7-15.1-.76-2.14-2.24-2.6-2.74-.4-2.82 12.85-6.04 1.22-10.12-.05-8.2-1.67-29.62 7.17-31.22 14.86z"/>
<g fill="none">
<path stroke="#cf63a6" stroke-linecap="round" stroke-width="2.76" d="M135.2 140.58c61.4-3.82 115.95-118.83 151.45-103.33"/>
<path stroke="#cf63a6" stroke-linecap="round" stroke-width="2.76" d="M74.43 46.66c38.15 8.21 64.05 42.26 60.78 93.92M286.65 37.25c-9.6 39.44-3.57 57.12-35.64 91.98"/>
<path stroke="#e4c101" stroke-dasharray="9.06,2.265" stroke-width="2.27" d="M397.92 162.52c-31.38 1.26-90.89-53.54-148.3-36.17"/>
<path stroke="#cf63a6" stroke-linecap="round" stroke-width="2.77" d="M17.6 245.88c14.35 0 14.4.05 28-.03"/>
<path stroke="#e3bf01" stroke-dasharray="9.06,2.265" stroke-width="2.27" d="M46.26 274.14c-17.52-.12-16.68.08-30.34.07"/>
</g>
<g transform="translate(120.8 -35.81)">
<circle cx="509.78" cy="68.74" r="18.12" fill="#240a3b" transform="translate(-93.3 38.03) scale(.50637)"/>
<circle cx="440.95" cy="251.87" r="18.12" fill="#240a3b" transform="translate(-93.3 38.03) scale(.50637)"/>
<circle cx="212.62" cy="272.19" r="18.12" fill="#240a3b" transform="translate(-93.3 38.03) scale(.50637)"/>
<circle cx="92.12" cy="87.56" r="18.12" fill="#240a3b" transform="translate(-93.3 38.03) scale(.50637)"/>
<circle cx="730.88" cy="315.83" r="18.12" fill="#67727b" transform="translate(-93.3 38.03) scale(.50637)"/>
<circle cx="-102.85" cy="282.18" r="9.18" fill="#240a3b"/>
<circle cx="-102.85" cy="309.94" r="9.18" fill="#67727b"/>
</g>
<g class="t">
<text xml:space="preserve" x="-24.76" y="10.37" stroke-width=".26" font-size="16.93" font-weight="700" style="line-height:1.25" transform="translate(27.79 2.5)" word-spacing="0"><tspan x="-24.76" y="10.37">The user</tspan></text>
<text xml:space="preserve" x="150.63" y="196.62" stroke-width=".26" font-size="16.93" font-weight="700" style="line-height:1.25" transform="translate(27.79 2.5)" word-spacing="0"><tspan x="150.63" y="196.62">This server</tspan></text>
<text xml:space="preserve" x="346.39" y="202.63" stroke-width=".26" font-size="16.93" font-weight="700" style="line-height:1.25" transform="translate(27.79 2.5)" word-spacing="0"><tspan x="346.39" y="202.63">Your service</tspan></text>
<text xml:space="preserve" x="34.52" y="249.07" stroke-width=".26" font-size="16.93" font-weight="700" style="line-height:1.25" transform="translate(27.79 2.5)" word-spacing="0"><tspan x="34.52" y="249.07">Nym network link</tspan></text>
<text xml:space="preserve" x="34.13" y="276.05" stroke-width=".26" font-size="16.93" font-weight="700" style="line-height:1.25" transform="translate(27.79 2.5)" word-spacing="0"><tspan x="34.13" y="276.05">Unencrypted link</tspan></text>
<path fill="none" stroke-linecap="round" stroke-width="1.67" d="M222.6 184.1c-2.6-15.27 8.95-23.6 18.43-38.86m186.75 45.61c-.68-10.17-9.4-17.68-18.08-23.49"/>
<path fill="none" stroke-linecap="round" stroke-width="1.67" d="M240.99 153.41c.35-3.41 1.19-6.17.04-8.17m-7.15 5.48c1.83-2.8 4.58-4.45 7.15-5.48"/>
<path fill="none" stroke-linecap="round" stroke-width="1.67" d="M412.43 173.21c-2.2-3.15-2.54-3.85-2.73-5.85m0 0c2.46-.65 3.85.01 6.67 1.24M61.62 40.8C48.89 36.98 36.45 27.54 36.9 18.96M61.62 40.8c.05-2.58-3.58-4.8-5.25-5.26m-2.65 6.04c1.8.54 6.8 1.31 7.9-.78"/>
<path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="2.44" d="M1.22 229.4h247.74v63.1H1.22z"/>
</g>
</svg>
</p>
<p>
<a href="https://nym.com/about/mixnet">Read more about how Nym works.</a></p>
<p>
Nym relies on a growing ecosystem of users, developers and researcher partners
aligned with the mission to make sure Nym software is running, remains usable
and solves real problems. While Nym is not designed for malicious computer
users, it is true that they can use the network for malicious ends. This
is largely because criminals and hackers have significantly better access to
privacy and anonymity than do the regular users whom they prey upon. Criminals
can and do build, sell, and trade far larger and more powerful networks than
Nym on a daily basis. Thus, in the mind of this operator, the social need for
easily accessible censorship-resistant private, anonymous communication trumps
the risk of unskilled bad actors, who are almost always more easily uncovered
by traditional police work than by extensive monitoring and surveillance anyway.</p>
<p>
In terms of applicable law, the best way to understand Nym is to consider it a
network of routers operating as common carriers, much like the Internet
backbone. However, unlike the Internet backbone routers, Nym mixnodes do not
contain identifiable routing information about the source of a packet and do
mix the user internet traffic with that of other users, making communications
private and protecting not just the user content but the metadata
(user's IP address, who the user talks to, when, where, from what device and
more) and no single Nym node can determine both the origin and destination
of a given transmission.</p>
<p>
As such, there is little the operator of this Exit Gateway can do to help you
track the connection further. This Exit Gateway maintains no logs of any of the
Nym mixnet traffic, so there is little that can be done to trace either legitimate or
illegitimate traffic (or to filter one from the other). Attempts to
seize this router will accomplish nothing.</p>
<!-- FIXME: US-Only section. Remove if you are a non-US operator -->
<!--
<p>
Furthermore, this machine also serves as a carrier of email, which means that
its contents are further protected under the ECPA. <a
href="https://www.law.cornell.edu/uscode/text/18/2707">18
USC 2707</a> explicitly allows for civil remedies ($1000/account
<i>plus</i> legal fees)
in the event of a seizure executed without good faith or probable cause (it
should be clear at this point that traffic with an originating IP address of
FIXME_DNS_NAME should not constitute probable cause to seize the
machine). Similar considerations exist for 1st amendment content on this
machine.</p>
-->
<!-- FIXME: May or may not be US-only. Some non-US tor nodes have in
fact reported DMCA harassment... -->
<!--
<p>
If you are a representative of a company who feels that this router is being
used to violate the DMCA, please be aware that this machine does not host or
contain any illegal content. Also be aware that network infrastructure
maintainers are not liable for the type of content that passes over their
equipment, in accordance with <a
href="https://www.law.cornell.edu/uscode/text/17/512">DMCA
"safe harbor" provisions</a>. In other words, you will have just as much luck
sending a takedown notice to the Internet backbone providers.
</p>
-->
<p>To decentralise and enable privacy for a broad range of services, this
Exit Gateway adopts an <a href="https://nymtech.net/.wellknown/network-requester/exit-policy.txt">Exit Policy</a>
in accordance with the <a href="https://tornull.org/">Tor Null deny list</a>
and the <a href="https://tornull.org/tor-reduced-reduced-exit-policy.php">Tor reduced policy</a>,
which are two established safeguards.
</p>
<p>
That being said, if you still have a complaint about the router, you may email the
<a href="mailto:>YOUR_EMAIL_ADDRESS>">maintainer</a>. If complaints are related
to a particular service that is being abused, the maintainer will submit that to the
NYM Operators Community in order to add it to the Exit Policy cited above.
If approved, that would prevent this router from allowing that traffic to exit through it.
That can be done only on an IP+destination port basis, however. Common P2P ports are already blocked.</p>
<p>
You also have the option of blocking this IP address and others on the Nym network if you so desire.
The Nym project provides a <a href="https://nym.com/explorer">
web service</a> to fetch a list of all IP addresses of Nym Gateway Exit nodes that allow exiting to a
specified IP:port combination. Please be considerate when using these options.</p>
</main>
</body>
</html>
```
</AccordionTemplate>
###### 3. If you used the template above - before you save and close the file, make sure to edit the text, especially the information in these points:
- Add your own favicon logo on the line:
```html
<link rel="icon" type="image/png" href="">
```
- Add your header logo on the line:
```html
<img class="logo" src="<FIXME>">
```
- By either setting the URl to the image (if you're hosting it publicly, i.e. on your web server)
```html
href="<PATH_TO_YOUR_PUBLIC_URL>"
# and
src="<PATH_TO_YOUR_PUBLIC_URL>"
```
- **or** by adding the image inline as base64 encoded image
```html
href="href="data:image/x-icon;base64,AAABAAMA....""
# and
src="href="data:image/x-icon;base64,AAABAAMA....""
```
- Add the email address you're willing to use for being contacted.
```
<a href="mailto:><YOUR_EMAIL_ADDRESS>">maintainer</a>
```
- Additionally you can add your own favicon logo on the line:
```html
<link rel="icon" type="YOUR_FAVICON_IMAGE_PATH" href="">
```
- If you're running the node within the US check the sections marked as `FIXME`, add your DNS name and un-comment those.
###### 4. Save and exit
@@ -20,12 +20,12 @@ This documentation page provides a guide on how to set up and run a [NYM NODE](.
```sh
nym-node
Binary Name: nym-node
Build Timestamp: 2025-10-01T10:42:58.647419869Z
Build Version: 1.18.0
Commit SHA: bbea2ff9e913f49cb7bf6c7bafa9d9b158c80de5
Commit Date: 2025-10-01T12:06:07.000000000+02:00
Build Timestamp: 2025-08-05T09:14:30.322593213Z
Build Version: 1.16.0
Commit SHA: 7f97f13799342f864e1b106e8cafc9f6d6c24c0f
Commit Date: 2025-07-24T11:00:58.000000000+01:00
Commit Branch: HEAD
rustc Version: 1.88.0
rustc Version: 1.86.0
rustc Channel: stable
cargo Profile: release
```
@@ -87,6 +87,11 @@ ufw status
</Tabs>
</div>
- In case of reverse proxy setup add:
```sh
ufw allow 443/tcp
```
- Re-check the status of the firewall:
```sh
ufw status
@@ -1,156 +0,0 @@
import { Callout } from 'nextra/components';
import { Tabs } from 'nextra/components';
import { MyTab } from 'components/generic-tabs.tsx';
import { RunTabs } from 'components/operators/nodes/node-run-command-tabs';
import { VarInfo } from 'components/variable-info.tsx';
import { AccordionTemplate } from 'components/accordion-template.tsx';
import { Steps } from 'nextra/components';
# Tools
On this page you can find tools to [setup a node automatically](#nym-node-cli), [explorers](#explorers) and other useful dashboards and [scripts](#cmd-reward-tracker).
<VarInfo />
## Explorers
Nym Network stats can be humanly read on some of the explorers and dashboards.
- **[Nym Explorer v2](https://nym.com/explorer):** Official Nym Explorer
- **[SpectreDAO Explorer](https://explorer.nym.spectredao.net/):** By operators for operators - currently the most used Nym explorer
- **[Nymesis](https://nymesis.vercel.app/):** A slick dashboard by operator community
- **[Nym Harbourmaster](https://harbourmaster.nymtech.net/):** A dashboard showing results of Gateway probes and more (*in development*)
## Nym Node CLI
This interactive command-line-based tool takes an operator through a journey of installing, configuring and starting a `nym-node` as a systemd service, doing most of the steps automatically for them.
**Installation & Running**
<Steps>
###### 1. SSH into your server (VPS)
- The installation of `nym-node` using this program requires you to run as `root`
###### 2. Download `nym-node-cli.py` and make executable
```sh
wget https://raw.githubusercontent.com/nymtech/nym/refs/heads/develop/scripts/nym-node-setup/nym-node-cli.py && \
chmod +x ./nym-node-cli.py
```
###### 3. Run the program
```
./nym-node-cli.py
```
###### 4. Read and follow the prompts
</ Steps>
## CMD Reward Tracker
A command-line-based program locally calculating nodes rewards based on provided Nyx account addresses in `data/wallet-addresses.csv`.
**Installation & Running**
<Steps>
###### 1. Pull / clone `nymtech/nym` repository
- Open terminal and navigate to where you want to have `nym` repostiry and run:
```sh
git clone https://github.com/nymtech/nym
```
###### 2. Add your Nyx accounts to `wallet-addresses.csv`
- Navigate to `nym/scripts/rewards-tracker/data`
- Open `wallet-addresses.csv` in your favourite text editor or a sheet managing tool (Like Libre Office Calc)
- To the first collumn called `address` add all Nyx addresses you want to track
- Delete all `add_wallet_or_delete` template examples
###### 3. Add entity to `wallet-addresses.csv` - optional
- In the same file operators who want to separate their nodes by an entity, can add this entity to the `tag` column
- If not leave this column empty - delete all `optional_tag_or_delete` fields
- Csv example with `tag`s:
```
address, tag
n1foofoofoo, personal
n1barbarbar, personal
n1bazbazbaz, mysquad
n1lollollol, mysquad
```
- For operators having all nodes under one entity, the tag field will be left empty. Example:
```csv
address, tag
n1foofoofoo
n1barbarbar
n1bazbazbaz
```
###### 4. Save `wallet-addresses.csv` and exit
###### 5. Run the program
- In terminal navigate to `nym/scripts/rewards-tracker`
- Run the program:
```
./node_rewards_tracker.py
```
</ Steps>
**The Output**
The result of running `node_rewards_tracker.py` is:
1. Printed table in terminal
2. Updated sheet with complete info stored in `data/node-balances.csv`
3. Historical data file stored in `data/data.yaml` - this file should not be changed manually, as all values older than 30 days get auto-removed
## Node Ping Tester
This tool is used to diagnose how many nodes providing self-described endpoint allow your IP to ping them. It's a very simple script fetching all [`/described`](https://validator.nymtech.net/api/v1/nym-nodes/described) nodes and trying to ping each of them.
The output is collected into two files:
```
├── ping_not_working.csv
└── ping_works.csv
```
**Installation & Running**
<Steps>
###### 1. SSH into your node server (VPS)
###### 2. Download and make executable
- Navigate to the directory where you want to have this script
- Download and make executable:
```sh
wget https://raw.githubusercontent.com/nymtech/nym/refs/heads/develop/scripts/test-nodes-pings.sh && \
chmod +x test-nodes-pings.sh
```
###### 3. Run the script
- Default running command is straight forward
```sh
./test-nodes-pings.sh
```
- If you want to increase the `ping` attempts from default 2 or lower the concurency, feel free to change the variables, like this:
```sh
PING_RETRIES=10 PING_TIMEOUT=5 CONCURRENCY=16 ./test-nodes-pings.sh
```
</Steps>
You can look up the IPs from `ping_not_working.csv`, using some online database, like [ipinfo.io](https://ipinfo.io).
Feel invited to share the outcome with Nym team, mentors and the rest of the operators in our [Matrix Node Operators channel](https://matrix.to/#/#operators:nymtech.chat).
@@ -67,8 +67,7 @@ pub struct PaginatedCachedNodesExpandedResponseSchema {
/// Return all Nym Nodes and optionally legacy mixnodes/gateways (if `no-legacy` flag is not used)
/// that are currently bonded.
#[utoipa::path(
operation_id = "v2_nodes_expanded",
tag = "Unstable Nym Nodes v2",
tag = "Unstable Nym Nodes",
get,
params(NodesParamsWithRole),
path = "",
@@ -15,8 +15,7 @@ use nym_api_requests::nym_nodes::NodeRoleQueryParam;
/// Return all Nym Nodes and optionally legacy mixnodes/gateways (if `no-legacy` flag is not used)
/// that are currently bonded.
#[utoipa::path(
operation_id = "v2_nodes_basic_all",
tag = "Unstable Nym Nodes v2",
tag = "Unstable Nym Nodes",
get,
params(NodesParamsWithRole),
path = "",
@@ -53,8 +52,7 @@ pub(crate) async fn nodes_basic_all(
/// Returns Nym Nodes and optionally legacy mixnodes (if `no-legacy` flag is not used)
/// that are currently bonded and support mixing role.
#[utoipa::path(
operation_id = "v2_mixnodes_basic_all",
tag = "Unstable Nym Nodes v2",
tag = "Unstable Nym Nodes",
get,
params(NodesParams),
path = "/mixnodes/all",
@@ -77,8 +75,7 @@ pub(crate) async fn mixnodes_basic_all(
/// Returns Nym Nodes and optionally legacy mixnodes (if `no-legacy` flag is not used)
/// that are currently bonded and are in the active set with one of the mixing roles.
#[utoipa::path(
operation_id = "v2_mixnodes_basic_active",
tag = "Unstable Nym Nodes v2",
tag = "Unstable Nym Nodes",
get,
params(NodesParams),
path = "/mixnodes/active",
@@ -101,8 +98,7 @@ pub(crate) async fn mixnodes_basic_active(
/// Returns Nym Nodes and optionally legacy gateways (if `no-legacy` flag is not used)
/// that are currently bonded and support entry gateway role.
#[utoipa::path(
operation_id = "v2_entry_gateways_basic_all",
tag = "Unstable Nym Nodes v2",
tag = "Unstable Nym Nodes",
get,
params(NodesParams),
path = "/entry-gateways",
@@ -125,8 +121,7 @@ pub(crate) async fn entry_gateways_basic_all(
/// Returns Nym Nodes and optionally legacy gateways (if `no-legacy` flag is not used)
/// that are currently bonded and support exit gateway role.
#[utoipa::path(
operation_id = "v2_exit_gateways_basic_all",
tag = "Unstable Nym Nodes v2",
tag = "Unstable Nym Nodes",
get,
params(NodesParams),
path = "/exit-gateways",
+2
View File
@@ -14,6 +14,7 @@ workspace = true
[dependencies]
bincode.workspace = true
futures.workspace = true
rand.workspace = true
semver.workspace = true
thiserror.workspace = true
tokio-util.workspace = true
@@ -24,6 +25,7 @@ nym-authenticator-requests = { path = "../common/authenticator-requests" }
nym-bandwidth-controller = { path = "../common/bandwidth-controller" }
nym-credentials-interface = { path = "../common/credentials-interface" }
nym-crypto = { path = "../common/crypto" }
nym-pemstore = { path = "../common/pemstore" }
nym-registration-common = { path = "../common/registration" }
nym-sdk = { path = "../sdk/rust/nym-sdk" }
nym-service-provider-requests-common = { path = "../common/service-provider-requests-common" }
+23
View File
@@ -1,7 +1,10 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use nym_crypto::asymmetric::x25519::KeyPair;
use nym_pemstore::KeyPairPath;
use nym_sdk::mixnet::{IncludedSurbs, Recipient, TransmissionLane};
use rand::{CryptoRng, RngCore};
pub(crate) fn create_input_message(
recipient: Recipient,
@@ -24,3 +27,23 @@ pub(crate) fn create_input_message(
),
}
}
pub(crate) fn load_or_generate_keypair<R: RngCore + CryptoRng>(
rng: &mut R,
paths: KeyPairPath,
) -> KeyPair {
match nym_pemstore::load_keypair(&paths) {
Ok(keypair) => keypair,
Err(_) => {
let keypair = KeyPair::new(rng);
if let Err(e) = nym_pemstore::store_keypair(&keypair, &paths) {
tracing::error!(
"could not store generated keypair at {:?} - {:?}; will use ephemeral keys",
paths,
e
);
}
keypair
}
}
}
+224
View File
@@ -0,0 +1,224 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use std::time::Duration;
use tracing::{debug, error};
use crate::mixnet_listener::{MixnetMessageBroadcastReceiver, MixnetMessageInputSender};
use crate::{helpers, ClientMessage, Error, Result};
use nym_authenticator_requests::{
client_message::QueryMessageImpl, response::AuthenticatorResponse, traits::Id, v2, v3, v4, v5,
AuthenticatorVersion,
};
use nym_credentials_interface::CredentialSpendingData;
use nym_crypto::asymmetric::x25519::{KeyPair, PublicKey};
use nym_sdk::mixnet::{IncludedSurbs, Recipient};
use nym_service_provider_requests_common::{Protocol, ServiceProviderTypeExt};
use nym_wireguard_types::PeerPublicKey;
impl crate::AuthenticatorClient {
pub fn into_legacy_and_keypair(self) -> (LegacyAuthenticatorClient, KeyPair) {
(
LegacyAuthenticatorClient {
public_key: *self.keypair.public_key(),
mixnet_listener: self.mixnet_listener,
mixnet_sender: self.mixnet_sender,
our_nym_address: self.our_nym_address,
auth_recipient: self.auth_recipient,
auth_version: self.auth_version,
},
self.keypair,
)
}
}
// This is the legacy Authenticator that has to be used to handle bandwidth top up for legacy gateaways
pub struct LegacyAuthenticatorClient {
public_key: PublicKey,
mixnet_listener: MixnetMessageBroadcastReceiver,
mixnet_sender: MixnetMessageInputSender,
our_nym_address: Recipient,
pub auth_recipient: Recipient,
auth_version: AuthenticatorVersion,
}
impl LegacyAuthenticatorClient {
pub async fn send_and_wait_for_response(
&mut self,
message: &ClientMessage,
) -> Result<AuthenticatorResponse> {
let request_id = self.send_request(message).await?;
debug!("Waiting for reply...");
self.listen_for_response(request_id).await
}
async fn send_request(&self, message: &ClientMessage) -> Result<u64> {
let (data, request_id) = message.bytes(self.our_nym_address)?;
// We use 20 surbs for the connect request because typically the
// authenticator mixnet client on the nym-node is configured to have a min
// threshold of 10 surbs that it reserves for itself to request additional
// surbs.
let surbs = if message.use_surbs() {
match &message {
ClientMessage::Initial(_) => IncludedSurbs::new(20),
_ => IncludedSurbs::new(1),
}
} else {
IncludedSurbs::ExposeSelfAddress
};
let input_message = helpers::create_input_message(self.auth_recipient, data, surbs);
self.mixnet_sender
.send(input_message)
.await
.map_err(|e| Error::SendMixnetMessage(Box::new(e)))?;
Ok(request_id)
}
async fn listen_for_response(&mut self, request_id: u64) -> Result<AuthenticatorResponse> {
let timeout = tokio::time::sleep(Duration::from_secs(10));
tokio::pin!(timeout);
loop {
tokio::select! {
_ = &mut timeout => {
error!("Timed out waiting for reply to connect request");
return Err(Error::TimeoutWaitingForConnectResponse);
}
msg = self.mixnet_listener.recv() => match msg {
Err(_) => {
return Err(Error::NoMixnetMessagesReceived);
}
Ok(msg) => {
let Some(header) = msg.message.first_chunk::<2>() else {
debug!("received too short message that couldn't have been from the authenticator while waiting for connect response");
continue;
};
let Ok(protocol) = Protocol::try_from(header) else {
debug!("received a message not meant to any service provider while waiting for connect response");
continue;
};
if !protocol.service_provider_type.is_authenticator() {
debug!("Received non-authenticator message while waiting for connect response");
continue;
}
// Confirm that the version is correct
let version = AuthenticatorVersion::from(protocol.version);
// Then we deserialize the message
debug!("AuthClient: got message while waiting for connect response with version {version:?}");
let ret: Result<AuthenticatorResponse> = match version {
AuthenticatorVersion::V1 => Err(Error::UnsupportedVersion),
AuthenticatorVersion::V2 => v2::response::AuthenticatorResponse::from_reconstructed_message(&msg).map(Into::into).map_err(Into::into),
AuthenticatorVersion::V3 => v3::response::AuthenticatorResponse::from_reconstructed_message(&msg).map(Into::into).map_err(Into::into),
AuthenticatorVersion::V4 => v4::response::AuthenticatorResponse::from_reconstructed_message(&msg).map(Into::into).map_err(Into::into),
AuthenticatorVersion::V5 => v5::response::AuthenticatorResponse::from_reconstructed_message(&msg).map(Into::into).map_err(Into::into),
AuthenticatorVersion::UNKNOWN => Err(Error::UnknownVersion),
};
let Ok(response) = ret else {
// This is ok, it's likely just one of our self-pings
debug!("Failed to deserialize reconstructed message");
continue;
};
if response.id() == request_id {
debug!("Got response with matching id");
return Ok(response);
}
}
}
}
}
}
pub async fn query_bandwidth(&mut self) -> Result<Option<i64>> {
let query_message = match self.auth_version {
AuthenticatorVersion::V1 => return Err(Error::UnsupportedAuthenticatorVersion),
AuthenticatorVersion::V2 => ClientMessage::Query(Box::new(QueryMessageImpl {
pub_key: PeerPublicKey::new(self.public_key.to_bytes().into()),
version: AuthenticatorVersion::V2,
})),
AuthenticatorVersion::V3 => ClientMessage::Query(Box::new(QueryMessageImpl {
pub_key: PeerPublicKey::new(self.public_key.to_bytes().into()),
version: AuthenticatorVersion::V3,
})),
AuthenticatorVersion::V4 => ClientMessage::Query(Box::new(QueryMessageImpl {
pub_key: PeerPublicKey::new(self.public_key.to_bytes().into()),
version: AuthenticatorVersion::V4,
})),
AuthenticatorVersion::V5 => ClientMessage::Query(Box::new(QueryMessageImpl {
pub_key: PeerPublicKey::new(self.public_key.to_bytes().into()),
version: AuthenticatorVersion::V5,
})),
AuthenticatorVersion::UNKNOWN => return Err(Error::UnsupportedAuthenticatorVersion),
};
let response = self.send_and_wait_for_response(&query_message).await?;
let available_bandwidth = match response {
AuthenticatorResponse::RemainingBandwidth(remaining_bandwidth_response) => {
if let Some(available_bandwidth) =
remaining_bandwidth_response.available_bandwidth()
{
available_bandwidth
} else {
return Ok(None);
}
}
_ => return Err(Error::InvalidGatewayAuthResponse),
};
let remaining_pretty = if available_bandwidth > 1024 * 1024 {
format!("{:.2} MB", available_bandwidth as f64 / 1024.0 / 1024.0)
} else {
format!("{} KB", available_bandwidth / 1024)
};
tracing::debug!(
"Remaining wireguard bandwidth with gateway {} for today: {}",
self.auth_recipient.gateway(),
remaining_pretty
);
if available_bandwidth < 1024 * 1024 {
tracing::warn!(
"Remaining bandwidth is under 1 MB. The wireguard mode will get suspended after that until tomorrow, UTC time. The client might shutdown with timeout soon"
);
}
Ok(Some(available_bandwidth))
}
pub async fn top_up(&mut self, credential: CredentialSpendingData) -> Result<i64> {
let top_up_message = match self.auth_version {
AuthenticatorVersion::V3 => ClientMessage::TopUp(Box::new(v3::topup::TopUpMessage {
pub_key: PeerPublicKey::new(self.public_key.to_bytes().into()),
credential,
})),
// NOTE: looks like a bug here using v3. But we're leaving it as is since it's working
// and V4 is deprecated in favour of V5
AuthenticatorVersion::V4 => ClientMessage::TopUp(Box::new(v4::topup::TopUpMessage {
pub_key: PeerPublicKey::new(self.public_key.to_bytes().into()),
credential,
})),
AuthenticatorVersion::V5 => ClientMessage::TopUp(Box::new(v5::topup::TopUpMessage {
pub_key: PeerPublicKey::new(self.public_key.to_bytes().into()),
credential,
})),
AuthenticatorVersion::V1 | AuthenticatorVersion::V2 | AuthenticatorVersion::UNKNOWN => {
return Err(Error::UnsupportedAuthenticatorVersion);
}
};
let response = self.send_and_wait_for_response(&top_up_message).await?;
let remaining_bandwidth = match response {
AuthenticatorResponse::TopUpBandwidth(top_up_bandwidth_response) => {
top_up_bandwidth_response.available_bandwidth()
}
_ => return Err(Error::InvalidGatewayAuthResponse),
};
Ok(remaining_bandwidth)
}
}
+72 -95
View File
@@ -1,13 +1,16 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use nym_authenticator_requests::client_message::QueryMessageImpl;
use nym_bandwidth_controller::{BandwidthTicketProvider, DEFAULT_TICKETS_TO_SPEND};
use nym_crypto::asymmetric::x25519::KeyPair;
use nym_registration_common::GatewayData;
use nym_registration_common::{
GatewayData, DEFAULT_PRIVATE_ENTRY_WIREGUARD_KEY_FILENAME,
DEFAULT_PRIVATE_EXIT_WIREGUARD_KEY_FILENAME, DEFAULT_PUBLIC_ENTRY_WIREGUARD_KEY_FILENAME,
DEFAULT_PUBLIC_EXIT_WIREGUARD_KEY_FILENAME,
};
use rand::rngs::OsRng;
use std::net::{IpAddr, SocketAddr};
use std::path::PathBuf;
use std::str::FromStr;
use std::sync::Arc;
use std::time::Duration;
use tracing::{debug, error, trace};
@@ -16,16 +19,20 @@ use nym_authenticator_requests::{
client_message::ClientMessage, response::AuthenticatorResponse, traits::Id, v2, v3, v4, v5,
AuthenticatorVersion,
};
use nym_credentials_interface::{CredentialSpendingData, TicketType};
use nym_credentials_interface::TicketType;
use nym_crypto::asymmetric::x25519::KeyPair;
use nym_pemstore::KeyPairPath;
use nym_sdk::mixnet::{IncludedSurbs, Recipient};
use nym_service_provider_requests_common::{Protocol, ServiceProviderTypeExt};
use nym_wireguard_types::PeerPublicKey;
mod error;
mod helpers;
mod legacy;
mod mixnet_listener;
pub use crate::error::{Error, Result};
pub use crate::legacy::LegacyAuthenticatorClient;
pub use crate::mixnet_listener::{AuthClientMixnetListener, AuthClientMixnetListenerHandle};
pub struct AuthenticatorClient {
@@ -35,21 +42,34 @@ pub struct AuthenticatorClient {
pub auth_recipient: Recipient,
auth_version: AuthenticatorVersion,
keypair: Arc<KeyPair>,
keypair: KeyPair,
ip_addr: IpAddr,
}
impl AuthenticatorClient {
#[allow(clippy::too_many_arguments)]
pub fn new(
fn new_type(
data_path: &Option<PathBuf>,
mixnet_listener: MixnetMessageBroadcastReceiver,
mixnet_sender: MixnetMessageInputSender,
our_nym_address: Recipient,
auth_recipient: Recipient,
auth_version: AuthenticatorVersion,
keypair: Arc<KeyPair>,
private_file_name: &str,
public_file_name: &str,
ip_addr: IpAddr,
) -> Self {
let mut rng = OsRng;
let keypair = if let Some(data_path) = data_path {
let paths = KeyPairPath::new(
data_path.join(private_file_name),
data_path.join(public_file_name),
);
helpers::load_or_generate_keypair(&mut rng, paths)
} else {
KeyPair::new(&mut rng)
};
Self {
mixnet_listener,
mixnet_sender,
@@ -61,6 +81,50 @@ impl AuthenticatorClient {
}
}
pub fn new_entry(
data_path: &Option<PathBuf>,
mixnet_listener: MixnetMessageBroadcastReceiver,
mixnet_sender: MixnetMessageInputSender,
our_nym_address: Recipient,
auth_recipient: Recipient,
auth_version: AuthenticatorVersion,
ip_addr: IpAddr,
) -> Self {
Self::new_type(
data_path,
mixnet_listener,
mixnet_sender,
our_nym_address,
auth_recipient,
auth_version,
DEFAULT_PRIVATE_ENTRY_WIREGUARD_KEY_FILENAME,
DEFAULT_PUBLIC_ENTRY_WIREGUARD_KEY_FILENAME,
ip_addr,
)
}
pub fn new_exit(
data_path: &Option<PathBuf>,
mixnet_listener: MixnetMessageBroadcastReceiver,
mixnet_sender: MixnetMessageInputSender,
our_nym_address: Recipient,
auth_recipient: Recipient,
auth_version: AuthenticatorVersion,
ip_addr: IpAddr,
) -> Self {
Self::new_type(
data_path,
mixnet_listener,
mixnet_sender,
our_nym_address,
auth_recipient,
auth_version,
DEFAULT_PRIVATE_EXIT_WIREGUARD_KEY_FILENAME,
DEFAULT_PUBLIC_EXIT_WIREGUARD_KEY_FILENAME,
ip_addr,
)
}
pub async fn send_and_wait_for_response(
&mut self,
message: &ClientMessage,
@@ -301,91 +365,4 @@ impl AuthenticatorClient {
Ok(gateway_data)
}
pub async fn query_bandwidth(&mut self) -> Result<Option<i64>> {
let query_message = match self.auth_version {
AuthenticatorVersion::V1 => return Err(Error::UnsupportedAuthenticatorVersion),
AuthenticatorVersion::V2 => ClientMessage::Query(Box::new(QueryMessageImpl {
pub_key: PeerPublicKey::new(self.keypair.public_key().to_bytes().into()),
version: AuthenticatorVersion::V2,
})),
AuthenticatorVersion::V3 => ClientMessage::Query(Box::new(QueryMessageImpl {
pub_key: PeerPublicKey::new(self.keypair.public_key().to_bytes().into()),
version: AuthenticatorVersion::V3,
})),
AuthenticatorVersion::V4 => ClientMessage::Query(Box::new(QueryMessageImpl {
pub_key: PeerPublicKey::new(self.keypair.public_key().to_bytes().into()),
version: AuthenticatorVersion::V4,
})),
AuthenticatorVersion::V5 => ClientMessage::Query(Box::new(QueryMessageImpl {
pub_key: PeerPublicKey::new(self.keypair.public_key().to_bytes().into()),
version: AuthenticatorVersion::V5,
})),
AuthenticatorVersion::UNKNOWN => return Err(Error::UnsupportedAuthenticatorVersion),
};
let response = self.send_and_wait_for_response(&query_message).await?;
let available_bandwidth = match response {
AuthenticatorResponse::RemainingBandwidth(remaining_bandwidth_response) => {
if let Some(available_bandwidth) =
remaining_bandwidth_response.available_bandwidth()
{
available_bandwidth
} else {
return Ok(None);
}
}
_ => return Err(Error::InvalidGatewayAuthResponse),
};
let remaining_pretty = if available_bandwidth > 1024 * 1024 {
format!("{:.2} MB", available_bandwidth as f64 / 1024.0 / 1024.0)
} else {
format!("{} KB", available_bandwidth / 1024)
};
tracing::debug!(
"Remaining wireguard bandwidth with gateway {} for today: {}",
self.auth_recipient.gateway(),
remaining_pretty
);
if available_bandwidth < 1024 * 1024 {
tracing::warn!(
"Remaining bandwidth is under 1 MB. The wireguard mode will get suspended after that until tomorrow, UTC time. The client might shutdown with timeout soon
"
);
}
Ok(Some(available_bandwidth))
}
pub async fn top_up(&mut self, credential: CredentialSpendingData) -> Result<i64> {
let top_up_message = match self.auth_version {
AuthenticatorVersion::V3 => ClientMessage::TopUp(Box::new(v3::topup::TopUpMessage {
pub_key: PeerPublicKey::new(self.keypair.public_key().to_bytes().into()),
credential,
})),
// NOTE: looks like a bug here using v3. But we're leaving it as is since it's working
// and V4 is deprecated in favour of V5
AuthenticatorVersion::V4 => ClientMessage::TopUp(Box::new(v4::topup::TopUpMessage {
pub_key: PeerPublicKey::new(self.keypair.public_key().to_bytes().into()),
credential,
})),
AuthenticatorVersion::V5 => ClientMessage::TopUp(Box::new(v5::topup::TopUpMessage {
pub_key: PeerPublicKey::new(self.keypair.public_key().to_bytes().into()),
credential,
})),
AuthenticatorVersion::V1 | AuthenticatorVersion::V2 | AuthenticatorVersion::UNKNOWN => {
return Err(Error::UnsupportedAuthenticatorVersion);
}
};
let response = self.send_and_wait_for_response(&top_up_message).await?;
let remaining_bandwidth = match response {
AuthenticatorResponse::TopUpBandwidth(top_up_bandwidth_response) => {
top_up_bandwidth_response.available_bandwidth()
}
_ => return Err(Error::InvalidGatewayAuthResponse),
};
Ok(remaining_bandwidth)
}
}
@@ -1,6 +1,6 @@
[package]
name = "nym-credential-proxy"
version = "0.3.0"
version = "0.2.0"
authors.workspace = true
repository.workspace = true
homepage.workspace = true
+1
View File
@@ -12,6 +12,7 @@ license.workspace = true
workspace = true
[dependencies]
futures.workspace = true
thiserror.workspace = true
tokio.workspace = true
tokio-util.workspace = true
+7 -15
View File
@@ -5,31 +5,24 @@ use nym_credential_storage::persistent_storage::PersistentStorage;
use nym_registration_common::NymNode;
use nym_sdk::{
mixnet::{
x25519::KeyPair, CredentialStorage, GatewaysDetailsStore, KeyStore, MixnetClient,
MixnetClientBuilder, MixnetClientStorage, OnDiskPersistent, ReplyStorageBackend,
StoragePaths,
CredentialStorage, GatewaysDetailsStore, KeyStore, MixnetClient, MixnetClientBuilder,
MixnetClientStorage, OnDiskPersistent, ReplyStorageBackend, StoragePaths,
},
DebugConfig, NymNetworkDetails, RememberMe, TopologyProvider, UserAgent,
};
#[cfg(unix)]
use std::os::fd::RawFd;
use std::{path::PathBuf, sync::Arc, time::Duration};
use std::{os::fd::RawFd, sync::Arc};
use std::{path::PathBuf, time::Duration};
use tokio_util::sync::CancellationToken;
use crate::error::RegistrationClientError;
const VPN_AVERAGE_PACKET_DELAY: Duration = Duration::from_millis(15);
#[derive(Clone)]
pub struct NymNodeWithKeys {
pub node: NymNode,
pub keys: Arc<KeyPair>,
}
pub struct BuilderConfig {
pub entry_node: NymNodeWithKeys,
pub exit_node: NymNodeWithKeys,
pub entry_node: NymNode,
pub exit_node: NymNode,
pub data_path: Option<PathBuf>,
pub mixnet_client_config: MixnetClientConfig,
pub two_hops: bool,
@@ -111,13 +104,12 @@ impl BuilderConfig {
let builder = builder
.with_user_agent(self.user_agent)
.request_gateway(self.entry_node.node.identity.to_string())
.request_gateway(self.entry_node.identity.to_string())
.network_details(self.network_env)
.debug_config(debug_config)
.credentials_mode(true)
.with_remember_me(remember_me)
.custom_topology_provider(self.custom_topology_provider);
#[cfg(unix)]
let builder = builder.with_connection_fd_callback(self.connection_fd_callback);
+10 -5
View File
@@ -1,10 +1,11 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use futures::channel::mpsc;
use nym_bandwidth_controller::{BandwidthController, BandwidthTicketProvider};
use nym_credential_storage::ephemeral_storage::EphemeralCredentialStorage;
use nym_sdk::{
mixnet::{MixnetClient, MixnetClientBuilder},
mixnet::{EventSender, MixnetClient, MixnetClientBuilder},
NymNetworkDetails,
};
use nym_validator_client::{
@@ -32,11 +33,13 @@ impl RegistrationClientBuilder {
pub async fn build(self) -> Result<RegistrationClient, RegistrationClientError> {
let storage = self.config.setup_storage().await?;
let config = RegistrationClientConfig {
entry: self.config.entry_node.clone(),
exit: self.config.exit_node.clone(),
entry: self.config.entry_node,
exit: self.config.exit_node,
two_hops: self.config.two_hops,
data_path: self.config.data_path.clone(),
};
let cancel_token = self.config.cancel_token.clone();
let (event_tx, event_rx) = mpsc::unbounded();
let nyxd_client = get_nyxd_client(&self.config.network_env)?;
@@ -44,7 +47,8 @@ impl RegistrationClientBuilder {
MixnetClient,
Box<dyn BandwidthTicketProvider>,
) = if let Some((mixnet_client_storage, credential_storage)) = storage {
let builder = MixnetClientBuilder::new_with_storage(mixnet_client_storage);
let builder = MixnetClientBuilder::new_with_storage(mixnet_client_storage)
.event_tx(EventSender(event_tx));
let mixnet_client = tokio::time::timeout(
MIXNET_CLIENT_STARTUP_TIMEOUT,
self.config.build_and_connect_mixnet_client(builder),
@@ -54,7 +58,7 @@ impl RegistrationClientBuilder {
Box::new(BandwidthController::new(credential_storage, nyxd_client));
(mixnet_client, bandwidth_controller)
} else {
let builder = MixnetClientBuilder::new_ephemeral();
let builder = MixnetClientBuilder::new_ephemeral().event_tx(EventSender(event_tx));
let mixnet_client = tokio::time::timeout(
MIXNET_CLIENT_STARTUP_TIMEOUT,
self.config.build_and_connect_mixnet_client(builder),
@@ -74,6 +78,7 @@ impl RegistrationClientBuilder {
cancel_token,
mixnet_client_address,
bandwidth_controller,
event_rx,
})
}
}
+5 -3
View File
@@ -1,10 +1,12 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
use crate::builder::config::NymNodeWithKeys;
use nym_registration_common::NymNode;
use std::path::PathBuf;
pub struct RegistrationClientConfig {
pub(crate) entry: NymNodeWithKeys,
pub(crate) exit: NymNodeWithKeys,
pub(crate) entry: NymNode,
pub(crate) exit: NymNode,
pub(crate) two_hops: bool,
pub(crate) data_path: Option<PathBuf>,
}
+22 -23
View File
@@ -8,7 +8,7 @@ use nym_bandwidth_controller::BandwidthTicketProvider;
use nym_credentials_interface::TicketType;
use nym_ip_packet_client::IprClientConnect;
use nym_registration_common::AssignedAddresses;
use nym_sdk::mixnet::{MixnetClient, Recipient};
use nym_sdk::mixnet::{EventReceiver, MixnetClient, Recipient};
use crate::config::RegistrationClientConfig;
@@ -17,10 +17,7 @@ mod config;
mod error;
mod types;
pub use builder::config::{
BuilderConfig as RegistrationClientBuilderConfig, MixnetClientConfig,
NymNodeWithKeys as RegistrationNymNode,
};
pub use builder::config::{BuilderConfig as RegistrationClientBuilderConfig, MixnetClientConfig};
pub use builder::RegistrationClientBuilder;
pub use error::RegistrationClientError;
pub use types::{MixnetRegistrationResult, RegistrationResult, WireguardRegistrationResult};
@@ -31,17 +28,18 @@ pub struct RegistrationClient {
mixnet_client_address: Recipient,
bandwidth_controller: Box<dyn BandwidthTicketProvider>,
cancel_token: CancellationToken,
event_rx: EventReceiver,
}
impl RegistrationClient {
async fn register_mix_exit(self) -> Result<RegistrationResult, RegistrationClientError> {
let entry_mixnet_gateway_ip = self.config.entry.node.ip_address;
let entry_mixnet_gateway_ip = self.config.entry.ip_address;
let exit_mixnet_gateway_ip = self.config.exit.node.ip_address;
let exit_mixnet_gateway_ip = self.config.exit.ip_address;
let ipr_address = self.config.exit.node.ipr_address.ok_or(
let ipr_address = self.config.exit.ipr_address.ok_or(
RegistrationClientError::NoIpPacketRouterAddress {
node_id: self.config.exit.node.identity.to_base58_string(),
node_id: self.config.exit.identity.to_base58_string(),
},
)?;
let mut ipr_client =
@@ -61,26 +59,27 @@ impl RegistrationClient {
entry_mixnet_gateway_ip,
exit_mixnet_gateway_ip,
},
event_rx: self.event_rx,
},
)))
}
async fn register_wg(self) -> Result<RegistrationResult, RegistrationClientError> {
let entry_auth_address = self.config.entry.node.authenticator_address.ok_or(
let entry_auth_address = self.config.entry.authenticator_address.ok_or(
RegistrationClientError::AuthenticationNotPossible {
node_id: self.config.entry.node.identity.to_base58_string(),
node_id: self.config.entry.identity.to_base58_string(),
},
)?;
let exit_auth_address = self.config.exit.node.authenticator_address.ok_or(
let exit_auth_address = self.config.exit.authenticator_address.ok_or(
RegistrationClientError::AuthenticationNotPossible {
node_id: self.config.exit.node.identity.to_base58_string(),
node_id: self.config.exit.identity.to_base58_string(),
},
)?;
let entry_version = self.config.entry.node.version;
let entry_version = self.config.entry.version;
tracing::debug!("Entry gateway version: {entry_version}");
let exit_version = self.config.exit.node.version;
let exit_version = self.config.exit.version;
tracing::debug!("Exit gateway version: {exit_version}");
// Start the auth client mixnet listener, which will listen for incoming messages from the
@@ -88,24 +87,24 @@ impl RegistrationClient {
let mixnet_listener =
AuthClientMixnetListener::new(self.mixnet_client, self.cancel_token.clone()).start();
let mut entry_auth_client = AuthenticatorClient::new(
let mut entry_auth_client = AuthenticatorClient::new_entry(
&self.config.data_path,
mixnet_listener.subscribe(),
mixnet_listener.mixnet_sender(),
self.mixnet_client_address,
entry_auth_address,
entry_version,
self.config.entry.keys,
self.config.entry.node.ip_address,
self.config.entry.ip_address,
);
let mut exit_auth_client = AuthenticatorClient::new(
let mut exit_auth_client = AuthenticatorClient::new_exit(
&self.config.data_path,
mixnet_listener.subscribe(),
mixnet_listener.mixnet_sender(),
self.mixnet_client_address,
exit_auth_address,
exit_version,
self.config.exit.keys,
self.config.exit.node.ip_address,
self.config.exit.ip_address,
);
let entry_fut = entry_auth_client
@@ -118,7 +117,7 @@ impl RegistrationClient {
let entry =
entry.map_err(
|source| RegistrationClientError::EntryGatewayRegisterWireguard {
gateway_id: self.config.entry.node.identity.to_base58_string(),
gateway_id: self.config.entry.identity.to_base58_string(),
authenticator_address: Box::new(entry_auth_address),
source: Box::new(source),
},
@@ -126,7 +125,7 @@ impl RegistrationClient {
let exit =
exit.map_err(
|source| RegistrationClientError::ExitGatewayRegisterWireguard {
gateway_id: self.config.exit.node.identity.to_base58_string(),
gateway_id: self.config.exit.identity.to_base58_string(),
authenticator_address: Box::new(exit_auth_address),
source: Box::new(source),
},
+2 -1
View File
@@ -4,7 +4,7 @@
use nym_authenticator_client::{AuthClientMixnetListenerHandle, AuthenticatorClient};
use nym_bandwidth_controller::BandwidthTicketProvider;
use nym_registration_common::{AssignedAddresses, GatewayData};
use nym_sdk::mixnet::MixnetClient;
use nym_sdk::mixnet::{EventReceiver, MixnetClient};
pub enum RegistrationResult {
Mixnet(Box<MixnetRegistrationResult>),
@@ -14,6 +14,7 @@ pub enum RegistrationResult {
pub struct MixnetRegistrationResult {
pub assigned_addresses: AssignedAddresses,
pub mixnet_client: MixnetClient,
pub event_rx: EventReceiver,
}
pub struct WireguardRegistrationResult {
+4 -19
View File
@@ -85,41 +85,26 @@ apply_iptables_rules() {
echo "applying IPtables rules for $interface..."
sleep 2
# INPUT rules - allow incoming connections TO the gateway from tunnel clients
# This is CRITICAL for mobile clients to reach the bandwidth controller at 10.1.0.1:51830
sudo iptables -I INPUT -i "$interface" -j ACCEPT
sudo ip6tables -I INPUT -i "$interface" -j ACCEPT
# NAT rules - for outbound traffic masquerading
sudo iptables -t nat -A POSTROUTING -o "$network_device" -j MASQUERADE
sudo ip6tables -t nat -A POSTROUTING -o "$network_device" -j MASQUERADE
# FORWARD rules - allow traffic through the gateway
sudo iptables -A FORWARD -i "$interface" -o "$network_device" -j ACCEPT
sudo iptables -A FORWARD -i "$network_device" -o "$interface" -m state --state RELATED,ESTABLISHED -j ACCEPT
sudo ip6tables -t nat -A POSTROUTING -o "$network_device" -j MASQUERADE
sudo ip6tables -A FORWARD -i "$interface" -o "$network_device" -j ACCEPT
sudo ip6tables -A FORWARD -i "$network_device" -o "$interface" -m state --state RELATED,ESTABLISHED -j ACCEPT
sudo iptables-save | sudo tee /etc/iptables/rules.v4
sudo ip6tables-save | sudo tee /etc/iptables/rules.v6
echo "IPtables rules applied successfully for $interface (including INPUT rules for bandwidth controller)."
}
check_tunnel_iptables() {
local interface=$1
echo "inspecting IPtables rules for $interface..."
echo "---------------------------------------"
echo "IPv4 INPUT rules (for bandwidth controller):"
iptables -L INPUT -v -n | grep -E "$interface|Chain INPUT" | head -20
echo "---------------------------------------"
echo "IPv4 FORWARD rules:"
echo "IPv4 rules:"
iptables -L FORWARD -v -n | awk -v dev="$interface" '/^Chain FORWARD/ || $0 ~ dev || $0 ~ "ufw-reject-forward"'
echo "---------------------------------------"
echo "IPv6 INPUT rules (for bandwidth controller):"
ip6tables -L INPUT -v -n | grep -E "$interface|Chain INPUT" | head -20
echo "---------------------------------------"
echo "IPv6 FORWARD rules:"
echo "IPv6 rules:"
ip6tables -L FORWARD -v -n | awk -v dev="$interface" '/^Chain FORWARD/ || $0 ~ dev || $0 ~ "ufw6-reject-forward"'
}
+1 -1
View File
@@ -1,6 +1,6 @@
#!/usr/bin/python3
__version__ = "1.1.0"
__version__ = "1.0.0"
__default_branch__ = "develop"
import os
@@ -1,6 +0,0 @@
address,tag
add_wallet_or_delete,optional_tag_or_delete
add_wallet_or_delete,optional_tag_or_delete
add_wallet_or_delete,optional_tag_or_delete
add_wallet_or_delete,optional_tag_or_delete
add_wallet_or_delete,optional_tag_or_delete
1 address tag
2 add_wallet_or_delete optional_tag_or_delete
3 add_wallet_or_delete optional_tag_or_delete
4 add_wallet_or_delete optional_tag_or_delete
5 add_wallet_or_delete optional_tag_or_delete
6 add_wallet_or_delete optional_tag_or_delete
@@ -1,627 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
This script fetches operators rewards based on provided Nyx account addresses provided in data/wallet-addresses.csv.
Output is:
1. Printet table in terminal
2. Sheet with complete info stored in data/node-balances.csv
3. Hiostorical data yaml file stored in data/data.yaml - this file should not be changed by hand, as
all values older than 30 days get auto-removed
Before you start fill first column of data/wallet-addresses with your Nyx account addresses and (optionally) second column
with a tag, for example "mysquad" and "personal" to get sorted output per entity.
"""
import csv
import os
import sys
import time
import yaml
import requests
from collections import defaultdict
from typing import Any, Dict, List, Tuple, Optional
from tabulate import tabulate
from colorama import init as colorama_init, Fore, Style
colorama_init()
DATA_DIR = os.path.join(os.getcwd(), "data")
ADDR_CSV = os.path.join(DATA_DIR, "wallet-addresses.csv")
OUT_CSV = os.path.join(DATA_DIR, "node-balances.csv")
HIST_FILE = os.path.join(DATA_DIR, "data.yaml")
SPECTRE_NODES_URL = "https://api.nym.spectredao.net/api/v1/nodes"
VALIDATOR_BONDED_URL = "https://validator.nymtech.net/api/v1/nym-nodes/bonded"
VALIDATOR_DESC_URL = "https://validator.nymtech.net/api/v1/nym-nodes/described"
SPECTRE_BAL_URL = "https://api.nym.spectredao.net/api/v1/balances/{address}"
SESSION = requests.Session()
SESSION.headers.update({"User-Agent": "nym-tools/1.0"})
def log(msg: str) -> None:
print(msg, flush=True)
def now_ts() -> float:
return time.time()
def to_float(x: Any, default: float = 0.0) -> float:
try:
if x is None:
return default
if isinstance(x, (int, float)):
return float(x)
if isinstance(x, str):
return float(x)
return default
except Exception:
return default
def to_int(x: Any, default: int = 0) -> int:
try:
if x is None:
return default
if isinstance(x, int):
return x
if isinstance(x, float):
return int(x)
if isinstance(x, str):
return int(float(x))
return default
except Exception:
return default
# pagination helpers
def _get_json(url: str, params: Dict[str, Any], timeout: int = 60) -> Any:
r = SESSION.get(url, params=params, timeout=timeout)
r.raise_for_status()
return r.json()
def _fetch_all_limit_offset(url: str, limit: int = 1000, timeout: int = 60) -> List[Any]:
out: List[Any] = []
offset = 0
tries = 0
while True:
tries += 1
data = _get_json(url, {"limit": limit, "offset": offset}, timeout=timeout)
if isinstance(data, dict) and "data" in data and isinstance(data["data"], list):
items = data["data"]
elif isinstance(data, list):
items = data
else:
break
if not items:
break
out.extend(items)
if len(items) < limit:
break
offset += limit
if tries > 500:
break
return out
def _fetch_all_page_pagesize(url: str, page_size: int = 1000, timeout: int = 60) -> List[Any]:
out: List[Any] = []
page = 0
tries = 0
while True:
tries += 1
data = _get_json(url, {"page": page, "size": page_size}, timeout=timeout)
if isinstance(data, dict) and "data" in data and isinstance(data["data"], list):
items = data["data"]
elif isinstance(data, list):
items = data
else:
break
out.extend(items)
total = to_int(data.get("pagination", {}).get("total"), -1) if isinstance(data, dict) else -1
if total >= 0 and len(out) >= total:
break
if not items:
break
page += 1
if tries > 500:
break
return out
def _fetch_all_single(url: str, timeout: int = 60) -> List[Any]:
data = _get_json(url, {}, timeout=timeout)
if isinstance(data, dict) and "data" in data and isinstance(data["data"], list):
return data["data"]
if isinstance(data, list):
return data
return []
def _fetch_all_any(url: str, timeout: int = 60) -> list:
got = _fetch_all_limit_offset(url, limit=1000, timeout=timeout)
if isinstance(got, list) and got:
return got
got = _fetch_all_page_pagesize(url, page_size=1000, timeout=timeout)
if isinstance(got, list) and got:
return got
return _fetch_all_single(url, timeout=timeout)
# load data
def read_wallets_csv(path: str) -> List[Tuple[str, str]]:
if not os.path.exists(path):
raise FileNotFoundError(f"Input CSV not found: {path}")
rows: List[Tuple[str, str]] = []
with open(path, newline="", encoding="utf-8") as f:
reader = csv.DictReader(f)
for row in reader:
addr = (row.get("address") or "").strip()
tag = (row.get("tag") or "").strip()
if addr:
rows.append((addr, tag))
return rows
def fetch_nodes_all() -> List[Dict[str, Any]]:
log(f"* * * Fetching nodes from {SPECTRE_NODES_URL} * * *")
nodes = _fetch_all_any(SPECTRE_NODES_URL, timeout=90)
log(f"Fetched {len(nodes)} node(s)")
return nodes
def fetch_bonded_all() -> List[Dict[str, Any]]:
log(f"* * *Fetching bonded from {VALIDATOR_BONDED_URL} * * *")
bonded = _fetch_all_any(VALIDATOR_BONDED_URL, timeout=90)
log(f"Fetched {len(bonded)} bonded record(s)")
return bonded
def fetch_described_all() -> List[Dict[str, Any]]:
log(f"* * * Fetching described from {VALIDATOR_DESC_URL} * * *")
described = _fetch_all_any(VALIDATOR_DESC_URL, timeout=90)
log(f"Fetched {len(described)} described record(s)")
return described
def fetch_balance_total_nym(address: str) -> float:
url = SPECTRE_BAL_URL.format(address=address)
try:
r = SESSION.get(url, timeout=30)
r.raise_for_status()
js = r.json()
amt = to_float(js.get("total", {}).get("amount"), 0.0)
return amt / 1_000_000.0
except Exception as e:
log(f"{Fore.YELLOW}* * * warn: balance fetch failed for {address}: {e}{Style.RESET_ALL} * * *")
return 0.0
# extract version
def _first_str(*vals) -> str:
for v in vals:
if isinstance(v, (str, int, float)):
s = str(v).strip()
if s:
return s
return ""
def extract_version_from_node(n: Dict[str, Any]) -> str:
desc = n.get("description") or {}
bi = n.get("build_information") or {}
return _first_str(
n.get("version"),
n.get("node_version"),
bi.get("build_version"),
bi.get("version"),
(desc.get("software") or {}).get("version"),
(desc.get("build_information") or {}).get("build_version"),
)
def extract_version_from_desc(d: Dict[str, Any]) -> str:
desc = d.get("description") or {}
bi = d.get("build_information") or {}
return _first_str(
d.get("version"),
d.get("node_version"),
bi.get("build_version"),
bi.get("version"),
(desc.get("software") or {}).get("version"),
(desc.get("build_information") or {}).get("build_version"),
)
# history storage in data/data.yaml + 30d cleanup + window helpers
def load_history(path: str) -> Dict[str, Any]:
if not os.path.exists(path):
return {}
with open(path, "r", encoding="utf-8") as f:
try:
return yaml.safe_load(f) or {}
except Exception:
return {}
def save_history(path: str, data: Dict[str, Any]) -> None:
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
yaml.safe_dump(data, f, sort_keys=True)
def add_history_point(hist: Dict[str, Any], node_id: int, epoch_ts: float, uptime: float, op_bal: float) -> None:
key = str(node_id)
lst = hist.setdefault(key, [])
lst.append({
"ts": epoch_ts,
"uptime": round(float(uptime), 6),
"operator_balance": round(float(op_bal), 6),
})
def last_snapshot(hist: Dict[str, Any], node_id: int) -> Optional[Dict[str, Any]]:
key = str(node_id)
if key not in hist:
return None
lst = hist[key]
if not lst:
return None
return sorted(lst, key=lambda x: x.get("ts", 0.0))[-1]
def cleanup_history_older_than(hist: Dict[str, Any], cutoff_ts: float) -> None:
# remove entries older than cutoff - 30 days
for key, lst in list(hist.items()):
new_lst = [e for e in lst if to_float(e.get("ts"), 0.0) >= cutoff_ts]
if new_lst:
hist[key] = new_lst
else:
del hist[key]
def scaled_window_change(
hist: Dict[str, Any],
node_id: int,
now: float,
window_days: float,
current_balance: float
) -> Optional[Tuple[float, float, float]]:
key = str(node_id)
if key not in hist or not hist[key]:
return None
cutoff_ts = now - window_days * 24 * 3600
candidates = [e for e in hist[key] if to_float(e.get("ts"), 0.0) <= cutoff_ts]
if not candidates:
return None
snap = sorted(candidates, key=lambda x: x.get("ts", 0.0))[-1]
span_hours = max(0.0, (now - to_float(snap.get("ts"), now)) / 3600.0)
if span_hours <= 0:
return None
profit_raw = current_balance - to_float(snap.get("operator_balance"), 0.0)
target_hours = window_days * 24.0
profit_scaled = profit_raw * (target_hours / span_hours)
hourly_scaled = profit_scaled / target_hours
return profit_scaled, span_hours, hourly_scaled
# output coloring fns
def colorize(text: str, color_name: str) -> str:
mapping = {
"green": Fore.GREEN,
"yellow": Fore.YELLOW,
"orange": Fore.MAGENTA,
"red": Fore.RED,
}
c = mapping.get(color_name, "")
if not c:
return text
return f"{c}{text}{Style.RESET_ALL}"
def uptime_color_name(u: float) -> str:
if u >= 0.95:
return "green"
if u >= 0.90:
return "yellow"
if u >= 0.80:
return "orange"
return "red"
# main program body
def main() -> None:
os.makedirs(DATA_DIR, exist_ok=True)
wallets = read_wallets_csv(ADDR_CSV)
if not wallets:
log(f"{Fore.RED}No wallets found in {ADDR_CSV}{Style.RESET_ALL}")
sys.exit(1)
log(f"Found {len(wallets)} wallet(s) in {ADDR_CSV}")
# preserve input order per wallet for per-tag tables
wallet_order = {addr: idx for idx, (addr, _tag) in enumerate(wallets)}
nodes = fetch_nodes_all()
bonded = fetch_bonded_all()
described = fetch_described_all()
# indexes
idx_nodes_by_wallet: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
for n in nodes:
w = (n.get("bonding_address") or "").strip()
if w:
idx_nodes_by_wallet[w].append(n)
idx_desc_by_node_id: Dict[int, Dict[str, Any]] = {}
for d in described:
nid = to_int(d.get("node_id"), 0)
if nid:
idx_desc_by_node_id[nid] = d
idx_bonded_by_owner: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
for b in bonded:
bi = b.get("bond_information", {})
owner = (bi.get("owner") or "").strip()
if owner:
idx_bonded_by_owner[owner].append(b)
headers_csv = [
"node_id",
"hostname",
"identity_key",
"wallet",
"uptime",
"version",
"operator_balance",
"profit_difference",
"epochs",
"average_hour",
"7_days",
"7_days_average",
"30_days",
"30_days_average",
"tag",
]
hist = load_history(HIST_FILE)
now = now_ts()
out_rows: List[Dict[str, Any]] = []
rows_by_tag: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
THIRTY_DAYS_SEC = 30 * 24 * 3600
cleanup_history_older_than(hist, now - THIRTY_DAYS_SEC) # prune before use
for wallet_addr, tag in wallets:
wallet_nodes = idx_nodes_by_wallet.get(wallet_addr, [])
if not wallet_nodes:
# fallback via bonded -> described + balance
for b in idx_bonded_by_owner.get(wallet_addr, []):
bi = b.get("bond_information", {})
nid = to_int(bi.get("node_id"), 0)
if nid <= 0:
continue
d = idx_desc_by_node_id.get(nid, {})
desc = d.get("description", {}) if isinstance(d, dict) else {}
hostinfo = desc.get("host_information", {}) if isinstance(desc, dict) else {}
hostname = hostinfo.get("hostname") or ""
node = bi.get("node", {}) if isinstance(bi, dict) else {}
identity_key = node.get("identity_key") or ""
op_bal = fetch_balance_total_nym(wallet_addr)
uptime = 0.0
# since last time change calculation
prev = last_snapshot(hist, nid)
prev_bal = to_float(prev.get("operator_balance"), 0.0) if prev else None
prev_ts = to_float(prev.get("ts"), 0.0) if prev else None
diff = 0.0
hours = 0.0
if prev is not None:
diff = op_bal - prev_bal
hours = max(0.0, (now - prev_ts) / 3600.0)
# last 7 / 30 days calculation
seven = scaled_window_change(hist, nid, now, 7.0, op_bal)
thirty = scaled_window_change(hist, nid, now, 30.0, op_bal)
row = {
"node_id": nid,
"hostname": hostname,
"identity_key": identity_key,
"wallet": wallet_addr,
"uptime": uptime,
"version": extract_version_from_desc(d),
"operator_balance": op_bal,
"profit_difference": diff,
"epochs": hours,
"average_hour": (diff / hours) if hours > 0 else 0.0,
"7_days": f"{seven[0]:.6f}" if seven else "no 7 days data stored",
"7_days_average": f"{seven[2]:.6f}" if seven else "no 7 days data stored",
"30_days": f"{thirty[0]:.6f}" if thirty else "no 30 days data stored",
"30_days_average": f"{thirty[2]:.6f}" if thirty else "no 30 days data stored",
"tag": tag,
"_prev_balance": prev_bal,
"_prev_ts": prev_ts,
"_wallet_order": wallet_order.get(wallet_addr, 10**9),
}
# append current snapshot & prune >30d
add_history_point(hist, nid, now, uptime, op_bal)
out_rows.append(row)
rows_by_tag[tag].append(row)
continue
# path from /nodes
for n in wallet_nodes:
nid = to_int(n.get("node_id"), 0)
if nid <= 0:
continue
identity_key = n.get("identity_key") or ""
uptime = to_float(n.get("uptime"), 0.0)
desc = n.get("description") or {}
hostinfo = desc.get("host_information") or {}
hostname = hostinfo.get("hostname") or ""
op_unym = to_float(n.get("rewarding_details", {}).get("operator"), 0.0)
op_bal = op_unym / 1_000_000.0
if op_bal <= 0:
op_bal = fetch_balance_total_nym(wallet_addr)
prev = last_snapshot(hist, nid)
prev_bal = to_float(prev.get("operator_balance"), 0.0) if prev else None
prev_ts = to_float(prev.get("ts"), 0.0) if prev else None
diff = 0.0
hours = 0.0
if prev is not None:
diff = op_bal - prev_bal
hours = max(0.0, (now - prev_ts) / 3600.0)
seven = scaled_window_change(hist, nid, now, 7.0, op_bal)
thirty = scaled_window_change(hist, nid, now, 30.0, op_bal)
row = {
"node_id": nid,
"hostname": hostname,
"identity_key": identity_key,
"wallet": wallet_addr,
"uptime": uptime,
"version": extract_version_from_node(n),
"operator_balance": op_bal,
"profit_difference": diff,
"epochs": hours,
"average_hour": (diff / hours) if hours > 0 else 0.0,
"7_days": f"{seven[0]:.6f}" if seven else "no 7 days data stored",
"7_days_average": f"{seven[2]:.6f}" if seven else "no 7 days data stored",
"30_days": f"{thirty[0]:.6f}" if thirty else "no 30 days data stored",
"30_days_average": f"{thirty[2]:.6f}" if thirty else "no 30 days data stored",
"tag": tag,
"_prev_balance": prev_bal,
"_prev_ts": prev_ts,
"_wallet_order": wallet_order.get(wallet_addr, 10**9),
}
add_history_point(hist, nid, now, uptime, op_bal)
out_rows.append(row)
rows_by_tag[tag].append(row)
# final prune & save
cleanup_history_older_than(hist, now - THIRTY_DAYS_SEC)
save_history(HIST_FILE, hist)
# write CSV
headers_csv = [
"node_id","hostname","identity_key","wallet","uptime","version","operator_balance",
"profit_difference","epochs","average_hour","7_days","7_days_average",
"30_days","30_days_average","tag",
]
with open(OUT_CSV, "w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=headers_csv)
writer.writeheader()
for r in out_rows:
writer.writerow({
"node_id": r.get("node_id",""),
"hostname": r.get("hostname",""),
"identity_key": r.get("identity_key",""),
"wallet": r.get("wallet",""),
"uptime": f"{to_float(r.get('uptime'),0.0):.6f}",
"version": r.get("version",""),
"operator_balance": f"{to_float(r.get('operator_balance'),0.0):.6f}",
"profit_difference": f"{to_float(r.get('profit_difference'),0.0):.6f}",
"epochs": f"{to_float(r.get('epochs'),0.0):.6f}",
"average_hour": f"{to_float(r.get('average_hour'),0.0):.6f}",
"7_days": r.get("7_days",""),
"7_days_average": r.get("7_days_average",""),
"30_days": r.get("30_days",""),
"30_days_average": r.get("30_days_average",""),
"tag": r.get("tag",""),
})
if not out_rows:
log(f"{Fore.YELLOW}No rows produced — check inputs and endpoints.{Style.RESET_ALL}")
return
# per-tag output (preserve input order)
for tag, rows in sorted(rows_by_tag.items(), key=lambda kv: kv[0] or ""):
headers_print = [
"node_id","hostname","wallet","uptime","version",
"operator_balance","profit_difference","epochs","average_hour",
"7_days","7_days_average","30_days","30_days_average",
]
view: List[List[str]] = []
for r in rows:
u = to_float(r.get("uptime"), 0.0)
u_col = uptime_color_name(u)
view.append([
r.get("node_id") or "",
(r.get("hostname") or "")[:40],
r.get("wallet") or "",
colorize(f"{u:.2f}", u_col) if u_col else f"{u:.2f}",
r.get("version") or "",
f"{to_float(r.get('operator_balance'), 0.0):.2f}",
f"{to_float(r.get('profit_difference'), 0.0):.2f}",
f"{to_float(r.get('epochs'), 0.0):.2f}",
f"{to_float(r.get('average_hour'), 0.0):.2f}",
r.get("7_days"),
r.get("7_days_average"),
r.get("30_days"),
r.get("30_days_average"),
])
title = f"Tag: {tag or '(untagged)'}{len(rows)} node(s)"
print("\n" + title)
print(tabulate(view, headers=headers_print, tablefmt="github", stralign="right", disable_numparse=True))
# per-tag summary
tag_total_now = sum(to_float(r.get("operator_balance"), 0.0) for r in rows)
# since last time: sum diffs, average hours across nodes that existed
prev_sum = 0.0
prev_hours: List[float] = []
for r in rows:
prev_bal = r.get("_prev_balance")
prev_ts = r.get("_prev_ts")
if prev_bal is not None and prev_ts is not None:
prev_sum += to_float(prev_bal, 0.0)
prev_hours.append(max(0.0, (now - to_float(prev_ts, now)) / 3600.0))
diff_total = tag_total_now - prev_sum if prev_hours else 0.0
hours_since = (sum(prev_hours) / len(prev_hours)) if prev_hours else 0.0
hourly = (diff_total / hours_since) if hours_since > 0 else 0.0
# 7-day / 30-day per-tag totals: sum nodes with data; hours fixed windows if any
def _num_or_none(v):
try:
return float(v)
except Exception:
return None
seven_vals = [_num_or_none(r.get("7_days")) for r in rows]
seven_vals = [v for v in seven_vals if v is not None]
total7 = sum(seven_vals) if seven_vals else 0.0
hours7 = 7.0 * 24.0 if seven_vals else 0.0
hourly7 = (total7 / hours7) if hours7 > 0 else 0.0
thirty_vals = [_num_or_none(r.get("30_days")) for r in rows]
thirty_vals = [v for v in thirty_vals if v is not None]
total30 = sum(thirty_vals) if thirty_vals else 0.0
hours30 = 30.0 * 24.0 if thirty_vals else 0.0
hourly30 = (total30 / hours30) if hours30 > 0 else 0.0
# print output with colored numbers
print(
"\n"
f"Total balance across all wallets: {Style.BRIGHT}{Fore.GREEN}{tag_total_now:.2f}{Style.RESET_ALL} NYM\n"
f"Difference of total balance from last time: {Fore.CYAN}{diff_total:.2f}{Style.RESET_ALL} NYM\n"
f"Time since last time: {Fore.BLUE}{hours_since:.2f}{Style.RESET_ALL} hours\n"
f"Approx hourly difference: {Style.BRIGHT}{Fore.GREEN}{hourly:.2f}{Style.RESET_ALL} NYM/h\n"
f"7-day change: "
f"{('no 7 days data stored' if not seven_vals else f'{Fore.CYAN}{total7:.2f}{Style.RESET_ALL} NYM, hours: {Fore.BLUE}{hours7:.2f}{Style.RESET_ALL}, hourly: {Style.BRIGHT}{Fore.GREEN}{hourly7:.2f}{Style.RESET_ALL} NYM/h')}\n"
f"30-day change: "
f"{('no 30 days data stored' if not thirty_vals else f'{Fore.CYAN}{total30:.2f}{Style.RESET_ALL} NYM, hours: {Fore.BLUE}{hours30:.2f}{Style.RESET_ALL}, hourly: {Style.BRIGHT}{Fore.GREEN}{hourly30:.2f}{Style.RESET_ALL} NYM/h')}\n"
)
log(f"\nCSV written to: {OUT_CSV}")
log(f"History saved to: {HIST_FILE}")
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
print("\nInterrupted.", file=sys.stderr)
sys.exit(130)
+10 -5
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
API_URL="${API_URL:-https://validator.nymtech.net/api/v1/nym-nodes/described}"
API_URL="${API_URL:-https://validator.nymtech.net/api/v1/gateways/described}"
CONCURRENCY="${CONCURRENCY:-64}" # how many pings in flight
PING_TIMEOUT="${PING_TIMEOUT:-7}" # seconds to wait for a single echo reply
PING_RETRIES="${PING_RETRIES:-1}" # additional attempts after the first failure
@@ -28,13 +28,19 @@ curl -fsSL --retry 3 --retry-delay 1 --compressed "$API_URL" -o "$tmp_json"
# extract IPs
ip_list="$(mktemp)"
jq -r '
.data[]?
| (.description.host_information.ip_address? // [])[]
.[]? as $g
| (
($g.self_described.host_information.ip_address? // [])
+ (
[ $g.bond.gateway.host ]
| map(select(type=="string"))
)
)[]
' "$tmp_json" \
| awk '
# very permissive IPv4/IPv6 syntax filters (we let ping validate the rest)
function is_ipv4(s){ return (s ~ /^[0-9]{1,3}(\.[0-9]{1,3}){3}$/) }
function is_ipv6(s){ return (index(s,":") > 0) }
function is_ipv6(s){ return (index(s,":")>0) }
{ if(is_ipv4($0) || is_ipv6($0)) print $0 }
' \
| sort -u > "$ip_list"
@@ -93,4 +99,3 @@ awk '{printf "%s,%s\n",$1,$2}' "$num_list" \
echo "Done. Results:"
echo " $(($(wc -l < "$OK_CSV") - 1)) reachable -> $OK_CSV"
echo " $(($(wc -l < "$BAD_CSV") - 1)) not reachable -> $BAD_CSV"
+10 -4
View File
@@ -45,15 +45,21 @@ pub use native_client::MixnetClient;
pub use native_client::MixnetClientSender;
#[allow(deprecated)]
pub use nym_client_core::client::{
base_client::storage::{
gateways_storage::{ActiveGateway, BadGateway, GatewayRegistration, GatewaysDetailsStore},
Ephemeral, MixnetClientStorage, OnDiskPersistent,
base_client::{
storage::{
gateways_storage::{
ActiveGateway, BadGateway, GatewayRegistration, GatewaysDetailsStore,
},
Ephemeral, MixnetClientStorage, OnDiskPersistent,
},
EventReceiver, EventSender, MixnetClientEvent,
},
inbound_messages::InputMessage,
key_manager::{
persistence::{InMemEphemeralKeys, KeyStore, OnDiskKeys},
ClientKeys,
},
mix_traffic::MixTrafficEvent,
replies::reply_storage::{
fs_backend::Backend as ReplyStorage, CombinedReplyStorage, Empty as EmptyReplyStorage,
ReplyStorageBackend,
@@ -63,7 +69,7 @@ pub use nym_credential_storage::{
ephemeral_storage::EphemeralStorage as EphemeralCredentialStorage,
models::StoredIssuedTicketbook, storage::Storage as CredentialStorage,
};
pub use nym_crypto::asymmetric::{ed25519, x25519};
pub use nym_crypto::asymmetric::ed25519;
pub use nym_network_defaults::NymNetworkDetails;
pub use nym_socks5_client_core::config::Socks5;
pub use nym_sphinx::{
+26 -3
View File
@@ -16,7 +16,7 @@ use nym_client_core::client::base_client::storage::helpers::{
use nym_client_core::client::base_client::storage::{
Ephemeral, GatewaysDetailsStore, MixnetClientStorage, OnDiskPersistent,
};
use nym_client_core::client::base_client::BaseClient;
use nym_client_core::client::base_client::{BaseClient, EventSender};
use nym_client_core::client::key_manager::persistence::KeyStore;
use nym_client_core::client::{
base_client::BaseClientBuilder, replies::reply_storage::ReplyStorageBackend,
@@ -53,6 +53,7 @@ pub struct MixnetClientBuilder<S: MixnetClientStorage = Ephemeral> {
custom_topology_provider: Option<Box<dyn TopologyProvider + Send + Sync>>,
custom_gateway_transceiver: Option<Box<dyn GatewayTransceiver + Send + Sync>>,
custom_shutdown: Option<ShutdownTracker>,
event_tx: Option<EventSender>,
force_tls: bool,
user_agent: Option<UserAgent>,
#[cfg(unix)]
@@ -96,6 +97,7 @@ impl MixnetClientBuilder<OnDiskPersistent> {
.await?,
gateway_endpoint_config_path: None,
custom_shutdown: None,
event_tx: None,
custom_gateway_transceiver: None,
force_tls: false,
user_agent: None,
@@ -129,6 +131,7 @@ where
custom_topology_provider: None,
custom_gateway_transceiver: None,
custom_shutdown: None,
event_tx: None,
force_tls: false,
user_agent: None,
#[cfg(unix)]
@@ -152,6 +155,7 @@ where
custom_topology_provider: self.custom_topology_provider,
custom_gateway_transceiver: self.custom_gateway_transceiver,
custom_shutdown: self.custom_shutdown,
event_tx: self.event_tx,
force_tls: self.force_tls,
user_agent: self.user_agent,
#[cfg(unix)]
@@ -269,6 +273,13 @@ where
self
}
/// Use an externally managed shutdown mechanism.
#[must_use]
pub fn event_tx(mut self, event_tx: EventSender) -> Self {
self.event_tx = Some(event_tx);
self
}
/// Attempt to wait for the selected gateway (if applicable) to come online if its currently not bonded.
#[must_use]
pub fn with_wait_for_gateway(mut self, wait_for_gateway: bool) -> Self {
@@ -317,8 +328,12 @@ where
/// Construct a [`DisconnectedMixnetClient`] from the setup specified.
pub fn build(self) -> Result<DisconnectedMixnetClient<S>> {
let mut client =
DisconnectedMixnetClient::new(self.config, self.socks5_config, self.storage)?;
let mut client = DisconnectedMixnetClient::new(
self.config,
self.socks5_config,
self.storage,
self.event_tx,
)?;
client.custom_gateway_transceiver = self.custom_gateway_transceiver;
client.custom_topology_provider = self.custom_topology_provider;
@@ -380,6 +395,9 @@ where
/// Allows passing an externally controlled shutdown handle.
custom_shutdown: Option<ShutdownTracker>,
/// Sender of mixnet client events to the SDK caller
event_tx: Option<EventSender>,
user_agent: Option<UserAgent>,
/// Callback on the websocket fd as soon as the connection has been established
@@ -415,6 +433,7 @@ where
config: Config,
socks5_config: Option<Socks5>,
storage: S,
event_tx: Option<EventSender>,
) -> Result<DisconnectedMixnetClient<S>> {
// don't create dkg client for the bandwidth controller if credentials are disabled
let dkg_query_client = if config.enabled_credentials_mode {
@@ -443,6 +462,7 @@ where
wait_for_gateway: false,
force_tls: false,
custom_shutdown: None,
event_tx,
user_agent: None,
#[cfg(unix)]
connection_fd_callback: None,
@@ -699,6 +719,9 @@ where
}
};
base_builder = base_builder.with_shutdown(shutdown_tracker);
if let Some(event_tx) = self.event_tx {
base_builder = base_builder.with_event_tx(event_tx);
}
if let Some(gateway_transceiver) = self.custom_gateway_transceiver {
base_builder = base_builder.with_gateway_transceiver(gateway_transceiver);