Merge branch 'max/wasm-demos-docs-components' of github.com:nymtech/nym into max/wasm-demos-docs-components

This commit is contained in:
mfahampshire
2026-06-09 19:26:18 +01:00
55 changed files with 3025 additions and 1985 deletions
+88
View File
@@ -4,6 +4,94 @@ Post 1.0.0 release, the changelog format is based on [Keep a Changelog](https://
## [Unreleased]
## [2026.11-xynomizithra] (2026-06-08)
- bugfix: allow re-inviting expired members ([#6863])
- feat: disable Nagle's algorithm for LP between nym-nodes ([#6857])
- Keep peer in wg table when updating psk ([#6856])
- chore: minor nym-node improvements ([#6850])
- chore: LP registration adjustments ([#6845])
- crates release: bump version to 1.21.1 ([#6844])
- fix gateways being penalised for no stress testing ([#6843])
- fix score inflation for throttled nodes ([#6842])
- Bugfix/cherry pick/waterloo stres testing floats ([#6841])
- bugfix: NMv3 race condition ([#6837])
- feat: implement UpdateFamily for the node families contract ([#6834])
- Bugfix/cherry pick/waterloo ns api ([#6833])
- experiment: attempt to retroactively generate specs for node families and ecash contracts ([#6813])
- moving lp packets in lp-data crate ([#6810])
- upgrade axum to 0.8.9 (and side deps) ([#6808])
- chore: expose admin method for migrating vesting delegations/mixnodes ([#6795])
- [chore] fix clippy 1.95 lints for future version update ([#6794])
- Handle Rate Limit Challenge Response ([#6786])
- NYM-583: Avoid corrupted database on Windows. ([#6785])
- Max/smolmix wasm ([#6784])
- Chore/bugfixes ([#6783])
- Switch from yarn to pnpm ([#6779])
- feat: Node Families: expose stake information inside DVpnGateway ([#6778])
- feat: Node Families: expose family information for NS API consumers ([#6777])
- feat: Node Families: cache and expose family data within nym API ([#6774])
- Re-order default API urls for network details ([#6767])
- add ci for NM agent binary ([#6764])
- feat/refactor: introduce shared contract caches within Nym API ([#6760])
- chore: removed dead code for redundant mixnet-vesting integration tests ([#6759])
- feat: Node Families: remove nodes upon unbonding ([#6752])
- feat: Node Families: contract transactions ([#6750])
- feat: Node Families: contract queries ([#6731])
- feat: Node Families: initial contract storage ([#6717])
- start node families topic branch ([#6715])
- Bump rand from 0.8.5 to 0.8.6 in /contracts ([#6702])
- Testing port checks in NS Agents ([#6694])
- build(deps): bump microsoft/setup-msbuild from 2 to 3 ([#6602])
- build(deps): bump tar from 0.4.44 to 0.4.45 ([#6595])
- build(deps): bump quinn-proto from 0.11.12 to 0.11.14 ([#6549])
- build(deps): bump docker/login-action from 3 to 4 ([#6518])
- build(deps): bump actions/download-artifact from 7 to 8 ([#6497])
- build(deps): bump actions/upload-artifact from 6 to 7 ([#6496])
[#6863]: https://github.com/nymtech/nym/pull/6863
[#6857]: https://github.com/nymtech/nym/pull/6857
[#6856]: https://github.com/nymtech/nym/pull/6856
[#6850]: https://github.com/nymtech/nym/pull/6850
[#6845]: https://github.com/nymtech/nym/pull/6845
[#6844]: https://github.com/nymtech/nym/pull/6844
[#6843]: https://github.com/nymtech/nym/pull/6843
[#6842]: https://github.com/nymtech/nym/pull/6842
[#6841]: https://github.com/nymtech/nym/pull/6841
[#6837]: https://github.com/nymtech/nym/pull/6837
[#6834]: https://github.com/nymtech/nym/pull/6834
[#6833]: https://github.com/nymtech/nym/pull/6833
[#6813]: https://github.com/nymtech/nym/pull/6813
[#6810]: https://github.com/nymtech/nym/pull/6810
[#6808]: https://github.com/nymtech/nym/pull/6808
[#6795]: https://github.com/nymtech/nym/pull/6795
[#6794]: https://github.com/nymtech/nym/pull/6794
[#6786]: https://github.com/nymtech/nym/pull/6786
[#6785]: https://github.com/nymtech/nym/pull/6785
[#6784]: https://github.com/nymtech/nym/pull/6784
[#6783]: https://github.com/nymtech/nym/pull/6783
[#6779]: https://github.com/nymtech/nym/pull/6779
[#6778]: https://github.com/nymtech/nym/pull/6778
[#6777]: https://github.com/nymtech/nym/pull/6777
[#6774]: https://github.com/nymtech/nym/pull/6774
[#6767]: https://github.com/nymtech/nym/pull/6767
[#6764]: https://github.com/nymtech/nym/pull/6764
[#6760]: https://github.com/nymtech/nym/pull/6760
[#6759]: https://github.com/nymtech/nym/pull/6759
[#6752]: https://github.com/nymtech/nym/pull/6752
[#6750]: https://github.com/nymtech/nym/pull/6750
[#6731]: https://github.com/nymtech/nym/pull/6731
[#6717]: https://github.com/nymtech/nym/pull/6717
[#6715]: https://github.com/nymtech/nym/pull/6715
[#6702]: https://github.com/nymtech/nym/pull/6702
[#6694]: https://github.com/nymtech/nym/pull/6694
[#6602]: https://github.com/nymtech/nym/pull/6602
[#6595]: https://github.com/nymtech/nym/pull/6595
[#6549]: https://github.com/nymtech/nym/pull/6549
[#6518]: https://github.com/nymtech/nym/pull/6518
[#6497]: https://github.com/nymtech/nym/pull/6497
[#6496]: https://github.com/nymtech/nym/pull/6496
## [2026.10-waterloo] (2026-05-27)
- Re-order default API urls for network details - Waterloo release ([#6799])
Generated
+1347 -1405
View File
File diff suppressed because it is too large Load Diff
+109 -110
View File
@@ -211,7 +211,7 @@ edition = "2024"
license = "Apache-2.0"
rust-version = "1.87.0"
readme = "README.md"
version = "1.21.0"
version = "1.21.1"
[workspace.dependencies]
addr = "0.15.6"
@@ -406,7 +406,6 @@ utoipa-swagger-ui = "9.0.2"
utoipauto = "0.2"
uuid = "1.19.0"
vergen = { version = "=8.3.1", default-features = false }
vergen-gitcl = { version = "1.0.8", default-features = false }
walkdir = "2"
x25519-dalek = "2.0.0"
zeroize = "1.7.0"
@@ -429,115 +428,115 @@ libcrux-sha3 = "0.0.8"
libcrux-traits = "0.0.6"
# Workspace dep definitions required by crates.io publication - we need a workspace version since `cargo workspaces` doesn't work with path imports from crate manifests
nym-api-requests = { version = "1.21.0", path = "nym-api/nym-api-requests" }
nym-authenticator-requests = { version = "1.21.0", path = "common/authenticator-requests" }
nym-async-file-watcher = { version = "1.21.0", path = "common/async-file-watcher" }
nym-authenticator-client = { version = "1.21.0", path = "nym-authenticator-client" }
nym-bandwidth-controller = { version = "1.21.0", path = "common/bandwidth-controller" }
nym-bin-common = { version = "1.21.0", path = "common/bin-common" }
nym-cache = { version = "1.21.0", path = "common/nym-cache" }
nym-client-core = { version = "1.21.0", path = "common/client-core", default-features = false }
nym-client-core-config-types = { version = "1.21.0", path = "common/client-core/config-types" }
nym-client-core-gateways-storage = { version = "1.21.0", path = "common/client-core/gateways-storage" }
nym-client-core-surb-storage = { version = "1.21.0", path = "common/client-core/surb-storage" }
nym-client-websocket-requests = { version = "1.21.0", path = "clients/native/websocket-requests" }
nym-common = { version = "1.21.0", path = "common/nym-common" }
nym-compact-ecash = { version = "1.21.0", path = "common/nym_offline_compact_ecash" }
nym-config = { version = "1.21.0", path = "common/config" }
nym-contracts-common = { version = "1.21.0", path = "common/cosmwasm-smart-contracts/contracts-common" }
nym-coconut-dkg-common = { version = "1.21.0", path = "common/cosmwasm-smart-contracts/coconut-dkg" }
nym-credential-storage = { version = "1.21.0", path = "common/credential-storage" }
nym-credential-utils = { version = "1.21.0", path = "common/credential-utils" }
nym-credential-proxy-lib = { version = "1.21.0", path = "common/credential-proxy" }
nym-credentials = { version = "1.21.0", path = "common/credentials", default-features = false }
nym-credentials-interface = { version = "1.21.0", path = "common/credentials-interface" }
nym-credential-proxy-requests = { version = "1.21.0", path = "nym-credential-proxy/nym-credential-proxy-requests", default-features = false }
nym-credential-verification = { version = "1.21.0", path = "common/credential-verification" }
nym-crypto = { version = "1.21.0", path = "common/crypto", default-features = false }
nym-dkg = { version = "1.21.0", path = "common/dkg" }
nym-ecash-contract-common = { version = "1.21.0", path = "common/cosmwasm-smart-contracts/ecash-contract" }
nym-ecash-signer-check = { version = "1.21.0", path = "common/ecash-signer-check" }
nym-ecash-signer-check-types = { version = "1.21.0", path = "common/ecash-signer-check-types" }
nym-ecash-time = { version = "1.21.0", path = "common/ecash-time" }
nym-exit-policy = { version = "1.21.0", path = "common/exit-policy" }
nym-ffi-shared = { version = "1.21.0", path = "sdk/ffi/shared" }
nym-gateway-client = { version = "1.21.0", path = "common/client-libs/gateway-client", default-features = false }
nym-gateway-probe = { version = "1.18.0", path = "nym-gateway-probe" }
nym-gateway-requests = { version = "1.21.0", path = "common/gateway-requests" }
nym-gateway-storage = { version = "1.21.0", path = "common/gateway-storage" }
nym-gateway-stats-storage = { version = "1.21.0", path = "common/gateway-stats-storage" }
nym-group-contract-common = { version = "1.21.0", path = "common/cosmwasm-smart-contracts/group-contract" }
nym-http-api-client = { version = "1.21.0", path = "common/http-api-client" }
nym-http-api-client-macro = { version = "1.21.0", path = "common/http-api-client-macro" }
nym-http-api-common = { version = "1.21.0", path = "common/http-api-common", default-features = false }
nym-id = { version = "1.21.0", path = "common/nym-id" }
nym-ip-packet-client = { version = "1.21.0", path = "nym-ip-packet-client" }
nym-ip-packet-requests = { version = "1.21.0", path = "common/ip-packet-requests" }
nym-lp = { version = "1.21.0", path = "common/nym-lp" }
nym-lp-data = { version = "1.21.0", path = "common/nym-lp-data" }
nym-kkt = { version = "1.21.0", path = "common/nym-kkt" }
nym-kkt-ciphersuite = { version = "1.21.0", path = "common/nym-kkt-ciphersuite" }
nym-kkt-context = { version = "1.21.0", path = "common/nym-kkt-context" }
nym-metrics = { version = "1.21.0", path = "common/nym-metrics" }
nym-mixnet-client = { version = "1.21.0", path = "common/client-libs/mixnet-client" }
nym-mixnet-contract-common = { version = "1.21.0", path = "common/cosmwasm-smart-contracts/mixnet-contract" }
nym-multisig-contract-common = { version = "1.21.0", path = "common/cosmwasm-smart-contracts/multisig-contract" }
nym-network-defaults = { version = "1.21.0", path = "common/network-defaults" }
nym-node-tester-utils = { version = "1.21.0", path = "common/node-tester-utils" }
nym-noise = { version = "1.21.0", path = "common/nymnoise" }
nym-noise-keys = { version = "1.21.0", path = "common/nymnoise/keys" }
nym-nonexhaustive-delayqueue = { version = "1.21.0", path = "common/nonexhaustive-delayqueue" }
nym-node-requests = { version = "1.21.0", path = "nym-node/nym-node-requests", default-features = false }
nym-node-metrics = { version = "1.21.0", path = "nym-node/nym-node-metrics" }
nym-node-families-contract-common = { version = "1.21.0", path = "common/cosmwasm-smart-contracts/node-families-contract" }
nym-ordered-buffer = { version = "1.21.0", path = "common/socks5/ordered-buffer" }
nym-outfox = { version = "1.21.0", path = "nym-outfox" }
nym-registration-common = { version = "1.21.0", path = "common/registration" }
nym-pemstore = { version = "1.21.0", path = "common/pemstore" }
nym-performance-contract-common = { version = "1.21.0", path = "common/cosmwasm-smart-contracts/nym-performance-contract" }
nym-sdk = { version = "1.21.0", path = "sdk/rust/nym-sdk" }
nym-serde-helpers = { version = "1.21.0", path = "common/serde-helpers" }
nym-service-providers-common = { version = "1.21.0", path = "service-providers/common" }
nym-service-provider-requests-common = { version = "1.21.0", path = "common/service-provider-requests-common" }
nym-socks5-client-core = { version = "1.21.0", path = "common/socks5-client-core" }
nym-socks5-proxy-helpers = { version = "1.21.0", path = "common/socks5/proxy-helpers" }
nym-socks5-requests = { version = "1.21.0", path = "common/socks5/requests" }
nym-sphinx = { version = "1.21.0", path = "common/nymsphinx" }
nym-sphinx-acknowledgements = { version = "1.21.0", path = "common/nymsphinx/acknowledgements" }
nym-sphinx-addressing = { version = "1.21.0", path = "common/nymsphinx/addressing" }
nym-sphinx-anonymous-replies = { version = "1.21.0", path = "common/nymsphinx/anonymous-replies" }
nym-sphinx-chunking = { version = "1.21.0", path = "common/nymsphinx/chunking" }
nym-sphinx-cover = { version = "1.21.0", path = "common/nymsphinx/cover" }
nym-sphinx-forwarding = { version = "1.21.0", path = "common/nymsphinx/forwarding" }
nym-sphinx-framing = { version = "1.21.0", path = "common/nymsphinx/framing" }
nym-sphinx-params = { version = "1.21.0", path = "common/nymsphinx/params" }
nym-sphinx-routing = { version = "1.21.0", path = "common/nymsphinx/routing" }
nym-sphinx-types = { version = "1.21.0", path = "common/nymsphinx/types" }
nym-statistics-common = { version = "1.21.0", path = "common/statistics" }
nym-store-cipher = { version = "1.21.0", path = "common/store-cipher" }
nym-task = { version = "1.21.0", path = "common/task" }
nym-tun = { version = "1.21.0", path = "common/tun" }
nym-test-utils = { version = "1.21.0", path = "common/test-utils" }
nym-ticketbooks-merkle = { version = "1.21.0", path = "common/ticketbooks-merkle" }
nym-topology = { version = "1.21.0", path = "common/topology" }
nym-types = { version = "1.21.0", path = "common/types" }
nym-upgrade-mode-check = { version = "1.21.0", path = "common/upgrade-mode-check" }
nym-validator-client = { version = "1.21.0", path = "common/client-libs/validator-client", default-features = false }
nym-vesting-contract-common = { version = "1.21.0", path = "common/cosmwasm-smart-contracts/vesting-contract" }
nym-network-monitors-contract-common = { version = "1.21.0", path = "common/cosmwasm-smart-contracts/network-monitors-contract" }
nym-verloc = { version = "1.21.0", path = "common/verloc" }
nym-wireguard = { version = "1.21.0", path = "common/wireguard" }
nym-wireguard-types = { version = "1.21.0", path = "common/wireguard-types" }
nym-wireguard-private-metadata-shared = { version = "1.21.0", path = "common/wireguard-private-metadata/shared" }
nym-wireguard-private-metadata-client = { version = "1.21.0", path = "common/wireguard-private-metadata/client" }
nym-wireguard-private-metadata-server = { version = "1.21.0", path = "common/wireguard-private-metadata/server" }
nym-sqlx-pool-guard = { version = "1.2.0", path = "nym-sqlx-pool-guard" }
nym-wasm-client-core = { version = "1.21.0", path = "common/wasm/client-core" }
nym-wasm-storage = { version = "1.21.0", path = "common/wasm/storage" }
nym-wasm-utils = { version = "1.21.0", path = "common/wasm/utils", default-features = false }
nyxd-scraper-shared = { version = "1.21.0", path = "common/nyxd-scraper-shared" }
nym-api-requests = { version = "1.21.1", path = "nym-api/nym-api-requests" }
nym-authenticator-requests = { version = "1.21.1", path = "common/authenticator-requests" }
nym-async-file-watcher = { version = "1.21.1", path = "common/async-file-watcher" }
nym-authenticator-client = { version = "1.21.1", path = "nym-authenticator-client" }
nym-bandwidth-controller = { version = "1.21.1", path = "common/bandwidth-controller" }
nym-bin-common = { version = "1.21.1", path = "common/bin-common" }
nym-cache = { version = "1.21.1", path = "common/nym-cache" }
nym-client-core = { version = "1.21.1", path = "common/client-core", default-features = false }
nym-client-core-config-types = { version = "1.21.1", path = "common/client-core/config-types" }
nym-client-core-gateways-storage = { version = "1.21.1", path = "common/client-core/gateways-storage" }
nym-client-core-surb-storage = { version = "1.21.1", path = "common/client-core/surb-storage" }
nym-client-websocket-requests = { version = "1.21.1", path = "clients/native/websocket-requests" }
nym-common = { version = "1.21.1", path = "common/nym-common" }
nym-compact-ecash = { version = "1.21.1", path = "common/nym_offline_compact_ecash" }
nym-config = { version = "1.21.1", path = "common/config" }
nym-contracts-common = { version = "1.21.1", path = "common/cosmwasm-smart-contracts/contracts-common" }
nym-coconut-dkg-common = { version = "1.21.1", path = "common/cosmwasm-smart-contracts/coconut-dkg" }
nym-credential-storage = { version = "1.21.1", path = "common/credential-storage" }
nym-credential-utils = { version = "1.21.1", path = "common/credential-utils" }
nym-credential-proxy-lib = { version = "1.21.1", path = "common/credential-proxy" }
nym-credentials = { version = "1.21.1", path = "common/credentials", default-features = false }
nym-credentials-interface = { version = "1.21.1", path = "common/credentials-interface" }
nym-credential-proxy-requests = { version = "1.21.1", path = "nym-credential-proxy/nym-credential-proxy-requests", default-features = false }
nym-credential-verification = { version = "1.21.1", path = "common/credential-verification" }
nym-crypto = { version = "1.21.1", path = "common/crypto", default-features = false }
nym-dkg = { version = "1.21.1", path = "common/dkg" }
nym-ecash-contract-common = { version = "1.21.1", path = "common/cosmwasm-smart-contracts/ecash-contract" }
nym-ecash-signer-check = { version = "1.21.1", path = "common/ecash-signer-check" }
nym-ecash-signer-check-types = { version = "1.21.1", path = "common/ecash-signer-check-types" }
nym-ecash-time = { version = "1.21.1", path = "common/ecash-time" }
nym-exit-policy = { version = "1.21.1", path = "common/exit-policy" }
nym-ffi-shared = { version = "1.21.1", path = "sdk/ffi/shared" }
nym-gateway-client = { version = "1.21.1", path = "common/client-libs/gateway-client", default-features = false }
nym-gateway-probe = { version = "1.21.1", path = "nym-gateway-probe" }
nym-gateway-requests = { version = "1.21.1", path = "common/gateway-requests" }
nym-gateway-storage = { version = "1.21.1", path = "common/gateway-storage" }
nym-gateway-stats-storage = { version = "1.21.1", path = "common/gateway-stats-storage" }
nym-group-contract-common = { version = "1.21.1", path = "common/cosmwasm-smart-contracts/group-contract" }
nym-http-api-client = { version = "1.21.1", path = "common/http-api-client" }
nym-http-api-client-macro = { version = "1.21.1", path = "common/http-api-client-macro" }
nym-http-api-common = { version = "1.21.1", path = "common/http-api-common", default-features = false }
nym-id = { version = "1.21.1", path = "common/nym-id" }
nym-ip-packet-client = { version = "1.21.1", path = "nym-ip-packet-client" }
nym-ip-packet-requests = { version = "1.21.1", path = "common/ip-packet-requests" }
nym-lp = { version = "1.21.1", path = "common/nym-lp" }
nym-lp-data = { version = "1.21.1", path = "common/nym-lp-data" }
nym-kkt = { version = "1.21.1", path = "common/nym-kkt" }
nym-kkt-ciphersuite = { version = "1.21.1", path = "common/nym-kkt-ciphersuite" }
nym-kkt-context = { version = "1.21.1", path = "common/nym-kkt-context" }
nym-metrics = { version = "1.21.1", path = "common/nym-metrics" }
nym-mixnet-client = { version = "1.21.1", path = "common/client-libs/mixnet-client" }
nym-mixnet-contract-common = { version = "1.21.1", path = "common/cosmwasm-smart-contracts/mixnet-contract" }
nym-multisig-contract-common = { version = "1.21.1", path = "common/cosmwasm-smart-contracts/multisig-contract" }
nym-network-defaults = { version = "1.21.1", path = "common/network-defaults" }
nym-node-tester-utils = { version = "1.21.1", path = "common/node-tester-utils" }
nym-noise = { version = "1.21.1", path = "common/nymnoise" }
nym-noise-keys = { version = "1.21.1", path = "common/nymnoise/keys" }
nym-nonexhaustive-delayqueue = { version = "1.21.1", path = "common/nonexhaustive-delayqueue" }
nym-node-requests = { version = "1.21.1", path = "nym-node/nym-node-requests", default-features = false }
nym-node-metrics = { version = "1.21.1", path = "nym-node/nym-node-metrics" }
nym-node-families-contract-common = { version = "1.21.1", path = "common/cosmwasm-smart-contracts/node-families-contract" }
nym-ordered-buffer = { version = "1.21.1", path = "common/socks5/ordered-buffer" }
nym-outfox = { version = "1.21.1", path = "nym-outfox" }
nym-registration-common = { version = "1.21.1", path = "common/registration" }
nym-pemstore = { version = "1.21.1", path = "common/pemstore" }
nym-performance-contract-common = { version = "1.21.1", path = "common/cosmwasm-smart-contracts/nym-performance-contract" }
nym-sdk = { version = "1.21.1", path = "sdk/rust/nym-sdk" }
nym-serde-helpers = { version = "1.21.1", path = "common/serde-helpers" }
nym-service-providers-common = { version = "1.21.1", path = "service-providers/common" }
nym-service-provider-requests-common = { version = "1.21.1", path = "common/service-provider-requests-common" }
nym-socks5-client-core = { version = "1.21.1", path = "common/socks5-client-core" }
nym-socks5-proxy-helpers = { version = "1.21.1", path = "common/socks5/proxy-helpers" }
nym-socks5-requests = { version = "1.21.1", path = "common/socks5/requests" }
nym-sphinx = { version = "1.21.1", path = "common/nymsphinx" }
nym-sphinx-acknowledgements = { version = "1.21.1", path = "common/nymsphinx/acknowledgements" }
nym-sphinx-addressing = { version = "1.21.1", path = "common/nymsphinx/addressing" }
nym-sphinx-anonymous-replies = { version = "1.21.1", path = "common/nymsphinx/anonymous-replies" }
nym-sphinx-chunking = { version = "1.21.1", path = "common/nymsphinx/chunking" }
nym-sphinx-cover = { version = "1.21.1", path = "common/nymsphinx/cover" }
nym-sphinx-forwarding = { version = "1.21.1", path = "common/nymsphinx/forwarding" }
nym-sphinx-framing = { version = "1.21.1", path = "common/nymsphinx/framing" }
nym-sphinx-params = { version = "1.21.1", path = "common/nymsphinx/params" }
nym-sphinx-routing = { version = "1.21.1", path = "common/nymsphinx/routing" }
nym-sphinx-types = { version = "1.21.1", path = "common/nymsphinx/types" }
nym-statistics-common = { version = "1.21.1", path = "common/statistics" }
nym-store-cipher = { version = "1.21.1", path = "common/store-cipher" }
nym-task = { version = "1.21.1", path = "common/task" }
nym-tun = { version = "1.21.1", path = "common/tun" }
nym-test-utils = { version = "1.21.1", path = "common/test-utils" }
nym-ticketbooks-merkle = { version = "1.21.1", path = "common/ticketbooks-merkle" }
nym-topology = { version = "1.21.1", path = "common/topology" }
nym-types = { version = "1.21.1", path = "common/types" }
nym-upgrade-mode-check = { version = "1.21.1", path = "common/upgrade-mode-check" }
nym-validator-client = { version = "1.21.1", path = "common/client-libs/validator-client", default-features = false }
nym-vesting-contract-common = { version = "1.21.1", path = "common/cosmwasm-smart-contracts/vesting-contract" }
nym-network-monitors-contract-common = { version = "1.21.1", path = "common/cosmwasm-smart-contracts/network-monitors-contract" }
nym-verloc = { version = "1.21.1", path = "common/verloc" }
nym-wireguard = { version = "1.21.1", path = "common/wireguard" }
nym-wireguard-types = { version = "1.21.1", path = "common/wireguard-types" }
nym-wireguard-private-metadata-shared = { version = "1.21.1", path = "common/wireguard-private-metadata/shared" }
nym-wireguard-private-metadata-client = { version = "1.21.1", path = "common/wireguard-private-metadata/client" }
nym-wireguard-private-metadata-server = { version = "1.21.1", path = "common/wireguard-private-metadata/server" }
nym-sqlx-pool-guard = { version = "1.21.1", path = "nym-sqlx-pool-guard" }
nym-wasm-client-core = { version = "1.21.1", path = "common/wasm/client-core" }
nym-wasm-storage = { version = "1.21.1", path = "common/wasm/storage" }
nym-wasm-utils = { version = "1.21.1", path = "common/wasm/utils", default-features = false }
nyxd-scraper-shared = { version = "1.21.1", path = "common/nyxd-scraper-shared" }
smolmix = { version = "1.21.0", path = "smolmix/core" }
smolmix = { version = "1.21.1", path = "smolmix/core" }
# coconut/DKG related
# unfortunately until https://github.com/zkcrypto/nym-bls12_381-fork/issues/10 is resolved, we have to rely on the fork
+1 -1
View File
@@ -1,7 +1,7 @@
[package]
name = "nym-client"
description = "Implementation of the Nym Client"
version = "1.1.77"
version = "1.1.78"
authors = ["Dave Hrycyszyn <futurechimp@users.noreply.github.com>", "Jędrzej Stuczyński <andrew@nymtech.net>"]
edition = "2021"
license.workspace = true
+1 -1
View File
@@ -1,7 +1,7 @@
[package]
name = "nym-socks5-client"
description = "A SOCKS5 localhost proxy that converts incoming messages to Sphinx and sends them to a Nym address"
version = "1.1.77"
version = "1.1.78"
authors = ["Dave Hrycyszyn <futurechimp@users.noreply.github.com>"]
edition = "2021"
license.workspace = true
+3 -1
View File
@@ -17,6 +17,7 @@ publish = true
[dependencies]
dashmap = { workspace = true }
futures = { workspace = true }
strum = { workspace = true }
tracing = { workspace = true }
tokio = { workspace = true, features = ["time", "sync"] }
tokio-util = { workspace = true, features = ["codec"], optional = true }
@@ -26,10 +27,11 @@ tokio-stream = { workspace = true }
nym-noise = { workspace = true }
nym-sphinx = { workspace = true }
nym-task = { workspace = true, optional = true }
nym-metrics = { workspace = true, optional = true }
[features]
default = ["client"]
client = ["tokio-util", "nym-task", "tokio/net", "tokio/rt"]
client = ["tokio-util", "nym-task", "nym-metrics", "tokio/net", "tokio/rt"]
[dev-dependencies]
nym-crypto = { workspace = true }
+62 -19
View File
@@ -1,6 +1,7 @@
// Copyright 2021-2024 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::trace::{TraceStage, Traced};
use dashmap::DashMap;
use futures::{SinkExt, StreamExt};
use nym_noise::config::NoiseConfig;
@@ -52,8 +53,10 @@ impl Config {
pub trait SendWithoutResponse {
// Without response in this context means we will not listen for anything we might get back (not
// that we should get anything), including any possible io errors
fn send_without_response(&self, packet: MixPacket) -> io::Result<()>;
// that we should get anything), including any possible io errors.
// The packet carries the latency trace started upstream (at receive); the egress stages are
// stamped here and are a no-op for unsampled packets.
fn send_without_response(&self, packet: Traced<MixPacket>) -> io::Result<()>;
}
pub struct Client {
@@ -89,7 +92,7 @@ impl Deref for ActiveConnections {
}
pub struct ConnectionSender {
channel: mpsc::Sender<FramedNymPacket>,
channel: mpsc::Sender<Traced<FramedNymPacket>>,
current_reconnection_attempt: Arc<AtomicU32>,
// Identifies the `ManagedConnection` task currently owning this entry; used
// to ensure drop-time eviction only fires on the still-owning task.
@@ -97,7 +100,7 @@ pub struct ConnectionSender {
}
impl ConnectionSender {
fn new(channel: mpsc::Sender<FramedNymPacket>, handle_token: Arc<()>) -> Self {
fn new(channel: mpsc::Sender<Traced<FramedNymPacket>>, handle_token: Arc<()>) -> Self {
ConnectionSender {
channel,
current_reconnection_attempt: Arc::new(AtomicU32::new(0)),
@@ -109,7 +112,7 @@ impl ConnectionSender {
struct ManagedConnection {
address: SocketAddr,
noise_config: NoiseConfig,
message_receiver: ReceiverStream<FramedNymPacket>,
message_receiver: ReceiverStream<Traced<FramedNymPacket>>,
connection_timeout: Duration,
current_reconnection: Arc<AtomicU32>,
active_connections: ActiveConnections,
@@ -143,7 +146,7 @@ impl ManagedConnection {
fn new(
address: SocketAddr,
noise_config: NoiseConfig,
message_receiver: mpsc::Receiver<FramedNymPacket>,
message_receiver: mpsc::Receiver<Traced<FramedNymPacket>>,
connection_timeout: Duration,
current_reconnection: Arc<AtomicU32>,
active_connections: ActiveConnections,
@@ -218,6 +221,11 @@ impl ManagedConnection {
"Managed to establish connection to {}", self.address
);
// disable Nagle: mix packets are latency-sensitive and flushed one at a time.
if let Err(err) = stream.set_nodelay(true) {
warn!(peer = %address, error = %err, "failed to set TCP_NODELAY on outbound mixnet connection");
}
// 3. perform noise handshake (if applicable)
let noise_start = tokio::time::Instant::now();
let noise_stream = match upgrade_noise_initiator(stream, &self.noise_config).await {
@@ -246,25 +254,42 @@ impl ManagedConnection {
noise_handshake_ms,
"Noise initiator handshake completed for {:?}", address
);
let conn = Framed::new(noise_stream, NymCodec);
let mut conn = Framed::new(noise_stream, NymCodec);
// let the write buffer accumulate several packets before flushing (see run_io_loop)
conn.set_backpressure_boundary(OUTBOUND_WRITE_BUFFER);
// 4. start handling the framed stream
run_io_loop(conn, self.message_receiver, address).await;
}
}
/// Upper bound on how many already-queued packets we drain into a single flush.
/// Bounds the per-batch allocation and how often we re-check the read side; the actual
/// write coalescing is governed by the Framed backpressure boundary below.
const OUTBOUND_FLUSH_BATCH: usize = 1024;
/// Write-buffer high-water mark for the egress `Framed`: packets are coalesced up to
/// roughly this many bytes before a flush, trading a larger write burst for far fewer
/// syscalls (and noise frames) under load. Kept under the ~64KiB noise frame ceiling so
/// a flush is usually a single frame.
const OUTBOUND_WRITE_BUFFER: usize = 32 * 1024;
// The connection is unidirectional (send-only); we read from it solely to
// notice peer FIN/RST while idle so we can evict the cache entry before the
// next outbound send finds it stale.
async fn run_io_loop<T>(
conn: Framed<T, NymCodec>,
mut receiver: ReceiverStream<FramedNymPacket>,
receiver: ReceiverStream<Traced<FramedNymPacket>>,
address: SocketAddr,
) where
T: AsyncRead + AsyncWrite + Unpin,
{
let (mut sink, mut stream) = conn.split();
// drain all currently-queued packets into one flush rather than flushing per packet,
// which otherwise caps egress throughput and backs up the per-connection queue under load
let mut receiver = receiver.ready_chunks(OUTBOUND_FLUSH_BATCH);
loop {
tokio::select! {
msg = stream.next() => {
@@ -305,14 +330,32 @@ async fn run_io_loop<T>(
);
break;
}
Some(packet) => {
if let Err(err) = sink.send(packet).await {
Some(batch) => {
// feed the whole ready batch, then flush once
let mut traces = Vec::with_capacity(batch.len());
let res = async {
for mut traced in batch {
// time spent waiting in this connection's egress buffer
traced.record(TraceStage::EgressQueue);
sink.feed(traced.inner).await?;
traces.push(traced.trace);
}
sink.flush().await
}
.await;
// after the batch hit the wire: socket-write time and end-to-end total
for mut trace in traces {
trace.record(TraceStage::SocketWrite);
trace.record_total();
}
if let Err(err) = res {
debug!(
event = "connection.forward_error",
peer = %address,
error = %err,
exit_reason = "forward_error",
"Failed to forward packet to {address}: {err}"
"failed to forward packet batch to {address}: {err}"
);
break;
}
@@ -358,7 +401,7 @@ impl Client {
}
}
fn make_connection(&self, address: SocketAddr, pending_packet: FramedNymPacket) {
fn make_connection(&self, address: SocketAddr, pending_packet: Traced<FramedNymPacket>) {
let (sender, receiver) = mpsc::channel(self.config.maximum_connection_buffer_size);
// this CAN'T fail because we just created the channel which has a non-zero capacity
@@ -418,14 +461,14 @@ impl Client {
}
impl SendWithoutResponse for Client {
fn send_without_response(&self, packet: MixPacket) -> io::Result<()> {
let address = packet.next_hop_address();
fn send_without_response(&self, packet: Traced<MixPacket>) -> io::Result<()> {
let address = packet.inner.next_hop_address();
trace!("Sending packet to {address}");
// TODO: optimisation for the future: rather than constantly using legacy encoding,
// use the mix packet type / flags to pick encoding per packet
let framed_packet =
FramedNymPacket::from_mix_packet(packet, self.config.use_legacy_packet_encoding);
let legacy = self.config.use_legacy_packet_encoding;
let queued = packet.map(|p| FramedNymPacket::from_mix_packet(p, legacy));
let Some(sender) = self.active_connections.get_mut(&address) else {
// there was never a connection to begin with
@@ -435,7 +478,7 @@ impl SendWithoutResponse for Client {
result = "not_connected",
"establishing initial connection to {address}"
);
self.make_connection(address, framed_packet);
self.make_connection(address, queued);
return Err(io::Error::new(
io::ErrorKind::NotConnected,
"connection is in progress",
@@ -446,7 +489,7 @@ impl SendWithoutResponse for Client {
let channel_available = sender.channel.capacity();
let channel_used = channel_capacity - channel_available;
let sending_res = sender.channel.try_send(framed_packet);
let sending_res = sender.channel.try_send(queued);
drop(sender);
sending_res.map_err(|err| {
@@ -547,7 +590,7 @@ mod tests {
active: &ActiveConnections,
addr: SocketAddr,
token: Arc<()>,
) -> mpsc::Receiver<FramedNymPacket> {
) -> mpsc::Receiver<Traced<FramedNymPacket>> {
let (tx, rx) = mpsc::channel(1);
active.insert(addr, ConnectionSender::new(tx, token));
rx
@@ -1,6 +1,7 @@
// Copyright 2021 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::trace::PacketTrace;
use futures::channel::mpsc;
use futures::channel::mpsc::SendError;
use nym_sphinx::forwarding::packet::MixPacket;
@@ -43,6 +44,9 @@ pub struct PacketToForward {
pub packet: MixPacket,
pub forward_delay_target: Option<Instant>,
pub network_monitor_packet: bool,
/// Latency breadcrumb started at packet receive; stamped as the packet moves through the
/// forwarder and egress stages. `PacketTrace::Off` for untraced packets (e.g. acks).
pub trace: PacketTrace,
}
impl PacketToForward {
@@ -50,15 +54,17 @@ impl PacketToForward {
packet: MixPacket,
forward_delay_target: Option<Instant>,
network_monitor_packet: bool,
trace: PacketTrace,
) -> Self {
PacketToForward {
packet,
forward_delay_target,
network_monitor_packet,
trace,
}
}
pub fn client_packet_without_delay(packet: MixPacket) -> Self {
Self::new(packet, None, false)
Self::new(packet, None, false, PacketTrace::Off)
}
}
@@ -4,6 +4,7 @@
#[cfg(feature = "client")]
pub mod client;
pub mod forwarder;
pub mod trace;
#[cfg(feature = "client")]
pub use client::{Client, Config, SendWithoutResponse};
@@ -0,0 +1,225 @@
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use strum::{AsRefStr, EnumIter, EnumProperty, IntoEnumIterator};
use tokio::time::Instant;
/// Histogram buckets (seconds) for per-stage and total packet latency: exponential,
/// ~100us .. ~1.6s. Shared by every stage so the waterfall is directly comparable.
const STAGE_LATENCY_BUCKETS: [f64; 14] = [
0.0001, 0.0002, 0.0004, 0.0008, 0.0016, 0.0032, 0.0064, 0.0128, 0.0256, 0.0512, 0.1024, 0.2048,
0.4096, 0.8192,
];
/// A stage in the packet-forwarding pipeline, in order. Each maps to its own latency histogram
/// (`AsRefStr` = metric name, `help` prop = description); `Total` is the end-to-end
/// receive -> socket-write time. Defined here so call sites just name the stage.
#[derive(Clone, Copy, EnumIter, AsRefStr, EnumProperty)]
pub enum TraceStage {
/// receive -> sphinx unwrap (partial: shared secret + header MAC)
#[strum(to_string = "mixnet_packet_stage_unwrap_seconds")]
#[strum(props(help = "Seconds spent unwrapping a received sphinx packet"))]
Unwrap,
/// unwrap -> replay-check + finalise (includes the deferral wait)
#[strum(to_string = "mixnet_packet_stage_replay_check_seconds")]
#[strum(props(
help = "Seconds from partial-unwrap to replay-check + finalise (includes the deferral wait)"
))]
ReplayCheck,
/// wait in the ingress -> forwarder channel
#[strum(to_string = "mixnet_packet_stage_forwarder_queue_seconds")]
#[strum(props(
help = "Seconds a forwarded packet waited in the ingress-to-forwarder channel"
))]
ForwarderQueue,
/// the (intended) mix delay
#[strum(to_string = "mixnet_packet_stage_delay_queue_seconds")]
#[strum(props(help = "Seconds a forwarded packet spent in the (intended) mix delay queue"))]
DelayQueue,
/// diagnostic overlay on `DelayQueue`: how late beyond the target release the packet was
/// actually forwarded (delay-queue scheduling/retrieval overhead, measured vs the deadline)
#[strum(to_string = "mixnet_packet_stage_delay_queue_overrun_seconds")]
#[strum(props(
help = "Seconds a delayed packet was forwarded beyond its target release time (delay-queue scheduling/retrieval overhead)"
))]
DelayQueueOverrun,
/// wait in the per-connection egress buffer
#[strum(to_string = "mixnet_packet_stage_egress_queue_seconds")]
#[strum(props(
help = "Seconds a forwarded packet waited in the per-connection egress buffer"
))]
EgressQueue,
/// flushing the packet batch to the socket
#[strum(to_string = "mixnet_packet_stage_socket_write_seconds")]
#[strum(props(help = "Seconds spent flushing a forwarded packet batch to the socket"))]
SocketWrite,
/// end-to-end: receive -> socket write
#[strum(to_string = "mixnet_packet_total_latency_seconds")]
#[strum(props(help = "Total in-node latency of a forwarded packet, receive to socket write"))]
Total,
}
/// Pre-register every stage histogram (at zero) into the global metrics registry so the whole
/// `mixnet_packet_*` family is present on the prometheus endpoint from boot, before any sampled
/// packet has been observed. Idempotent.
pub fn register_stage_metrics() {
let registry = nym_metrics::metrics_registry();
for stage in TraceStage::iter() {
registry.register_histogram(
stage.as_ref(),
stage.get_str("help"),
Some(STAGE_LATENCY_BUCKETS.as_slice()),
);
}
}
/// Observe a stage latency into the process-global metrics registry. Explicit metric name (no
/// per-crate prefix) so every stage lands in one uniform `mixnet_packet_*` family regardless of
/// which crate records it.
fn observe(stage: TraceStage, secs: f64) {
nym_metrics::metrics_registry().maybe_register_and_add_to_histogram(
stage.as_ref(),
secs,
Some(STAGE_LATENCY_BUCKETS.as_slice()),
stage.get_str("help"),
);
}
/// A lightweight per-packet stopwatch for attributing forwarding latency to pipeline
/// stages. Unsampled packets carry the `Off` variant and do zero clock reads, so the only
/// cost on the hot path is moving a small `Copy` value and a branch.
#[derive(Clone, Copy)]
pub enum PacketTrace {
Off,
On {
received_at: Instant,
stage_at: Instant,
},
}
impl PacketTrace {
/// Begin tracing. Reads the clock only for sampled packets.
pub fn start(sampled: bool) -> Self {
if sampled {
let now = Instant::now();
PacketTrace::On {
received_at: now,
stage_at: now,
}
} else {
PacketTrace::Off
}
}
/// Seconds spent in the stage just completed, advancing the cursor to now.
/// Returns `None` for unsampled packets.
fn lap(&mut self) -> Option<f64> {
match self {
PacketTrace::Off => None,
PacketTrace::On { stage_at, .. } => {
let now = Instant::now();
let secs = now.duration_since(*stage_at).as_secs_f64();
*stage_at = now;
Some(secs)
}
}
}
/// Seconds since tracing began (i.e. since the packet was received), or `None` if unsampled.
fn total(&self) -> Option<f64> {
match self {
PacketTrace::Off => None,
PacketTrace::On { received_at, .. } => {
Some(Instant::now().duration_since(*received_at).as_secs_f64())
}
}
}
/// Close out the stage just completed: lap the timer and, only if the packet is sampled,
/// observe `stage`'s latency histogram.
pub fn record(&mut self, stage: TraceStage) {
if let Some(secs) = self.lap() {
observe(stage, secs);
}
}
/// Observe the end-to-end [`TraceStage::Total`] latency (since receive) if sampled. Unlike
/// [`PacketTrace::record`] this does not lap, so it can be called at the very end.
pub fn record_total(&self) {
if let Some(secs) = self.total() {
observe(TraceStage::Total, secs);
}
}
/// Observe an explicit `secs` value for `stage` if the packet is sampled, without lapping the
/// stage cursor. For diagnostics that don't fit the sequential waterfall (e.g. delay-queue
/// overrun, measured against the target deadline rather than the previous stage).
pub fn record_value(&self, stage: TraceStage, secs: f64) {
if matches!(self, PacketTrace::On { .. }) {
observe(stage, secs);
}
}
}
/// A value paired with its in-flight latency trace, so the trace rides along as the value is
/// moved between pipeline stages (and transformed via [`Traced::map`]). Used wherever a packet
/// crosses a queue/channel: replay batch, delay queue, egress channel.
pub struct Traced<T> {
pub inner: T,
pub trace: PacketTrace,
}
impl<T> Traced<T> {
pub fn new(inner: T, trace: PacketTrace) -> Self {
Traced { inner, trace }
}
/// Transform the carried value, keeping the same trace.
pub fn map<U>(self, f: impl FnOnce(T) -> U) -> Traced<U> {
Traced {
inner: f(self.inner),
trace: self.trace,
}
}
/// Record the stage just completed for the carried trace (see [`PacketTrace::record`]).
pub fn record(&mut self, stage: TraceStage) {
self.trace.record(stage)
}
/// Observe an explicit value for the carried trace (see [`PacketTrace::record_value`]).
pub fn record_value(&self, stage: TraceStage, secs: f64) {
self.trace.record_value(stage, secs)
}
}
#[cfg(test)]
mod tests {
use super::*;
// guards that AsRefStr honours `#[strum(to_string = ...)]` (rather than falling back to the
// variant name) and that every stage carries a help string.
#[test]
fn every_stage_has_a_mixnet_packet_name_and_help() {
for stage in TraceStage::iter() {
assert!(
stage.as_ref().starts_with("mixnet_packet_"),
"unexpected metric name: {}",
stage.as_ref()
);
assert!(
stage.get_str("help").is_some(),
"missing help for {}",
stage.as_ref()
);
}
assert_eq!(
TraceStage::Unwrap.as_ref(),
"mixnet_packet_stage_unwrap_seconds"
);
assert_eq!(
TraceStage::Total.as_ref(),
"mixnet_packet_total_latency_seconds"
);
}
}
@@ -61,9 +61,12 @@ pub struct NodeFamily {
/// A pending invitation for a node to join a particular family.
///
/// Invitations are stored until they are accepted, rejected, revoked, or until the
/// chain advances past `expires_at` (in which case they remain in storage but are
/// treated as inert — there is no background process clearing expired invitations).
/// Invitations are stored until they are accepted, rejected, or revoked. Once the
/// chain advances past `expires_at` an invitation becomes inert but stays in storage
/// — there is no background process clearing expired invitations. A timed-out
/// invitation is cleared either when explicitly revoked/rejected, or when the family
/// issues a fresh invitation for the same node, which archives the stale one as
/// `Expired` and supersedes it.
#[cw_serde]
pub struct FamilyInvitation {
/// The family that issued the invitation.
@@ -107,8 +110,10 @@ pub struct PastFamilyMember {
/// Terminal status for an invitation that has been moved out of the pending set.
///
/// Note: timed-out invitations are not represented here — they are simply left in
/// the pending set (see `FamilyInvitation::expires_at`).
/// Note: an invitation that merely times out is **not** archived here on its own —
/// it is left inert in the pending set (see `FamilyInvitation::expires_at`). It only
/// reaches `Expired` if the family issues a fresh invitation for the same node, which
/// supersedes and archives the stale one.
#[cw_serde]
pub enum FamilyInvitationStatus {
/// Still awaiting a response. Recorded with a timestamp for completeness even
@@ -121,11 +126,16 @@ pub enum FamilyInvitationStatus {
/// The family revoked the invitation at the given timestamp before it could
/// be accepted or rejected.
Revoked { at: u64 },
/// The invitation had already expired and was superseded by a fresh invitation
/// for the same node from the same family, issued at the given timestamp. This is
/// the only path that archives a timed-out invitation.
Expired { at: u64 },
}
/// Historical record of an invitation that has reached a terminal state
/// (`Accepted`, `Rejected`, or `Revoked`). Timed-out invitations are **not**
/// archived here — they remain in the pending map until explicitly cleared.
/// (`Accepted`, `Rejected`, `Revoked`, or `Expired`). A timed-out invitation is
/// archived here only when a fresh invitation for the same node supersedes it
/// (status `Expired`); otherwise it stays in the pending map until explicitly cleared.
#[cw_serde]
pub struct PastFamilyInvitation {
/// The original invitation as it was issued.
+2 -4
View File
@@ -24,10 +24,8 @@ pub const PERFORMANCE_CONTRACT_ADDRESS: &str = "";
pub const NETWORK_MONITORS_CONTRACT_ADDRESS: &str =
"n1m3a2ltkjqud8mkmrpqvgllrtv2p4r6js6qwl7p8cqkzrq8jg6e2qwqgl8z";
// \/ TODO: this has to be updated once the contract is deployed
pub const NODE_FAMILIES_CONTRACT_ADDRESS: &str = "";
// /\ TODO: this has to be updated once the contract is deployed
pub const NODE_FAMILIES_CONTRACT_ADDRESS: &str =
"n1na0vys0z077hq3zrz6pfea85zgv8ks3t5zysdt6y38c87q045hnsyf2g5x";
pub const ECASH_CONTRACT_ADDRESS: &str =
"n1r7s6aksyc6pqardx88k3rkgfagwvj4z4zum9mmz2sfk3zm2mha0sd4dnun";
pub const GROUP_CONTRACT_ADDRESS: &str =
+1 -1
View File
@@ -1,7 +1,7 @@
[package]
name = "nym-kkt"
description = "Key transport protocol for the Nym network"
version = "1.21.0"
version = "1.21.1"
authors = ["Georgio Nicolas <georgio@nymtech.net>"]
edition = { workspace = true }
license.workspace = true
@@ -14,6 +14,7 @@ use tokio::sync::mpsc::Receiver;
#[derive(Hash, PartialOrd, PartialEq, Clone, Debug, Eq, Copy)]
pub enum PeerControlRequestTypeV2 {
AddPeer,
UpdatePeerPsk,
RemovePeer,
QueryPeer,
GetClientBandwidthByKey,
@@ -26,6 +27,7 @@ impl From<&PeerControlRequest> for PeerControlRequestTypeV2 {
fn from(req: &PeerControlRequest) -> Self {
match req {
PeerControlRequest::AddPeer { .. } => PeerControlRequestTypeV2::AddPeer,
PeerControlRequest::UpdatePeerPsk { .. } => PeerControlRequestTypeV2::UpdatePeerPsk,
PeerControlRequest::PreAllocateIpPair { .. } => PeerControlRequestTypeV2::AddPeer,
PeerControlRequest::RemovePeer { .. } => PeerControlRequestTypeV2::RemovePeer,
PeerControlRequest::QueryPeer { .. } => PeerControlRequestTypeV2::QueryPeer,
@@ -115,6 +117,15 @@ impl MockPeerControllerV2 {
)
.unwrap();
}
PeerControlRequest::UpdatePeerPsk { response_tx, .. } => {
response_tx
.send(
*response
.downcast()
.expect("registered response has mismatched type"),
)
.unwrap();
}
PeerControlRequest::PreAllocateIpPair { response_tx, .. } => {
response_tx
.send(
@@ -71,6 +71,7 @@ impl From<&Key> for KeyWrapper {
#[derive(Hash, PartialOrd, PartialEq, Clone, Debug, Eq)]
pub enum PeerControlRequestType {
AddPeer { public_key: KeyWrapper },
UpdatePeerPsk { peer_key: KeyWrapper },
AllocatePeerIpPair {},
ReleaseIpPair { ip_pair: IpPair },
RemovePeer { key: KeyWrapper },
@@ -86,6 +87,7 @@ impl PeerControlRequestType {
pub fn peer_key(&self) -> Option<KeyWrapper> {
match self {
PeerControlRequestType::AddPeer { public_key } => Some(public_key.clone()),
PeerControlRequestType::UpdatePeerPsk { peer_key } => Some(peer_key.clone()),
PeerControlRequestType::AllocatePeerIpPair {} => None,
PeerControlRequestType::ReleaseIpPair { .. } => None,
PeerControlRequestType::RemovePeer { key } => Some(key.clone()),
@@ -109,6 +111,11 @@ impl From<&PeerControlRequest> for PeerControlRequestType {
PeerControlRequest::AddPeer { peer, .. } => PeerControlRequestType::AddPeer {
public_key: (&peer.public_key).into(),
},
PeerControlRequest::UpdatePeerPsk { peer_key, .. } => {
PeerControlRequestType::UpdatePeerPsk {
peer_key: peer_key.into(),
}
}
PeerControlRequest::PreAllocateIpPair { .. } => {
PeerControlRequestType::AllocatePeerIpPair {}
}
@@ -271,6 +278,9 @@ impl MockPeerController {
}
response_tx.send_downcasted(response.content)
}
PeerControlRequest::UpdatePeerPsk { response_tx, .. } => {
response_tx.send_downcasted(response.content)
}
PeerControlRequest::PreAllocateIpPair { response_tx, .. } => {
response_tx.send_downcasted(response.content)
}
@@ -76,6 +76,12 @@ pub enum PeerControlRequest {
peer: Peer,
response_tx: oneshot::Sender<AddPeerControlResponse>,
},
/// Update PSK for an existing peer, without changing its IP allocation
UpdatePeerPsk {
peer_key: Key,
psk: Key,
response_tx: oneshot::Sender<UpdatePeerPskControlResponse>,
},
/// Attempt to allocate an IP pair from the pool
PreAllocateIpPair {
response_tx: oneshot::Sender<AllocatePeerControlResponse>,
@@ -118,6 +124,7 @@ pub enum PeerControlRequest {
}
pub type AddPeerControlResponse = Result<()>;
pub type UpdatePeerPskControlResponse = Result<()>;
pub type AllocatePeerControlResponse = Result<IpPair>;
pub type ReleaseIpPairControlResponse = Result<()>;
pub type RemovePeerControlResponse = Result<()>;
@@ -317,6 +324,50 @@ impl PeerController {
Ok(())
}
async fn handle_update_peer_psk_request(&mut self, peer_key: &Key, psk: Key) -> Result<()> {
// observation will get automatically added once dropped
let _metric_timer =
PROMETHEUS_METRICS.start_timer(PrometheusMetric::WireguardDefguardPeerPskUpdate);
nym_metrics::inc!("wg_peer_update_psk_attempts");
let Ok(Some(mut peer)) = self.handle_query_peer_by_key(peer_key).await else {
return Ok(());
};
let encoded_psk = psk.to_lower_hex();
peer.preshared_key = Some(psk);
// Account for bandwidth used so far *before* reconfiguring: `configure_peer`
// isn't guaranteed to preserve the kernel rx/tx counters, so fold the
// accrued bytes into the metrics first to avoid losing them on a reset.
if let Ok(host) = self.wg_api.read_interface_data() {
self.update_metrics(&host).await;
*self.host_information.write().await = host;
}
// Try to update WireGuard peer
if let Err(e) = self.wg_api.configure_peer(&peer) {
nym_metrics::inc!("wg_peer_update_psk_failed");
nym_metrics::inc!("wg_config_errors_total");
return Err(e.into());
};
// Persist the new PSK to disk so it survives a restart. Kernel-first: a
// failure here leaves the live session working, only risking drift on restart.
self.ecash_verifier
.storage()
.update_peer_psk(&peer_key.to_string(), Some(&encoded_psk))
.await?;
// Refresh again so the cached host information reflects the post-update state
if let Ok(host) = self.wg_api.read_interface_data() {
*self.host_information.write().await = host;
}
nym_metrics::inc!("wg_peer_update_psk_success");
Ok(())
}
/// Allocate IP pair from pool for a new peer registration
///
/// This only allocates IPs - the caller must handle database storage and
@@ -513,6 +564,15 @@ impl PeerController {
PeerControlRequest::AddPeer { peer, response_tx } => {
response_tx.send(self.handle_add_request(&peer).await).ok();
}
PeerControlRequest::UpdatePeerPsk {
peer_key,
psk,
response_tx,
} => {
response_tx
.send(self.handle_update_peer_psk_request(&peer_key, psk).await)
.ok();
}
PeerControlRequest::PreAllocateIpPair { response_tx } => {
response_tx.send(self.handle_ip_allocation_request()).ok();
}
+278 -265
View File
File diff suppressed because it is too large Load Diff
@@ -1188,7 +1188,7 @@
"additionalProperties": false,
"definitions": {
"FamilyInvitation": {
"description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, revoked, or until the chain advances past `expires_at` (in which case they remain in storage but are treated as inert — there is no background process clearing expired invitations).",
"description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, or revoked. Once the chain advances past `expires_at` an invitation becomes inert but stays in storage — there is no background process clearing expired invitations. A timed-out invitation is cleared either when explicitly revoked/rejected, or when the family issues a fresh invitation for the same node, which archives the stale one as `Expired` and supersedes it.",
"type": "object",
"required": [
"expires_at",
@@ -1218,7 +1218,7 @@
"additionalProperties": false
},
"FamilyInvitationStatus": {
"description": "Terminal status for an invitation that has been moved out of the pending set.\n\nNote: timed-out invitations are not represented here — they are simply left in the pending set (see `FamilyInvitation::expires_at`).",
"description": "Terminal status for an invitation that has been moved out of the pending set.\n\nNote: an invitation that merely times out is **not** archived here on its own — it is left inert in the pending set (see `FamilyInvitation::expires_at`). It only reaches `Expired` if the family issues a fresh invitation for the same node, which supersedes and archives the stale one.",
"oneOf": [
{
"description": "Still awaiting a response. Recorded with a timestamp for completeness even though pending invitations live in a separate map.",
@@ -1315,11 +1315,35 @@
}
},
"additionalProperties": false
},
{
"description": "The invitation had already expired and was superseded by a fresh invitation for the same node from the same family, issued at the given timestamp. This is the only path that archives a timed-out invitation.",
"type": "object",
"required": [
"expired"
],
"properties": {
"expired": {
"type": "object",
"required": [
"at"
],
"properties": {
"at": {
"type": "integer",
"format": "uint64",
"minimum": 0.0
}
},
"additionalProperties": false
}
},
"additionalProperties": false
}
]
},
"PastFamilyInvitation": {
"description": "Historical record of an invitation that has reached a terminal state (`Accepted`, `Rejected`, or `Revoked`). Timed-out invitations are **not** archived here — they remain in the pending map until explicitly cleared.",
"description": "Historical record of an invitation that has reached a terminal state (`Accepted`, `Rejected`, `Revoked`, or `Expired`). A timed-out invitation is archived here only when a fresh invitation for the same node supersedes it (status `Expired`); otherwise it stays in the pending map until explicitly cleared.",
"type": "object",
"required": [
"invitation",
@@ -1388,7 +1412,7 @@
"additionalProperties": false,
"definitions": {
"FamilyInvitation": {
"description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, revoked, or until the chain advances past `expires_at` (in which case they remain in storage but are treated as inert — there is no background process clearing expired invitations).",
"description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, or revoked. Once the chain advances past `expires_at` an invitation becomes inert but stays in storage — there is no background process clearing expired invitations. A timed-out invitation is cleared either when explicitly revoked/rejected, or when the family issues a fresh invitation for the same node, which archives the stale one as `Expired` and supersedes it.",
"type": "object",
"required": [
"expires_at",
@@ -2073,7 +2097,7 @@
"additionalProperties": false,
"definitions": {
"FamilyInvitation": {
"description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, revoked, or until the chain advances past `expires_at` (in which case they remain in storage but are treated as inert — there is no background process clearing expired invitations).",
"description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, or revoked. Once the chain advances past `expires_at` an invitation becomes inert but stays in storage — there is no background process clearing expired invitations. A timed-out invitation is cleared either when explicitly revoked/rejected, or when the family issues a fresh invitation for the same node, which archives the stale one as `Expired` and supersedes it.",
"type": "object",
"required": [
"expires_at",
@@ -2103,7 +2127,7 @@
"additionalProperties": false
},
"FamilyInvitationStatus": {
"description": "Terminal status for an invitation that has been moved out of the pending set.\n\nNote: timed-out invitations are not represented here — they are simply left in the pending set (see `FamilyInvitation::expires_at`).",
"description": "Terminal status for an invitation that has been moved out of the pending set.\n\nNote: an invitation that merely times out is **not** archived here on its own — it is left inert in the pending set (see `FamilyInvitation::expires_at`). It only reaches `Expired` if the family issues a fresh invitation for the same node, which supersedes and archives the stale one.",
"oneOf": [
{
"description": "Still awaiting a response. Recorded with a timestamp for completeness even though pending invitations live in a separate map.",
@@ -2200,11 +2224,35 @@
}
},
"additionalProperties": false
},
{
"description": "The invitation had already expired and was superseded by a fresh invitation for the same node from the same family, issued at the given timestamp. This is the only path that archives a timed-out invitation.",
"type": "object",
"required": [
"expired"
],
"properties": {
"expired": {
"type": "object",
"required": [
"at"
],
"properties": {
"at": {
"type": "integer",
"format": "uint64",
"minimum": 0.0
}
},
"additionalProperties": false
}
},
"additionalProperties": false
}
]
},
"PastFamilyInvitation": {
"description": "Historical record of an invitation that has reached a terminal state (`Accepted`, `Rejected`, or `Revoked`). Timed-out invitations are **not** archived here — they remain in the pending map until explicitly cleared.",
"description": "Historical record of an invitation that has reached a terminal state (`Accepted`, `Rejected`, `Revoked`, or `Expired`). A timed-out invitation is archived here only when a fresh invitation for the same node supersedes it (status `Expired`); otherwise it stays in the pending map until explicitly cleared.",
"type": "object",
"required": [
"invitation",
@@ -2280,7 +2328,7 @@
"additionalProperties": false,
"definitions": {
"FamilyInvitation": {
"description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, revoked, or until the chain advances past `expires_at` (in which case they remain in storage but are treated as inert — there is no background process clearing expired invitations).",
"description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, or revoked. Once the chain advances past `expires_at` an invitation becomes inert but stays in storage — there is no background process clearing expired invitations. A timed-out invitation is cleared either when explicitly revoked/rejected, or when the family issues a fresh invitation for the same node, which archives the stale one as `Expired` and supersedes it.",
"type": "object",
"required": [
"expires_at",
@@ -2310,7 +2358,7 @@
"additionalProperties": false
},
"FamilyInvitationStatus": {
"description": "Terminal status for an invitation that has been moved out of the pending set.\n\nNote: timed-out invitations are not represented here — they are simply left in the pending set (see `FamilyInvitation::expires_at`).",
"description": "Terminal status for an invitation that has been moved out of the pending set.\n\nNote: an invitation that merely times out is **not** archived here on its own — it is left inert in the pending set (see `FamilyInvitation::expires_at`). It only reaches `Expired` if the family issues a fresh invitation for the same node, which supersedes and archives the stale one.",
"oneOf": [
{
"description": "Still awaiting a response. Recorded with a timestamp for completeness even though pending invitations live in a separate map.",
@@ -2407,11 +2455,35 @@
}
},
"additionalProperties": false
},
{
"description": "The invitation had already expired and was superseded by a fresh invitation for the same node from the same family, issued at the given timestamp. This is the only path that archives a timed-out invitation.",
"type": "object",
"required": [
"expired"
],
"properties": {
"expired": {
"type": "object",
"required": [
"at"
],
"properties": {
"at": {
"type": "integer",
"format": "uint64",
"minimum": 0.0
}
},
"additionalProperties": false
}
},
"additionalProperties": false
}
]
},
"PastFamilyInvitation": {
"description": "Historical record of an invitation that has reached a terminal state (`Accepted`, `Rejected`, or `Revoked`). Timed-out invitations are **not** archived here — they remain in the pending map until explicitly cleared.",
"description": "Historical record of an invitation that has reached a terminal state (`Accepted`, `Rejected`, `Revoked`, or `Expired`). A timed-out invitation is archived here only when a fresh invitation for the same node supersedes it (status `Expired`); otherwise it stays in the pending map until explicitly cleared.",
"type": "object",
"required": [
"invitation",
@@ -2634,7 +2706,7 @@
"additionalProperties": false,
"definitions": {
"FamilyInvitation": {
"description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, revoked, or until the chain advances past `expires_at` (in which case they remain in storage but are treated as inert — there is no background process clearing expired invitations).",
"description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, or revoked. Once the chain advances past `expires_at` an invitation becomes inert but stays in storage — there is no background process clearing expired invitations. A timed-out invitation is cleared either when explicitly revoked/rejected, or when the family issues a fresh invitation for the same node, which archives the stale one as `Expired` and supersedes it.",
"type": "object",
"required": [
"expires_at",
@@ -2724,7 +2796,7 @@
"additionalProperties": false,
"definitions": {
"FamilyInvitation": {
"description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, revoked, or until the chain advances past `expires_at` (in which case they remain in storage but are treated as inert — there is no background process clearing expired invitations).",
"description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, or revoked. Once the chain advances past `expires_at` an invitation becomes inert but stays in storage — there is no background process clearing expired invitations. A timed-out invitation is cleared either when explicitly revoked/rejected, or when the family issues a fresh invitation for the same node, which archives the stale one as `Expired` and supersedes it.",
"type": "object",
"required": [
"expires_at",
@@ -2814,7 +2886,7 @@
"additionalProperties": false,
"definitions": {
"FamilyInvitation": {
"description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, revoked, or until the chain advances past `expires_at` (in which case they remain in storage but are treated as inert — there is no background process clearing expired invitations).",
"description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, or revoked. Once the chain advances past `expires_at` an invitation becomes inert but stays in storage — there is no background process clearing expired invitations. A timed-out invitation is cleared either when explicitly revoked/rejected, or when the family issues a fresh invitation for the same node, which archives the stale one as `Expired` and supersedes it.",
"type": "object",
"required": [
"expires_at",
@@ -51,7 +51,7 @@
"additionalProperties": false,
"definitions": {
"FamilyInvitation": {
"description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, revoked, or until the chain advances past `expires_at` (in which case they remain in storage but are treated as inert — there is no background process clearing expired invitations).",
"description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, or revoked. Once the chain advances past `expires_at` an invitation becomes inert but stays in storage — there is no background process clearing expired invitations. A timed-out invitation is cleared either when explicitly revoked/rejected, or when the family issues a fresh invitation for the same node, which archives the stale one as `Expired` and supersedes it.",
"type": "object",
"required": [
"expires_at",
@@ -81,7 +81,7 @@
"additionalProperties": false
},
"FamilyInvitationStatus": {
"description": "Terminal status for an invitation that has been moved out of the pending set.\n\nNote: timed-out invitations are not represented here — they are simply left in the pending set (see `FamilyInvitation::expires_at`).",
"description": "Terminal status for an invitation that has been moved out of the pending set.\n\nNote: an invitation that merely times out is **not** archived here on its own — it is left inert in the pending set (see `FamilyInvitation::expires_at`). It only reaches `Expired` if the family issues a fresh invitation for the same node, which supersedes and archives the stale one.",
"oneOf": [
{
"description": "Still awaiting a response. Recorded with a timestamp for completeness even though pending invitations live in a separate map.",
@@ -178,11 +178,35 @@
}
},
"additionalProperties": false
},
{
"description": "The invitation had already expired and was superseded by a fresh invitation for the same node from the same family, issued at the given timestamp. This is the only path that archives a timed-out invitation.",
"type": "object",
"required": [
"expired"
],
"properties": {
"expired": {
"type": "object",
"required": [
"at"
],
"properties": {
"at": {
"type": "integer",
"format": "uint64",
"minimum": 0.0
}
},
"additionalProperties": false
}
},
"additionalProperties": false
}
]
},
"PastFamilyInvitation": {
"description": "Historical record of an invitation that has reached a terminal state (`Accepted`, `Rejected`, or `Revoked`). Timed-out invitations are **not** archived here — they remain in the pending map until explicitly cleared.",
"description": "Historical record of an invitation that has reached a terminal state (`Accepted`, `Rejected`, `Revoked`, or `Expired`). A timed-out invitation is archived here only when a fresh invitation for the same node supersedes it (status `Expired`); otherwise it stays in the pending map until explicitly cleared.",
"type": "object",
"required": [
"invitation",
@@ -39,7 +39,7 @@
"additionalProperties": false,
"definitions": {
"FamilyInvitation": {
"description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, revoked, or until the chain advances past `expires_at` (in which case they remain in storage but are treated as inert — there is no background process clearing expired invitations).",
"description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, or revoked. Once the chain advances past `expires_at` an invitation becomes inert but stays in storage — there is no background process clearing expired invitations. A timed-out invitation is cleared either when explicitly revoked/rejected, or when the family issues a fresh invitation for the same node, which archives the stale one as `Expired` and supersedes it.",
"type": "object",
"required": [
"expires_at",
@@ -46,7 +46,7 @@
"additionalProperties": false,
"definitions": {
"FamilyInvitation": {
"description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, revoked, or until the chain advances past `expires_at` (in which case they remain in storage but are treated as inert — there is no background process clearing expired invitations).",
"description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, or revoked. Once the chain advances past `expires_at` an invitation becomes inert but stays in storage — there is no background process clearing expired invitations. A timed-out invitation is cleared either when explicitly revoked/rejected, or when the family issues a fresh invitation for the same node, which archives the stale one as `Expired` and supersedes it.",
"type": "object",
"required": [
"expires_at",
@@ -76,7 +76,7 @@
"additionalProperties": false
},
"FamilyInvitationStatus": {
"description": "Terminal status for an invitation that has been moved out of the pending set.\n\nNote: timed-out invitations are not represented here — they are simply left in the pending set (see `FamilyInvitation::expires_at`).",
"description": "Terminal status for an invitation that has been moved out of the pending set.\n\nNote: an invitation that merely times out is **not** archived here on its own — it is left inert in the pending set (see `FamilyInvitation::expires_at`). It only reaches `Expired` if the family issues a fresh invitation for the same node, which supersedes and archives the stale one.",
"oneOf": [
{
"description": "Still awaiting a response. Recorded with a timestamp for completeness even though pending invitations live in a separate map.",
@@ -173,11 +173,35 @@
}
},
"additionalProperties": false
},
{
"description": "The invitation had already expired and was superseded by a fresh invitation for the same node from the same family, issued at the given timestamp. This is the only path that archives a timed-out invitation.",
"type": "object",
"required": [
"expired"
],
"properties": {
"expired": {
"type": "object",
"required": [
"at"
],
"properties": {
"at": {
"type": "integer",
"format": "uint64",
"minimum": 0.0
}
},
"additionalProperties": false
}
},
"additionalProperties": false
}
]
},
"PastFamilyInvitation": {
"description": "Historical record of an invitation that has reached a terminal state (`Accepted`, `Rejected`, or `Revoked`). Timed-out invitations are **not** archived here — they remain in the pending map until explicitly cleared.",
"description": "Historical record of an invitation that has reached a terminal state (`Accepted`, `Rejected`, `Revoked`, or `Expired`). A timed-out invitation is archived here only when a fresh invitation for the same node supersedes it (status `Expired`); otherwise it stays in the pending map until explicitly cleared.",
"type": "object",
"required": [
"invitation",
@@ -46,7 +46,7 @@
"additionalProperties": false,
"definitions": {
"FamilyInvitation": {
"description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, revoked, or until the chain advances past `expires_at` (in which case they remain in storage but are treated as inert — there is no background process clearing expired invitations).",
"description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, or revoked. Once the chain advances past `expires_at` an invitation becomes inert but stays in storage — there is no background process clearing expired invitations. A timed-out invitation is cleared either when explicitly revoked/rejected, or when the family issues a fresh invitation for the same node, which archives the stale one as `Expired` and supersedes it.",
"type": "object",
"required": [
"expires_at",
@@ -76,7 +76,7 @@
"additionalProperties": false
},
"FamilyInvitationStatus": {
"description": "Terminal status for an invitation that has been moved out of the pending set.\n\nNote: timed-out invitations are not represented here — they are simply left in the pending set (see `FamilyInvitation::expires_at`).",
"description": "Terminal status for an invitation that has been moved out of the pending set.\n\nNote: an invitation that merely times out is **not** archived here on its own — it is left inert in the pending set (see `FamilyInvitation::expires_at`). It only reaches `Expired` if the family issues a fresh invitation for the same node, which supersedes and archives the stale one.",
"oneOf": [
{
"description": "Still awaiting a response. Recorded with a timestamp for completeness even though pending invitations live in a separate map.",
@@ -173,11 +173,35 @@
}
},
"additionalProperties": false
},
{
"description": "The invitation had already expired and was superseded by a fresh invitation for the same node from the same family, issued at the given timestamp. This is the only path that archives a timed-out invitation.",
"type": "object",
"required": [
"expired"
],
"properties": {
"expired": {
"type": "object",
"required": [
"at"
],
"properties": {
"at": {
"type": "integer",
"format": "uint64",
"minimum": 0.0
}
},
"additionalProperties": false
}
},
"additionalProperties": false
}
]
},
"PastFamilyInvitation": {
"description": "Historical record of an invitation that has reached a terminal state (`Accepted`, `Rejected`, or `Revoked`). Timed-out invitations are **not** archived here — they remain in the pending map until explicitly cleared.",
"description": "Historical record of an invitation that has reached a terminal state (`Accepted`, `Rejected`, `Revoked`, or `Expired`). A timed-out invitation is archived here only when a fresh invitation for the same node supersedes it (status `Expired`); otherwise it stays in the pending map until explicitly cleared.",
"type": "object",
"required": [
"invitation",
@@ -35,7 +35,7 @@
"additionalProperties": false,
"definitions": {
"FamilyInvitation": {
"description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, revoked, or until the chain advances past `expires_at` (in which case they remain in storage but are treated as inert — there is no background process clearing expired invitations).",
"description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, or revoked. Once the chain advances past `expires_at` an invitation becomes inert but stays in storage — there is no background process clearing expired invitations. A timed-out invitation is cleared either when explicitly revoked/rejected, or when the family issues a fresh invitation for the same node, which archives the stale one as `Expired` and supersedes it.",
"type": "object",
"required": [
"expires_at",
@@ -34,7 +34,7 @@
"additionalProperties": false,
"definitions": {
"FamilyInvitation": {
"description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, revoked, or until the chain advances past `expires_at` (in which case they remain in storage but are treated as inert — there is no background process clearing expired invitations).",
"description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, or revoked. Once the chain advances past `expires_at` an invitation becomes inert but stays in storage — there is no background process clearing expired invitations. A timed-out invitation is cleared either when explicitly revoked/rejected, or when the family issues a fresh invitation for the same node, which archives the stale one as `Expired` and supersedes it.",
"type": "object",
"required": [
"expires_at",
@@ -34,7 +34,7 @@
"additionalProperties": false,
"definitions": {
"FamilyInvitation": {
"description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, revoked, or until the chain advances past `expires_at` (in which case they remain in storage but are treated as inert — there is no background process clearing expired invitations).",
"description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, or revoked. Once the chain advances past `expires_at` an invitation becomes inert but stays in storage — there is no background process clearing expired invitations. A timed-out invitation is cleared either when explicitly revoked/rejected, or when the family issues a fresh invitation for the same node, which archives the stale one as `Expired` and supersedes it.",
"type": "object",
"required": [
"expires_at",
+80 -20
View File
@@ -292,11 +292,17 @@ impl NodeFamiliesStorage<'_> {
/// - ensuring `expires_at` is strictly in the future.
///
/// As defence-in-depth, this method errors with [`FamilyNotFound`] if
/// `family_id` is unknown and with [`PendingInvitationAlreadyExists`] if
/// a pending invitation for the same `(family, node)` pair is already
/// stored — the underlying `IndexedMap` would otherwise silently
/// `family_id` is unknown and with [`PendingInvitationAlreadyExists`] if a
/// *still-valid* pending invitation for the same `(family, node)` pair is
/// already stored — the underlying `IndexedMap` would otherwise silently
/// overwrite it.
///
/// If a pending invitation for the pair exists but has already expired
/// (`now >= expires_at`), it is archived in [`Self::past_family_invitations`]
/// with status [`FamilyInvitationStatus::Expired`] and the fresh invitation
/// supersedes it. Together with an explicit revoke/reject, this is the only
/// path that clears a timed-out invitation out of the pending map.
///
/// Returns the freshly persisted [`FamilyInvitation`].
///
/// [`FamilyNotFound`]: NodeFamiliesContractError::FamilyNotFound
@@ -304,25 +310,37 @@ impl NodeFamiliesStorage<'_> {
pub(crate) fn add_pending_invitation(
&self,
store: &mut dyn Storage,
env: &Env,
family_id: NodeFamilyId,
node_id: NodeId,
expires_at: u64,
) -> Result<FamilyInvitation, NodeFamiliesContractError> {
let now = env.block.time.seconds();
let key: FamilyMember = (family_id, node_id);
if !self.families.has(store, family_id) {
return Err(NodeFamiliesContractError::FamilyNotFound { family_id });
}
if self
.pending_family_invitations
.may_load(store, key)?
.is_some()
{
return Err(NodeFamiliesContractError::PendingInvitationAlreadyExists {
family_id,
node_id,
});
if let Some(existing) = self.pending_family_invitations.may_load(store, key)? {
// a still-valid invitation blocks a duplicate; an expired one is
// archived and superseded by the fresh invitation below.
if now < existing.expires_at {
return Err(NodeFamiliesContractError::PendingInvitationAlreadyExists {
family_id,
node_id,
});
}
let counter = self.next_past_invitation_counter(store, key)?;
self.past_family_invitations.save(
store,
(key, counter),
&PastFamilyInvitation {
invitation: existing,
status: FamilyInvitationStatus::Expired { at: now },
},
)?;
}
let invitation = FamilyInvitation {
@@ -914,10 +932,11 @@ mod tests {
let s = NodeFamiliesStorage::new();
let alice = tester.addr_make("alice");
let f = tester.make_family(&alice);
let expires_at = tester.env().block.time.seconds() + 100;
let env = tester.env();
let expires_at = env.block.time.seconds() + 100;
let inv = s
.add_pending_invitation(tester.storage_mut(), f.id, 42, expires_at)
.add_pending_invitation(tester.storage_mut(), &env, f.id, 42, expires_at)
.unwrap();
assert_eq!(inv.family_id, f.id);
@@ -937,7 +956,7 @@ mod tests {
let env = tester.env();
let expires_at = env.block.time.seconds() + 100;
let res = s.add_pending_invitation(tester.storage_mut(), 99, 42, expires_at);
let res = s.add_pending_invitation(tester.storage_mut(), &env, 99, 42, expires_at);
assert_eq!(
res.unwrap_err(),
NodeFamiliesContractError::FamilyNotFound { family_id: 99 }
@@ -955,7 +974,7 @@ mod tests {
tester.invite_to_family(f.id, 42);
let expires_at = env.block.time.seconds() + 200;
let res = s.add_pending_invitation(tester.storage_mut(), f.id, 42, expires_at);
let res = s.add_pending_invitation(tester.storage_mut(), &env, f.id, 42, expires_at);
assert_eq!(
res.unwrap_err(),
NodeFamiliesContractError::PendingInvitationAlreadyExists {
@@ -965,6 +984,47 @@ mod tests {
);
}
#[test]
fn add_pending_invitation_supersedes_expired() {
let mut tester = init_contract_tester();
let s = NodeFamiliesStorage::new();
let env = tester.env();
let alice = tester.addr_make("alice");
let f = tester.make_family(&alice);
// first invitation expires at exactly `now`, so it is immediately stale
let stale_exp = env.block.time.seconds();
s.add_pending_invitation(tester.storage_mut(), &env, f.id, 42, stale_exp)
.unwrap();
// re-inviting the same node supersedes the expired invitation
let fresh_exp = env.block.time.seconds() + 100;
let fresh = s
.add_pending_invitation(tester.storage_mut(), &env, f.id, 42, fresh_exp)
.unwrap();
assert_eq!(fresh.expires_at, fresh_exp);
// the fresh invitation is the one left pending
let pending = s
.pending_family_invitations
.load(tester.storage(), (f.id, 42))
.unwrap();
assert_eq!(pending.expires_at, fresh_exp);
// the stale one is archived as Expired, stamped at `now`
let past = s
.past_family_invitations
.load(tester.storage(), ((f.id, 42), 0))
.unwrap();
assert_eq!(
past.status,
FamilyInvitationStatus::Expired {
at: env.block.time.seconds()
}
);
assert_eq!(past.invitation.expires_at, stale_exp);
}
// ---- accept_invitation ----
#[test]
@@ -975,7 +1035,7 @@ mod tests {
let alice = tester.addr_make("alice");
let f = tester.make_family(&alice);
let expires_at = env.block.time.seconds() + 100;
s.add_pending_invitation(tester.storage_mut(), f.id, 42, expires_at)
s.add_pending_invitation(tester.storage_mut(), &env, f.id, 42, expires_at)
.unwrap();
let updated = s
@@ -1032,7 +1092,7 @@ mod tests {
let f = tester.make_family(&alice);
// expires at exactly `now` — `now >= expires_at` triggers
let expires_at = tester.env().block.time.seconds();
s.add_pending_invitation(tester.storage_mut(), f.id, 42, expires_at)
s.add_pending_invitation(tester.storage_mut(), &env, f.id, 42, expires_at)
.unwrap();
let res = s.accept_invitation(tester.storage_mut(), &env, f.id, 42);
@@ -1087,7 +1147,7 @@ mod tests {
let alice = tester.addr_make("alice");
let f = tester.make_family(&alice);
let expires_at = env.block.time.seconds();
s.add_pending_invitation(tester.storage_mut(), f.id, 42, expires_at)
s.add_pending_invitation(tester.storage_mut(), &env, f.id, 42, expires_at)
.unwrap();
s.reject_pending_invitation(tester.storage_mut(), &env, f.id, 42)
@@ -1205,7 +1265,7 @@ mod tests {
let expires_at = env.block.time.seconds() + 100;
for _ in 0..2 {
s.add_pending_invitation(tester.storage_mut(), f.id, 42, expires_at)
s.add_pending_invitation(tester.storage_mut(), &env, f.id, 42, expires_at)
.unwrap();
s.accept_invitation(tester.storage_mut(), &env, f.id, 42)
.unwrap();
+2 -1
View File
@@ -168,8 +168,9 @@ pub trait NodeFamiliesContractTesterExt:
node: NodeId,
expiration: u64,
) -> FamilyInvitation {
let env = self.env();
NodeFamiliesStorage::new()
.add_pending_invitation(self, family, node, expiration)
.add_pending_invitation(self, &env, family, node, expiration)
.unwrap()
}
+96 -1
View File
@@ -254,7 +254,8 @@ pub(crate) fn try_invite_to_family(
ensure_node_not_in_family(&storage, deps.as_ref(), node_id)?;
let expires_at = env.block.time.seconds() + validity;
let invitation = storage.add_pending_invitation(deps.storage, owned.id, node_id, expires_at)?;
let invitation =
storage.add_pending_invitation(deps.storage, &env, owned.id, node_id, expires_at)?;
Ok(Response::new().add_event(
Event::new(events::FAMILY_INVITATION_EVENT_NAME)
@@ -1311,6 +1312,8 @@ mod tests {
use super::*;
use crate::testing::NodeFamiliesContractTesterExt;
use mixnet_contract::testable_mixnet_contract::EmbeddedMixnetContractExt;
use nym_contracts_common_testing::ChainOpts;
use nym_node_families_contract_common::FamilyInvitationStatus;
#[test]
fn happy_path_persists_pending_invitation() -> anyhow::Result<()> {
@@ -1469,6 +1472,98 @@ mod tests {
);
Ok(())
}
#[test]
fn allows_reinvite_once_previous_invitation_has_expired() -> anyhow::Result<()> {
let mut tester = init_contract_tester();
let alice = tester.addr_make("alice");
let family = tester.make_family(&alice);
let node_id = tester.bond_dummy_nymnode()?;
// first invitation with a short, explicit validity
let first_env = tester.env();
try_invite_to_family(
tester.deps_mut(),
first_env.clone(),
message_info(&alice, &[]),
node_id,
Some(5),
)?;
let first_expires_at = first_env.block.time.seconds() + 5;
// let it lapse
tester.advance_time_by(10);
// re-inviting the same node now succeeds and refreshes the expiry
let second_env = tester.env();
try_invite_to_family(
tester.deps_mut(),
second_env.clone(),
message_info(&alice, &[]),
node_id,
Some(5),
)?;
let storage = NodeFamiliesStorage::new();
let pending = storage
.pending_family_invitations
.load(tester.deps().storage, (family.id, node_id))?;
assert_eq!(pending.expires_at, second_env.block.time.seconds() + 5);
// the lapsed invitation was archived as Expired at the re-invite time
let archived = storage
.past_family_invitations
.load(tester.deps().storage, ((family.id, node_id), 0))?;
assert!(matches!(
archived.status,
FamilyInvitationStatus::Expired { at } if at == second_env.block.time.seconds()
));
assert_eq!(archived.invitation.expires_at, first_expires_at);
Ok(())
}
#[test]
fn rejects_reinvite_while_previous_invitation_is_still_valid() -> anyhow::Result<()> {
let mut tester = init_contract_tester();
let alice = tester.addr_make("alice");
let family = tester.make_family(&alice);
let node_id = tester.bond_dummy_nymnode()?;
let env = tester.env();
try_invite_to_family(
tester.deps_mut(),
env,
message_info(&alice, &[]),
node_id,
Some(100),
)?;
// some time passes, but the invitation has not yet expired
tester.advance_time_by(10);
let env = tester.env();
let err = try_invite_to_family(
tester.deps_mut(),
env,
message_info(&alice, &[]),
node_id,
Some(100),
)
.unwrap_err();
assert_eq!(
err,
NodeFamiliesContractError::PendingInvitationAlreadyExists {
family_id: family.id,
node_id,
}
);
// nothing was archived — the still-valid invitation stays pending
assert!(NodeFamiliesStorage::new()
.past_family_invitations
.may_load(tester.deps().storage, ((family.id, node_id), 0))?
.is_none());
Ok(())
}
}
mod revoke_family_invitation {
@@ -86,7 +86,7 @@ Railgun address derives once the engine is up), check the balance, then shield a
small amount. If the wallet is low, top it up at a
[Sepolia faucet](https://sepoliafaucet.com/) using the public address shown.
**Sepolia testnet only.** The wallet holds only test ETH and the mnemonic is
**Sepolia testnet only.** The wallet holds test ETH and the mnemonic is
stored in plain browser storage. Never paste a mainnet mnemonic.
<NetworkTabCallout />
@@ -58,6 +58,57 @@ This page displays a full list of all the changes during our release cycle from
<VarInfo />
## `v2026.11-xynomizithra`
- [Release Binaries](https://github.com/nymtech/nym/releases/tag/nym-binaries-v2026.11-xynomizithra)
- [`nym-node`](nodes/nym-node.mdx) version `1.33.0`
```sh
nym-node
Binary Name: nym-node
Build Timestamp: 2026-06-08T15:46:08.599178376Z
Build Version: 1.33.0
Commit SHA: 34709e76a1c23ed9f2f01bbb4f851fc44bfd7c8d
Commit Date: 2026-06-08T16:30:10.000000000+02:00
Commit Branch: HEAD
rustc Version: 1.91.1
rustc Channel: stable
cargo Profile: release
```
### Operators Tools
<Callout type="error" emoji="🚨">
The provider [WorkTitans B.V., AS209847](https://ipinfo.io/AS209847), also known as _the.hosting, PQ-Hosting and Stark Industries_, has been [under investigation](https://securityaffairs.com/192602/intelligence/dutch-authorities-dismantle-hosting-network-allegedly-used-for-cyberattacks-and-disinformation.html) and majority of their servers were seized.
**If your nodes are still hosted by this provider or its subsidiaries (i.e., *one.hosting*, *geo.hosting*, *ufo.hosting*) and still running, please move to a new provider and discontinue using these services.**
</Callout>
- [**New menu layout**](https://github.com/nymtech/nym/commit/495f020730c7d421ba33a77ec15c2bad8f1f2603): Operators Guide has a new menu, easier to navigate through
- **Ansible improvements**: [\#6848](https://github.com/nymtech/nym/pull/6848) and [\#6860](https://github.com/nymtech/nym/pull/6860):
- [Simpler configuration](/operators/orchestration/ansible#configuration): all operator unique values are set only in [`group_vars/all.yml`](https://github.com/nymtech/nym/blob/develop/ansible/nym-node/playbooks/group_vars/all.yml) and [`inventory/all`](https://github.com/nymtech/nym/blob/develop/ansible/nym-node/playbooks/inventory/all)
- [Automatic bonding flow](/operators/orchestration/ansible#2-bond): Using new components:
- [`auto_bond_all.py`](https://github.com/nymtech/nym/blob/develop/scripts/nym-node-setup/auto-bond/auto_bond_all.py): a program using `nodes.csv` and [Ansible playbook](/operators/orchestration/ansible) to bond all desired nodes in an automated flow
- [`nodes.csv`](https://github.com/nymtech/nym/blob/develop/scripts/nym-node-setup/auto-bond/nodes.csv.example): a node registry combining Ansible inventory and Nyx mnemonics to serve `auto_bond_all.py`, `unbond_all.py` and `show_balances.py`
- Additionally there are two more components to help automate the flow:
- [`unbond_all.py`](https://github.com/nymtech/nym/blob/develop/scripts/nym-node-setup/auto-bond/unbond_all.py): a program using `nodes.csv` to unbond all desired nodes in an automated flow
- [`show_balances.py`](https://github.com/nymtech/nym/blob/develop/scripts/nym-node-setup/auto-bond/show_balances.py): a program using `nodes.csv` to show all Nyx account balances, helping operators to decide on bonding amount
### Features
- [Disable Nagle's algorithm for LP between nym-nodes](https://github.com/nymtech/nym/pull/6857)
- [Node Families (full implementation) - storage, queries, transactions, API exposure](https://github.com/nymtech/nym/pull/6715)
- [Max/smolmix wasm - Browser-friendly WASM version](https://github.com/nymtech/nym/pull/6784)
- [New Typescript SDKs & Docs](https://github.com/nymtech/nym/pull/6840/)
### Bugfix
- [Fix gateways being penalised for no stress testing](https://github.com/nymtech/nym/pull/6843)
- [Fix score inflation for throttled nodes](https://github.com/nymtech/nym/pull/6842)
- [NYM-583: Avoid corrupted database on Windows](https://github.com/nymtech/nym/pull/6785)
## `v2026.10-waterloo`
- [Release Binaries](https://github.com/nymtech/nym/releases/tag/nym-binaries-v2026.10-waterloo)
@@ -86,7 +137,7 @@ cargo Profile: release
1. [**Security steps required**](/operators/troubleshooting/vps-isp#security-patch-copyfail--dirtyfrag): Several critical [Linux kernel vulnerabilities](https://ubuntu.com/blog/copy-fail-vulnerability-fixes-available) had been disclosed. Check out your servers and if needed apply required mitigations!
</Callout>
- [**New Nym Wallet `v1.2.20`**](https://github.com/nymtech/nym/releases/tag/nym-wallet-v1.2.20)
- [**New Nym Wallet `v1.2.20`**](https://github.com/nymtech/nym/releases/tag/nym-wallet-v1.2.20)
- [**NIP-11 - NTM updated: Telegram voice and video call works now!**](https://github.com/nymtech/nym/pull/6807) Please re-run [Nym network tunnel manager](https://raw.githubusercontent.com/nymtech/nym/refs/heads/develop/scripts/nym-node-setup/network-tunnel-manager.sh) (NTM) on your hosting servers:
@@ -118,7 +169,7 @@ HOST_SSH_PORT=<PORT> ./network-tunnel-manager.sh complete_networking_configurati
```
<NTMExplanation />
</Steps>
</Steps>
</MyTab>
<MyTab>
<Steps>
@@ -137,7 +188,7 @@ ansible-playbook deploy.yml -t ntm
```
<NTMExplanation />
</Steps>
</Steps>
</MyTab>
</Tabs>
</div>
@@ -172,14 +223,14 @@ This situation is a reminder of why we have [Operator Terms and Conditions with
### Features
- [Re-order default API urls for network details - Waterloo release](https://github.com/nymtech/nym/pull/6799):
- [Re-order default API urls for network details - Waterloo release](https://github.com/nymtech/nym/pull/6799):
- [Add workflows for NM3](https://github.com/nymtech/nym/pull/6729): Added automated GitHub Actions workflows to streamline deployment of network monitor components to the container registry with optional release versioning
- [Credential proxy pool](https://github.com/nymtech/nym/pull/6726)
- [NMv3 updated performance calculation](https://github.com/nymtech/nym/pull/6714): Wire stress-testing scores submitted by the network-monitor orchestrator into nym-api's node performance calculation, behind a config gate and an availability guard so an orchestrator outage cannot silently slash every node's score.
- [NMv3: submission of stress testing result into nym-api](https://github.com/nymtech/nym/pull/6709): Allow the network monitor orchestrator to submit stress-testing results to the nym-api over a signed, authenticated channel.
- [NMv3: Prometheus metrics for network monitor](https://github.com/nymtech/nym/pull/6693): Wires up a `/v1/metrics/prometheus` scrape endpoint for the v3 orchestrator, along with the metric variants it exposes and the call-site instrumentation that feeds them. The handle follows the existing nym-node wrapper pattern: a singleton `PROMETHEUS_METRICS` backed by `nym_metrics`, with every variant pre-registered at startup so scrapes never see a missing series.
@@ -392,7 +443,7 @@ New release is here and with it a completely new theme of the docs!
2. Lewes protocol ports (`41264/tcp` and `51264/udp`) as well as all the essential ports for mixnet operation (before `ufw`) are now included in NTM
- Follow [these steps](#network-tunnel-manager-ntm-updates) to implement changes if you run any Gateway type of Nym node
- For operators running mixnode mode, make sure to open all ports via `ufw` as [documented here](/operators/nodes/preliminary-steps/vps-setup#3-configure-your-firewall), do *not* use NTM as you nodes do *not* need all exress ports (exit policy) opened
- For operators running mixnode mode, make sure to open all ports via `ufw` as [documented here](/operators/nodes/preliminary-steps/vps-setup#3-configure-your-firewall), do *not* use NTM as you nodes do *not* need all exress ports (exit policy) opened
#### Network Tunnel Manager (NTM) Updates
@@ -401,7 +452,7 @@ New release is here and with it a completely new theme of the docs!
Nym team is testing an unreleased version of [Gateway Probe](/operators/performance-and-testing/gateway-probe). This new version checks whether the ports opened align with the governance decision about exit policy. If they don't, the nodes will be taken out of Service grant program and [Delegation program](https://nym.com/network/DP).
</Callout>
NTM is now changed to be a standalone port manager for servers hosting Nym nodes running in a Gateway mode (with or without WireGuard). Operators of these nodes no longer need to manage mixnet fundamental ports by `ufw` separately, as the NTM will take care of it as well as adjusting the ports according to Nym exit policy.
NTM is now changed to be a standalone port manager for servers hosting Nym nodes running in a Gateway mode (with or without WireGuard). Operators of these nodes no longer need to manage mixnet fundamental ports by `ufw` separately, as the NTM will take care of it as well as adjusting the ports according to Nym exit policy.
**Follow these steps to update the ports setting of your server using NTM.**
@@ -422,12 +473,12 @@ chmod +x network-tunnel-manager.sh
###### 3. Disable `ufw`
Right now your NTM is handling the port management. You can disable `ufw`.
Right now your NTM is handling the port management. You can disable `ufw`.
- We suggest to not uninstall it, just make it innactive for the time being:
```sh
ufw disable
ufw disable
```
###### 4. Re-run NTM
@@ -492,7 +543,7 @@ cargo Profile: release
- **Follow the [steps to update your node exit policy](#update-nym-exit-policy)**, required for operators running wireguard or Exit gateway
###### 2. [Lewes Protocol (LP)](#lewes-protocol-lp) is out
###### 2. [Lewes Protocol (LP)](#lewes-protocol-lp) is out
- **Follow [these steps](#lewes-protocol-lp) to open your ports for the protocol to work**
@@ -588,7 +639,7 @@ cargo Profile: release
### Tools
- **Diagnostic Tool** - a standalone binary for network diagnostics. It performs DNS, HTTP, and gateway connectivity tests, helping developers identify connectivity issues and monitor network performance. It can also be run via the daemon CLI. Read the full guide [here](https://nym.com/docs/developers/tools/diagnostic-tool).
- **Diagnostic Tool** - a standalone binary for network diagnostics. It performs DNS, HTTP, and gateway connectivity tests, helping developers identify connectivity issues and monitor network performance. It can also be run via the daemon CLI. Read the full guide [here](https://nym.com/docs/developers/tools/diagnostic-tool).
- **Socks5 Score Calculation** - performed by the Gateway probe, which tests `nym-node --mode exit-gateway` instances over Socks5. The probe assigns a latency-based rating: high, medium, low, or offline. Full guide [here](https://nym.com/docs/operators/performance-and-testing#socks5-score-calculation-process).
## `v2026.3-parmigiano`
@@ -692,7 +743,7 @@ journalctl -u nym-node -f --all
#### Update Nym exit policy
As a result of [NIP-7: Nym Exit Policy Update - Opening Ports for Steam, Discord & SSH](https://governator.nym.com/proposal/prop-281e9ec1-8e10-4e97-848c-311823e83f61), we updated [`network-tunnel-manager.sh` (NTM)](https://github.com/nymtech/nym/blob/develop/scripts/nym-node-setup/network-tunnel-manager.sh). Every operator is required to download and re-run the current version of NTM on the servers hosting Nym nodes.
As a result of [NIP-7: Nym Exit Policy Update - Opening Ports for Steam, Discord & SSH](https://governator.nym.com/proposal/prop-281e9ec1-8e10-4e97-848c-311823e83f61), we updated [`network-tunnel-manager.sh` (NTM)](https://github.com/nymtech/nym/blob/develop/scripts/nym-node-setup/network-tunnel-manager.sh). Every operator is required to download and re-run the current version of NTM on the servers hosting Nym nodes.
These are the steps for the exit policy update, using NTM.
@@ -719,7 +770,7 @@ chmod +x network-tunnel-manager.sh
- [Add `Copy+Clone` to `nym_api_provider::Config`](https://github.com/nymtech/nym/pull/6296): Add `Copy+Clone` to `nym_client_core::client::topology_control::nym_api_provider::Config`
- [LP Registration + Telescoping + Gateway Probe Localnet Mode](https://github.com/nymtech/nym/pull/6286): Combines LP registration protocol implementation, adds telescoping/nested sessions support, adds localnet mode for `gateway-probe` testing, integrates KKT & PSQ cryptographic primitives
- [LP Registration + Telescoping + Gateway Probe Localnet Mode](https://github.com/nymtech/nym/pull/6286): Combines LP registration protocol implementation, adds telescoping/nested sessions support, adds localnet mode for `gateway-probe` testing, integrates KKT & PSQ cryptographic primitives
- [Minor DNS improvements](https://github.com/nymtech/nym/pull/6283): Increase timeouts back to 10 seconds for overall lookup and 5 seconds per query, ignore unreliable test, remove JIT resolution in http client as it is at best not useful, and at worst increasing timeout
@@ -778,13 +829,13 @@ cargo Profile: release
### Operators Updates & Tools
Were excited to announce the first **nym-node release of 2026**.
Were excited to announce the first **nym-node release of 2026**.
**Exit Policy Ports Management**
In December 2025, two NIP proposals were approved, introducing new ports to Nym network: [NIP-6](https://governator.nym.com/proposal/prop-ba886b9d-6f6e-4365-b4ed-fe7e604bc375), opening ports for WhatsApp and Session + Port `465` and [NIP-4](https://governator.nym.com/proposal/prop-ca6726ea-38b1-4568-97fe-8bdc5fdc83a0), opening port `587`.
**Due to the concerns raised by the operators we built a rate limiting function to Network tunnel manager (NTM) to prevent spam abuse of the network. You can see the changes [here](https://github.com/nymtech/nym/pull/6317).**
**Due to the concerns raised by the operators we built a rate limiting function to Network tunnel manager (NTM) to prevent spam abuse of the network. You can see the changes [here](https://github.com/nymtech/nym/pull/6317).**
To implement the changes and ensure that the nodes have expected performance, please re-run NTM following these steps:
@@ -829,7 +880,7 @@ Please, let us know how that worked for you.
- [`gateway-probe` fixes for run-local](https://github.com/nymtech/nym/pull/6212)
- [Upgrade mode: VPN adjustments](https://github.com/nymtech/nym/pull/6189): This PR further builds up on [\#6174](https://github.com/nymtech/nym/pull/6174) to include changes required by the VPN-client to fully support the upgrade mode, what is relevant here is that this PR modifies the credential storage to allow it to storage an opaque `emergency credential` that lets it be shared between sessions (if it is still valid)
- [Upgrade mode: VPN adjustments](https://github.com/nymtech/nym/pull/6189): This PR further builds up on [\#6174](https://github.com/nymtech/nym/pull/6174) to include changes required by the VPN-client to fully support the upgrade mode, what is relevant here is that this PR modifies the credential storage to allow it to storage an opaque `emergency credential` that lets it be shared between sessions (if it is still valid)
- [Add weighted scoring to NS API](https://github.com/nymtech/nym/pull/6144)
@@ -839,7 +890,7 @@ Please, let us know how that worked for you.
- [Do not re-derive wallet keys on every tx](https://github.com/nymtech/nym/pull/6213): The `cosmrs' trait bounds on `EcdsaSigner` got updated to include `Send` and `Sync`, meaning we no longer need to derive private keys on every transaction and instead we can just do it once, on construction
- [Remove support for legacy mixnode within the performance contract](https://github.com/nymtech/nym/pull/6205): The network no longer supports those nodes, there's no point in having the "brand new" (kinda) contract support them either
- [Remove support for legacy mixnode within the performance contract](https://github.com/nymtech/nym/pull/6205): The network no longer supports those nodes, there's no point in having the "brand new" (kinda) contract support them either
@@ -853,7 +904,7 @@ We are not going to do a platform release this year anymore, but we have two imp
### Governance decisions
During December operators were active in voting about new ports and changes in the quorum.
During December operators were active in voting about new ports and changes in the quorum.
**As agreed in [NIP-4](https://forum.nym.com/t/nip-4-nym-exit-policy-update-opening-port-587/2029/2) and [NIP-6](https://forum.nym.com/t/nip-6-nym-exit-policy-update-opening-ports-for-whatsapp-and-session/2042/1), we are opening these ports on [Nym exit policy](https://nymtech.net/.wellknown/network-requester/exit-policy.txt):**
```ini
@@ -898,7 +949,7 @@ chmod +x network-tunnel-manager.sh && \
- Mixnet exit policy is being pulled with `nym-node run` command from this source: [nymtech.net/.wellknown/network-requester/exit-policy.txt](https://nymtech.net/.wellknown/network-requester/exit-policy.txt)
- All you need to do is to restart your node:
```sh
service nym-node restart
service nym-node restart
```
</Steps>
@@ -911,7 +962,7 @@ Finally we release our initial version **[Ansible playbooks](/operators/orchestr
- Upgrade
- Bond
Checkout the [docs](/operators/orchestration/ansible) and the [Ansible template](https://github.com/nymtech/nym/tree/develop/ansible/nym-node) and let us know how this flag ship worked for you.
Checkout the [docs](/operators/orchestration/ansible) and the [Ansible template](https://github.com/nymtech/nym/tree/develop/ansible/nym-node) and let us know how this flag ship worked for you.
## `v2025.21-mozzarella`
@@ -938,31 +989,31 @@ cargo Profile: release
**NOTE THAT THIS IS A NEW TOOL HANDLING VARIOUS COMPLEX SERVER SETTINGS, PLEASE RUN IT ON A FEW NODES AT A TIME, DO THE TESTING, MONITOR YOUR NODES AND [REPORT ANY ISSUES](https://matrix.to/#/#operators:nymtech.chat).**
We will keep `nym-node v1.21.1` as the latest version on API side for another 3 days to not penalize operators taking time for a proper implementation.
We will keep `nym-node v1.21.1` as the latest version on API side for another 3 days to not penalize operators taking time for a proper implementation.
</ Callout>
We are proud to announce that there are several improvements on tools aiming to make node operators life easier. Please let us know how do these programs work for you.
We are proud to announce that there are several improvements on tools aiming to make node operators life easier. Please let us know how do these programs work for you.
- [**New Nym Node CLI version**](/operators/tools#nym-node-cli): This version introduces arguments, allowing the operator to pass all needed variables (ie. hostname, mode etcetra) and let the program setup everything required for `nym-node` installation, server configuration, nginx, routing, tunnels and bridges, without further prompts for these values. This version also installs [QUIC bridge](/operators/nodes/nym-node/configuration#quic-transport-bridge-deployment).
- [**New Network Tunnel Manager**](/operators/nodes/nym-node/configuration#routing-configuration): NTM went through an big overhaul ([\#6186](https://github.com/nymtech/nym/pull/6186/)) - this new version combines old NTM with WG scripts. If run with `complete_networking_configuration` the tool does entire routing configuration, creates interfaces, and configures WireGuard exit policy.
- [**New Network Tunnel Manager**](/operators/nodes/nym-node/configuration#routing-configuration): NTM went through an big overhaul ([\#6186](https://github.com/nymtech/nym/pull/6186/)) - this new version combines old NTM with WG scripts. If run with `complete_networking_configuration` the tool does entire routing configuration, creates interfaces, and configures WireGuard exit policy.
- [**Updated QUIC Deployment Tool**](/operators/nodes/nym-node/configuration#quic-transport-bridge-deployment): The program is lighter, stripped of redundant functions reworking iptables rules.
- [**Updated QUIC Deployment Tool**](/operators/nodes/nym-node/configuration#quic-transport-bridge-deployment): The program is lighter, stripped of redundant functions reworking iptables rules.
### Features
- [HTTP API resilience enable & domain rotation conditions](https://github.com/nymtech/nym/pull/6200): Changes to make sure fallback domains are updated / enabled only for relevant
- [Typescript SDK 1.4.1](https://github.com/nymtech/nym/pull/6146): This PR is a new release of the Typescript SDK, `mixFetch` and `WASM` client. It also removes the Harbour Master client from `mixFetch`, replacing it with the Nym API's described endpoint for nym-nodes.
- [Typescript SDK 1.4.1](https://github.com/nymtech/nym/pull/6146): This PR is a new release of the Typescript SDK, `mixFetch` and `WASM` client. It also removes the Harbour Master client from `mixFetch`, replacing it with the Nym API's described endpoint for nym-nodes.
- [Enable URL rotation and retries for mixnet gateway init](https://github.com/nymtech/nym/pull/6126):
- [Enable URL rotation and retries for mixnet gateway init](https://github.com/nymtech/nym/pull/6126):
- Multiple URL fallback with configurable retries (defaults to 3)
- Infallible URL conversion per feedback (`Url::from()` instead of `parse().ok()`)
- Non-breaking builder pattern for `BuilderConfig` per feedback
- Reverted redundant node filtering per clarification that API already filters by `supported_roles.entry`
- [Credential proxy jwt](https://github.com/nymtech/nym/pull/5957): This PR is part of the 'Upgrade Mode' that should allow usage of the network in a situation where ecash credentials are unissuable, because, for example, we have lost signing quorum (i.e. we have fewer than the required number of threshold signers responding to requests). This version is more naive. Instead requesting actual 'emergency credentials' that would have been issued by a subset of ecash signers, the credentials proxy creates a JWT, signed with its key, attesting the upgrade mode has been enabled.
### Bugfix
- [Tunnel not waiting on `MixnetClient` to shut down cleanly](https://github.com/nymtech/nym/pull/6225): When there is a wireguard registration error, the mixnet client is signalled to shut down, but the tunnel doesn't wait. Now on errors, the registration client doesn't return until everything is properly stopped
@@ -1021,7 +1072,7 @@ cargo Profile: release
- [Expose more explicit `new_with_fronted_urls` builder for http API client](https://github.com/nymtech/nym/pull/6136)
- [Domain fronting](https://github.com/nymtech/nym/pull/6134): Enable URL rotation and retries for mixnet gateway [`init`](https://github.com/nymtech/nym/pull/6126)
- [Domain fronting](https://github.com/nymtech/nym/pull/6134): Enable URL rotation and retries for mixnet gateway [`init`](https://github.com/nymtech/nym/pull/6126)
### Bugfix
@@ -1086,7 +1137,7 @@ Alongside this platform release we are happy to introduce several improvements a
- [Propagate cancel token to mixnet client](https://github.com/nymtech/nym/pull/6105): Ensures cancellation token propagation to mixnet client
- [[DOCs/operators] QUIC deployment script & docs](https://github.com/nymtech/nym/pull/6098): Script and documentation for QUIC deployment, referencing `nym-bridges` repository
- [[DOCs/operators] QUIC deployment script & docs](https://github.com/nymtech/nym/pull/6098): Script and documentation for QUIC deployment, referencing `nym-bridges` repository
- [Move gateway probe to monorepo (Rust edition 2024)](https://github.com/nymtech/nym/pull/6094): Moves `nym-gateway-probe` and related packages into monorepo, updates to Rust 2024 edition
@@ -1094,14 +1145,14 @@ Alongside this platform release we are happy to introduce several improvements a
### Bugfix
- [Cherry pick - request #6143 from nymtech/bugfix/mix-tx-closed-v2](https://github.com/nymtech/nym/pull/6153): Add circuit breaker
- [Cherry pick - request #6143 from nymtech/bugfix/mix-tx-closed-v2](https://github.com/nymtech/nym/pull/6153): Add circuit breaker
<AccordionTemplate name={<TestingSteps/>}>
**Summary:**
- Network-requester started successfully
- SOCKS5 client started successfully
- Traffic was proxied through the mixnet
- Shutdown was clean
- No 'channel closed (outside of shutdown!)' errors
- Network-requester started successfully
- SOCKS5 client started successfully
- Traffic was proxied through the mixnet
- Shutdown was clean
- No 'channel closed (outside of shutdown!)' errors
</AccordionTemplate>
- [`nym-credential-proxy` query params parsing regression](https://github.com/nymtech/nym/pull/6121): Fix query deserialization issue with `serde_urlencoded` breaking compatibility with VPN API
@@ -21,10 +21,10 @@ 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: 2026-05-27T12:46:38.359447083Z
Build Version: 1.32.0
Commit SHA: 25eba09b92cff648cd37bdd7f0921e710eed25f5
Commit Date: 2026-05-27T11:00:31.000000000+02:00
Build Timestamp: 2026-06-08T15:46:08.599178376Z
Build Version: 1.33.0
Commit SHA: 34709e76a1c23ed9f2f01bbb4f851fc44bfd7c8d
Commit Date: 2026-06-08T16:30:10.000000000+02:00
Commit Branch: HEAD
rustc Version: 1.91.1
rustc Channel: stable
+1
View File
@@ -18,6 +18,7 @@ MULTISIG_CONTRACT_ADDRESS=n1tz0setr8vkh9udp8xyxgpqc89ns27k4d0jx2h942hr0ax63yjhmq
COCONUT_DKG_CONTRACT_ADDRESS=n1v3n2ly2dp3a9ng3ff6rh26yfkn0pc5hed7w2shc5u9ca5c865utqj5elvh
ECASH_CONTRACT_ADDRESS=n1v3vydvs2ued84yv3khqwtgldmgwn0elljsdh08dr5s2j9x4rc5fs9jlwz9
NETWORK_MONITORS_CONTRACT_ADDRESS=n1x5krtvyqklj360x38v62ze42g8s8trfsfqzlv8c9296chcpvqadssqnem5
NODE_FAMILIES_CONTRACT_ADDRESS=n13clyapdqk5umyynp20kqwf59rxlwlp24yf2ltzasflhsdhrxq7fsahyr6z
STATISTICS_SERVICE_DOMAIN_ADDRESS="http://0.0.0.0"
NYXD=https://rpc.sandbox.nymtech.net
@@ -15,25 +15,14 @@ use std::time::Instant;
impl PeerRegistrator {
/// In the case of an already registered WG peer, update its PSK.
///
/// The peer controller keeps the active config and the on-disk PSK in sync.
pub(super) async fn update_peer_psk(
&self,
peer: PeerPublicKey,
psk: Key,
) -> Result<(), GatewayWireguardError> {
// 1. check if the peer is currently being handled
if self.peer_manager.check_active_peer(peer).await? {
// 2. if so, force disconnect it (as we're handling new request from the same peer)
self.peer_manager.remove_peer(peer).await?;
}
// 3. update the on-disk PSK
let encoded_psk = psk.to_lower_hex();
self.ecash_verifier
.storage()
.update_peer_psk(&peer.to_string(), Some(&encoded_psk))
.await?;
Ok(())
self.peer_manager.update_peer_psk(peer, psk).await
}
fn lp_peer_to_final_response(
@@ -125,6 +125,44 @@ impl PeerManager {
res
}
pub async fn update_peer_psk(
&self,
pub_key: PeerPublicKey,
psk: Key,
) -> Result<(), GatewayWireguardError> {
let controller_start = Instant::now();
let peer_key = Key::new(pub_key.to_bytes());
let (response_tx, response_rx) = oneshot::channel();
let msg = PeerControlRequest::UpdatePeerPsk {
peer_key,
psk,
response_tx,
};
self.wireguard_gateway_data
.peer_tx()
.send(msg)
.await
.map_err(|_| GatewayWireguardError::PeerInteractionStopped)?;
let res = response_rx
.await
.map_err(|_| GatewayWireguardError::internal("no response for update peer psk"))?
.map_err(|err| {
GatewayWireguardError::InternalError(format!(
"updating peer psk could not be performed: {err:?}"
))
});
let latency = controller_start.elapsed().as_secs_f64();
add_histogram_obs!(
"wg_peer_controller_channel_latency_seconds",
latency,
WG_CONTROLLER_LATENCY_BUCKETS
);
res
}
pub async fn remove_peer(&self, pub_key: PeerPublicKey) -> Result<(), GatewayWireguardError> {
let controller_start = Instant::now();
let key = Key::new(pub_key.to_bytes());
+1 -1
View File
@@ -3,7 +3,7 @@
[package]
name = "nym-api"
version = "1.1.80-no-gw-penalty"
version = "1.1.81"
authors.workspace = true
edition = "2021"
license = "GPL-3.0"
+1 -5
View File
@@ -75,11 +75,7 @@ test-utils = []
[build-dependencies]
anyhow = { workspace = true }
vergen-gitcl = { workspace = true, default-features = false, features = [
"build",
"cargo",
"rustc",
] }
vergen = { workspace = true, features = ["build", "git", "gitcl", "rustc", "cargo"] }
[package.metadata.cargo-machete]
ignored = ["vergen", "nym-http-api-client"]
+9 -6
View File
@@ -5,18 +5,21 @@
use anyhow::{Context, bail};
use std::{path::PathBuf, process::Command};
use vergen_gitcl::{BuildBuilder, CargoBuilder, Emitter, GitclBuilder, RustcBuilder};
use vergen::EmitBuilder;
fn main() -> anyhow::Result<()> {
build_go()?;
generate_exit_policy_ports()?;
Emitter::default()
.add_instructions(&BuildBuilder::all_build()?)?
.add_instructions(&CargoBuilder::all_cargo()?)?
.add_instructions(&GitclBuilder::all_git()?)?
.add_instructions(&RustcBuilder::all_rustc()?)?
EmitBuilder::builder()
.all_build()
.all_git()
.all_rustc()
.all_cargo()
.emit()
.context("failed to extract build metadata")?;
Ok(())
}
/// Parse PORT_MAPPINGS from network-tunnel-manager.sh and generate a sorted
+1 -1
View File
@@ -3,7 +3,7 @@
[package]
name = "nym-node"
version = "1.32.0"
version = "1.33.0"
authors.workspace = true
edition.workspace = true
license = "GPL-3.0"
@@ -157,6 +157,9 @@ pub enum PrometheusMetric {
#[strum(props(help = "The distribution of defguard peer creation time"))]
WireguardDefguardPeerCreation,
#[strum(props(help = "The distribution of defguard peer psk update time"))]
WireguardDefguardPeerPskUpdate,
#[strum(props(
help = "The distribution of time it takes to verify a credential during peer registration"
))]
@@ -320,6 +323,9 @@ impl PrometheusMetric {
PrometheusMetric::WireguardDefguardPeerCreation => {
Metric::new_histogram(&name, help, Some(REG_LATENCY_BUCKETS))
}
PrometheusMetric::WireguardDefguardPeerPskUpdate => {
Metric::new_histogram(&name, help, Some(REG_LATENCY_BUCKETS))
}
PrometheusMetric::DvpnAuthenticatorClientRegistrationMsg1 => {
Metric::new_histogram(&name, help, Some(REG_LATENCY_BUCKETS))
}
@@ -452,7 +458,7 @@ mod tests {
// a sanity check for anyone adding new metrics. if this test fails,
// make sure any methods on `PrometheusMetric` enum don't need updating
// or require custom Display impl
assert_eq!(46, PrometheusMetric::COUNT)
assert_eq!(47, PrometheusMetric::COUNT)
}
#[test]
@@ -6,6 +6,7 @@ use crate::logging::granual_filtered_env;
use crate::throughput_tester::test_mixing_throughput;
use anyhow::bail;
use humantime_serde::re::humantime;
use indicatif::ProgressStyle;
use nym_bin_common::logging::default_tracing_fmt_layer;
use std::env::temp_dir;
use std::path::PathBuf;
@@ -40,6 +41,9 @@ pub struct Args {
fn init_test_logger() -> anyhow::Result<()> {
let indicatif_layer = IndicatifLayer::new()
.with_progress_style(ProgressStyle::with_template(
"{span_child_prefix}{spinner} {span_fields} -- {span_name} {wide_msg}",
)?)
.with_span_child_prefix_symbol("")
.with_span_child_prefix_indent(" ");
+7 -1
View File
@@ -85,7 +85,13 @@ pub(crate) struct Cli {
}
impl Cli {
#[allow(clippy::unreachable)]
pub(crate) fn execute(self) -> anyhow::Result<()> {
// test_throughput sets up its own logger and builds a runtime internally.
if let Commands::TestThroughput(args) = self.command {
return test_throughput::execute(args);
}
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
@@ -104,7 +110,7 @@ impl Cli {
Commands::Run(args) => run::execute(*args).await?,
Commands::Migrate(args) => migrate::execute(*args)?,
Commands::Sign(args) => sign::execute(args).await?,
Commands::TestThroughput(args) => test_throughput::execute(args)?,
Commands::TestThroughput(..) => unreachable!(),
Commands::UnsafeResetSphinxKeys(args) => reset_sphinx_keys::execute(args).await?,
Commands::Debug(debug) => match debug.command {
DebugCommands::ResetProvidersGatewayDbs(args) => {
+16 -3
View File
@@ -693,7 +693,12 @@ pub struct MixnetDebug {
#[serde(with = "humantime_serde")]
pub initial_connection_timeout: Duration,
/// Maximum number of packets that can be stored waiting to get sent to a particular connection.
/// Maximum number of packets buffered per egress connection awaiting a socket write.
/// This is a short-term burst absorber, not a queue: buffer depth converts directly into
/// added latency (roughly `depth / per-peer send rate`), so an oversized value is just
/// bufferbloat. Once it fills, further packets for that peer are dropped rather than
/// delayed, which is preferable in a mixnet where a packet held that long has already
/// missed its usefulness window. Keep worst-case queuing well under the per-hop mix delay.
pub maximum_connection_buffer_size: usize,
/// Specify whether any framed packets between nodes should use the legacy format (v7)
@@ -703,6 +708,10 @@ pub struct MixnetDebug {
/// processing received packets.
pub use_legacy_packet_encoding: bool,
/// Sample 1-in-N forwarded packets for egress latency tracing (per-connection buffer
/// sojourn histogram). 0 disables tracing entirely.
pub egress_trace_sample_rate: u64,
/// Specifies whether this node should **NOT** use noise protocol in the connections (currently not implemented)
pub unsafe_disable_noise: bool,
}
@@ -882,9 +891,12 @@ impl MixnetDebug {
// which for all intents and purposes will never happen
const DEFAULT_MAXIMUM_FORWARD_PACKET_DELAY: Duration = Duration::from_secs(10);
const DEFAULT_PACKET_FORWARDING_INITIAL_BACKOFF: Duration = Duration::from_millis(10_000);
const DEFAULT_PACKET_FORWARDING_MAXIMUM_BACKOFF: Duration = Duration::from_millis(300_000);
const DEFAULT_PACKET_FORWARDING_MAXIMUM_BACKOFF: Duration = Duration::from_secs(16);
const DEFAULT_INITIAL_CONNECTION_TIMEOUT: Duration = Duration::from_millis(1_500);
const DEFAULT_MAXIMUM_CONNECTION_BUFFER_SIZE: usize = 2000;
// small enough to keep worst-case egress queuing in the tens-of-ms range at a few thousand
// pps per peer (vs. the old 2000, which was hundreds of ms of bufferbloat)
const DEFAULT_MAXIMUM_CONNECTION_BUFFER_SIZE: usize = 192;
const DEFAULT_EGRESS_TRACE_SAMPLE_RATE: u64 = 100;
}
impl Default for MixnetDebug {
@@ -895,6 +907,7 @@ impl Default for MixnetDebug {
packet_forwarding_maximum_backoff: Self::DEFAULT_PACKET_FORWARDING_MAXIMUM_BACKOFF,
initial_connection_timeout: Self::DEFAULT_INITIAL_CONNECTION_TIMEOUT,
maximum_connection_buffer_size: Self::DEFAULT_MAXIMUM_CONNECTION_BUFFER_SIZE,
egress_trace_sample_rate: Self::DEFAULT_EGRESS_TRACE_SAMPLE_RATE,
// TODO: update this in few releases...
use_legacy_packet_encoding: true,
unsafe_disable_noise: false,
@@ -532,6 +532,7 @@ pub async fn try_upgrade_config_v13<P: AsRef<Path>>(
.packet_forwarding_maximum_backoff,
initial_connection_timeout: old_cfg.mixnet.debug.initial_connection_timeout,
maximum_connection_buffer_size: old_cfg.mixnet.debug.maximum_connection_buffer_size,
egress_trace_sample_rate: MixnetDebug::DEFAULT_EGRESS_TRACE_SAMPLE_RATE,
unsafe_disable_noise: old_cfg.mixnet.debug.unsafe_disable_noise,
use_legacy_packet_encoding: old_cfg.mixnet.debug.use_legacy_packet_encoding,
},
@@ -404,7 +404,7 @@ where
};
// Connect to target gateway with timeout
let stream = match timeout(Duration::from_secs(5), S::connect(target_addr)).await {
let mut stream = match timeout(Duration::from_secs(5), S::connect(target_addr)).await {
Ok(Ok(stream)) => stream,
Ok(Err(e)) => {
inc!("lp_forward_failed");
@@ -422,6 +422,16 @@ where
}
};
// Disable Nagle's algorithm: the forward stream carries small request/response
// handshake packets, so we want them sent immediately rather than coalesced.
if let Err(e) = stream.set_no_delay(true) {
inc!("lp_forward_failed");
return Err(LpHandlerError::ConnectionFailure {
egress: target_addr,
reason: format!("failed to set TCP_NODELAY: {e}"),
});
}
debug!("Opened persistent exit connection to {target_addr} for forwarding");
self.exit_stream = Some((stream, target_addr));
@@ -171,6 +171,14 @@ impl LpControlListener {
}
fn handle_connection(&self, stream: tokio::net::TcpStream, remote_addr: SocketAddr) {
// Disable Nagle's algorithm on the accepted socket so our responses are flushed
// immediately rather than coalesced. This is the write side of every reply we send,
// including handshake replies forwarded back to an entry gateway. Non-fatal: a valid
// connection should still be served if the option can't be set.
if let Err(e) = stream.set_nodelay(true) {
warn!("failed to set TCP_NODELAY on accepted LP connection from {remote_addr}: {e}");
}
if let Some(initiator_details) = self
.nodes_handler_state
.nodes
+175 -25
View File
@@ -4,6 +4,7 @@
use crate::node::key_rotation::active_keys::SphinxKeyGuard;
use crate::node::mixnet::shared::SharedData;
use futures::StreamExt;
use nym_mixnet_client::trace::{PacketTrace, TraceStage, Traced};
use nym_noise::connection::Connection;
use nym_noise::upgrade_noise_responder;
use nym_sphinx_forwarding::packet::MixPacket;
@@ -18,6 +19,7 @@ use nym_sphinx_types::{Delay, REPLAY_TAG_SIZE};
use std::collections::HashMap;
use std::mem;
use std::net::SocketAddr;
use std::time::Duration;
use tokio::net::TcpStream;
use tokio::time::Instant;
use tokio_util::codec::Framed;
@@ -27,8 +29,9 @@ use tracing::{Span, debug, error, instrument, trace, warn};
const SPAN_UPDATE_INTERVAL: u64 = 10_000;
struct PendingReplayCheckPackets {
// map of rotation id used for packet creation to the packets
packets: HashMap<u32, Vec<PartiallyUnwrappedPacket>>,
// map of rotation id used for packet creation to the packets (each carrying the latency
// trace started at receive, so the deferral wait is attributed to the ReplayCheck stage)
packets: HashMap<u32, Vec<Traced<PartiallyUnwrappedPacket>>>,
last_acquired_mutex: Instant,
}
@@ -40,31 +43,41 @@ impl PendingReplayCheckPackets {
}
}
fn reset(&mut self, now: Instant) -> HashMap<u32, Vec<PartiallyUnwrappedPacket>> {
fn reset(&mut self, now: Instant) -> HashMap<u32, Vec<Traced<PartiallyUnwrappedPacket>>> {
self.last_acquired_mutex = now;
mem::take(&mut self.packets)
}
fn push(&mut self, now: Instant, packet: PartialyUnwrappedPacketWithKeyRotation) {
fn push(
&mut self,
now: Instant,
packet: PartialyUnwrappedPacketWithKeyRotation,
trace: PacketTrace,
) {
if self.packets.is_empty() {
self.last_acquired_mutex = now;
}
self.packets
.entry(packet.used_key_rotation)
.or_default()
.push(packet.packet)
.push(Traced::new(packet.packet, trace))
}
fn total_count(&self) -> usize {
self.packets.values().map(|v| v.len()).sum()
}
/// Instant at which the currently-deferred batch must be flushed, or `None` if nothing is pending.
fn flush_deadline(&self, deferral: Duration) -> Option<Instant> {
(self.total_count() > 0).then(|| self.last_acquired_mutex + deferral)
}
fn replay_tags(&self) -> HashMap<u32, Vec<&[u8; REPLAY_TAG_SIZE]>> {
let mut replay_tags = HashMap::with_capacity(self.packets.len());
'outer: for (rotation_id, packets) in &self.packets {
let mut rotation_replay_tags = Vec::with_capacity(packets.len());
for packet in packets {
let Some(replay_tag) = packet.replay_tag() else {
let Some(replay_tag) = packet.inner.replay_tag() else {
error!(
"corrupted batch of {} packets - replay tag was missing",
self.packets.len()
@@ -86,6 +99,9 @@ pub(crate) struct ConnectionHandler {
// packets pending for replay detection
pending_packets: PendingReplayCheckPackets,
// per-connection monotonic counter driving 1-in-N latency-trace sampling
trace_sampler: u64,
}
impl Drop for ConnectionHandler {
@@ -115,9 +131,22 @@ impl ConnectionHandler {
},
remote_address,
pending_packets: PendingReplayCheckPackets::new(),
trace_sampler: 0,
}
}
/// Start a latency trace for a freshly received packet, sampling 1-in-N (rate from config,
/// 0 disables). Sampling is per-connection, which still yields ~1/N of total traffic.
fn start_trace(&mut self, packet: FramedNymPacket) -> Traced<FramedNymPacket> {
let rate = self.shared.processing_config.egress_trace_sample_rate;
let sampled = rate != 0 && {
let n = self.trace_sampler;
self.trace_sampler = n.wrapping_add(1);
n.is_multiple_of(rate)
};
Traced::new(packet, PacketTrace::start(sampled))
}
/// Check if the current connection is from an authorised Network Monitor agent.
///
/// # Replay Protection Bypass
@@ -168,7 +197,7 @@ impl ConnectionHandler {
#[instrument(
name = "mixnode.forward_packet",
skip(self, mix_packet, delay),
skip(self, mix_packet, delay, trace),
level = "debug",
fields(
remote_addr = %self.remote_address,
@@ -181,6 +210,7 @@ impl ConnectionHandler {
mix_packet: MixPacket,
delay: Option<Delay>,
network_monitor_packet: bool,
trace: PacketTrace,
) {
if !self.shared.processing_config.forward_hop_processing_enabled {
warn!(
@@ -200,12 +230,12 @@ impl ConnectionHandler {
);
}
self.shared
.forward_mix_packet(mix_packet, forward_instant, network_monitor_packet);
.forward_mix_packet(mix_packet, forward_instant, network_monitor_packet, trace);
}
#[instrument(
name = "mixnode.final_hop",
skip(self, final_hop_data),
skip(self, final_hop_data, trace),
level = "debug",
fields(
remote_addr = %self.remote_address,
@@ -218,6 +248,7 @@ impl ConnectionHandler {
&self,
final_hop_data: ProcessedFinalHop,
network_monitor_packet: bool,
trace: PacketTrace,
) {
if !self.shared.processing_config.final_hop_processing_enabled {
warn!(
@@ -275,7 +306,8 @@ impl ConnectionHandler {
// if we managed to either push message directly to the [online] client or store it at
// disk, forward the ack
self.shared.forward_ack_packet(final_hop_data.forward_ack);
self.shared
.forward_ack_packet(final_hop_data.forward_ack, trace);
if has_ack {
Span::current().record("ack_forwarded", true);
}
@@ -289,7 +321,7 @@ impl ConnectionHandler {
.processing_config
.maximum_replay_detection_deferral;
let count_threshold = self.pending_packets.packets.len()
let count_threshold = self.pending_packets.total_count()
< self
.shared
.processing_config
@@ -418,8 +450,11 @@ impl ConnectionHandler {
async fn handle_received_packet_with_replay_detection(
&mut self,
now: Instant,
packet: FramedNymPacket,
packet: Traced<FramedNymPacket>,
) {
let mut trace = packet.trace;
let packet = packet.inner;
// 1. derive and expand shared secret
// also check the header integrity
let partially_unwrapped = match self.try_partially_unwrap_packet(packet) {
@@ -440,7 +475,9 @@ impl ConnectionHandler {
}
};
self.pending_packets.push(now, partially_unwrapped);
// close out the Unwrap stage (partial unwrap: shared secret + header MAC)
trace.record(TraceStage::Unwrap);
self.pending_packets.push(now, partially_unwrapped, trace);
// 2. check for packet replay
// 2.1 first try it without locking
@@ -462,6 +499,7 @@ impl ConnectionHandler {
now: Instant,
unwrapped_packet: Result<MixProcessingResult, PacketProcessingError>,
network_monitor_packet: bool,
trace: PacketTrace,
) {
// 2. increment our favourite metrics stats
self.shared
@@ -474,10 +512,10 @@ impl ConnectionHandler {
}
Ok(processed_packet) => match processed_packet.processing_data {
MixProcessingResultData::ForwardHop { packet, delay } => {
self.handle_forward_packet(now, packet, delay, network_monitor_packet);
self.handle_forward_packet(now, packet, delay, network_monitor_packet, trace);
}
MixProcessingResultData::FinalHop { final_hop_data } => {
self.handle_final_hop(final_hop_data, network_monitor_packet)
self.handle_final_hop(final_hop_data, network_monitor_packet, trace)
.await;
}
},
@@ -487,7 +525,7 @@ impl ConnectionHandler {
async fn handle_post_replay_detection_packets(
&self,
now: Instant,
packets: HashMap<u32, Vec<PartiallyUnwrappedPacket>>,
packets: HashMap<u32, Vec<Traced<PartiallyUnwrappedPacket>>>,
replay_check_results: HashMap<u32, Vec<bool>>,
) {
let mut replays_detected: u64 = 0;
@@ -497,7 +535,11 @@ impl ConnectionHandler {
error!("inconsistent replay check result - no values for rotation {rotation_id}");
continue;
};
for (packet, &replayed) in packets.into_iter().zip(replay_checks) {
for (traced, &replayed) in packets.into_iter().zip(replay_checks) {
let Traced {
inner: packet,
mut trace,
} = traced;
// CRITICAL SECURITY DECISION POINT: Replay Protection Bypass for Network Monitors
//
// This is where we decide whether to enforce replay protection for this packet.
@@ -525,17 +567,22 @@ impl ConnectionHandler {
rotation_id,
"dropping replayed packet"
);
trace.record(TraceStage::ReplayCheck);
self.handle_unwrapped_packet(
now,
Err(PacketProcessingError::PacketReplay),
network_monitor_packet,
trace,
)
.await;
continue;
}
// finalise the (expensive) full unwrapping, then close out the ReplayCheck stage:
// it spans partial-unwrap -> deferral -> replay check -> finalise
let unwrapped_packet = packet.finalise_unwrapping();
self.handle_unwrapped_packet(now, unwrapped_packet, network_monitor_packet)
trace.record(TraceStage::ReplayCheck);
self.handle_unwrapped_packet(now, unwrapped_packet, network_monitor_packet, trace)
.await;
}
}
@@ -625,28 +672,33 @@ impl ConnectionHandler {
async fn handle_received_packet_with_no_replay_detection(
&mut self,
now: Instant,
packet: FramedNymPacket,
packet: Traced<FramedNymPacket>,
) {
let mut trace = packet.trace;
let packet = packet.inner;
let unwrapped_packet = self.try_full_unwrap_packet(packet);
// no replay batching on this path: the Unwrap stage covers the full unwrapping
trace.record(TraceStage::Unwrap);
let is_network_monitor_packet = self.is_from_authorised_network_monitor_agent();
self.handle_unwrapped_packet(now, unwrapped_packet, is_network_monitor_packet)
self.handle_unwrapped_packet(now, unwrapped_packet, is_network_monitor_packet, trace)
.await;
}
#[instrument(skip(self, packet), level = "debug")]
async fn handle_received_nym_packet(&mut self, packet: FramedNymPacket) {
let now = Instant::now();
let traced = self.start_trace(packet);
// 1. attempt to unwrap the packet
// if it's a sphinx packet attempt to do pre-processing and replay detection
if packet.is_sphinx() && !self.shared.replay_protection_filter.disabled() {
self.handle_received_packet_with_replay_detection(now, packet)
if traced.inner.is_sphinx() && !self.shared.replay_protection_filter.disabled() {
self.handle_received_packet_with_replay_detection(now, traced)
.await;
} else {
// otherwise just skip that whole procedure and go straight to payload unwrapping
// (assuming the basic framing is valid)
self.handle_received_packet_with_no_replay_detection(now, packet)
self.handle_received_packet_with_no_replay_detection(now, traced)
.await;
};
}
@@ -706,13 +758,23 @@ impl ConnectionHandler {
) {
let mut packets_processed: u64 = 0;
loop {
// make sure pending packets are not stuck in the queue if we don't get any more packets
// from this sender
let flush_deadline = self.pending_packets.flush_deadline(
self.shared
.processing_config
.maximum_replay_detection_deferral,
);
tokio::select! {
biased;
// 1. check for cancellation
_ = self.shared.shutdown_token.cancelled() => {
trace!("connection handler: received shutdown");
Span::current().record("exit_reason", "shutdown");
break
}
// 2. handle any incoming packet
maybe_framed_nym_packet = mixnet_connection.next() => {
match maybe_framed_nym_packet {
Some(Ok(packet)) => {
@@ -732,7 +794,7 @@ impl ConnectionHandler {
);
Span::current().record("exit_reason", "corrupted");
Span::current().record("packets_processed", packets_processed);
return
break
}
None => {
debug!(
@@ -742,14 +804,102 @@ impl ConnectionHandler {
);
Span::current().record("exit_reason", "closed_by_remote");
Span::current().record("packets_processed", packets_processed);
return
break
}
}
}
// 3. check for the deferred pending packets
_ = async move {
match flush_deadline {
Some(d) => tokio::time::sleep_until(d).await,
None => std::future::pending::<()>().await,
}
} => {
self.handle_pending_packets_batch(Instant::now()).await;
}
}
}
// drain any packets still deferred for replay-checking so they are forwarded
// rather than silently dropped when the connection closes, errors, or shuts down
self.handle_pending_packets_batch(Instant::now()).await;
Span::current().record("packets_processed", packets_processed);
debug!("exiting and closing connection");
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use nym_sphinx_params::{PacketSize, PacketType};
use nym_sphinx_types::{
DESTINATION_ADDRESS_LENGTH, Destination, DestinationAddressBytes, IDENTIFIER_LENGTH,
NODE_ADDRESS_LENGTH, Node, NodeAddressBytes, NymPacket, PrivateKey, PublicKey,
};
fn random_pubkey() -> PublicKey {
(&PrivateKey::random()).into()
}
// Build a real sphinx packet whose first hop validates against `key`, then partially
// unwrap it - enough to land one entry in the pending replay-check batch.
fn pending_packet(key: &PrivateKey) -> PartialyUnwrappedPacketWithKeyRotation {
let route = [
Node::new(
NodeAddressBytes::from_bytes([1u8; NODE_ADDRESS_LENGTH]),
key.into(),
),
Node::new(
NodeAddressBytes::from_bytes([2u8; NODE_ADDRESS_LENGTH]),
random_pubkey(),
),
];
let destination = Destination::new(
DestinationAddressBytes::from_bytes([3u8; DESTINATION_ADDRESS_LENGTH]),
[4u8; IDENTIFIER_LENGTH],
);
let delays: Vec<Delay> = std::iter::repeat_with(|| Delay::new_from_nanos(0))
.take(route.len())
.collect();
let packet = NymPacket::sphinx_build(
true,
PacketSize::RegularPacket.payload_size(),
b"x",
&route,
&destination,
&delays,
)
.expect("failed to build test sphinx packet");
let framed =
FramedNymPacket::new(packet, PacketType::Mix, SphinxKeyRotation::Unknown, true);
PartiallyUnwrappedPacket::new(framed, key)
.map_err(|(_, err)| err)
.expect("failed to partially unwrap test packet")
.with_key_rotation(0)
}
#[test]
fn no_flush_deadline_when_nothing_pending() {
let pending = PendingReplayCheckPackets::new();
assert!(pending.flush_deadline(Duration::from_millis(50)).is_none());
}
#[test]
fn flush_deadline_is_batch_start_plus_deferral() {
let key = PrivateKey::random();
let mut pending = PendingReplayCheckPackets::new();
let batch_start = Instant::now();
// the trace is irrelevant to flush scheduling
pending.push(batch_start, pending_packet(&key), PacketTrace::Off);
let deferral = Duration::from_millis(50);
assert_eq!(
pending.flush_deadline(deferral),
Some(batch_start + deferral)
);
}
}
@@ -7,6 +7,7 @@ use nym_mixnet_client::SendWithoutResponse;
use nym_mixnet_client::forwarder::{
MixForwardingReceiver, MixForwardingSender, PacketToForward, mix_forwarding_channels,
};
use nym_mixnet_client::trace::{TraceStage, Traced};
use nym_node_metrics::NymNodeMetrics;
use nym_nonexhaustive_delayqueue::{Expired, NonExhaustiveDelayQueue};
use nym_sphinx_forwarding::packet::MixPacket;
@@ -16,7 +17,7 @@ use tokio::time::Instant;
use tracing::{debug, error, trace, warn};
pub struct PacketForwarder<C, F> {
delay_queue: NonExhaustiveDelayQueue<MixPacket>,
delay_queue: NonExhaustiveDelayQueue<Traced<MixPacket>>,
mixnet_client: C,
metrics: NymNodeMetrics,
@@ -44,12 +45,12 @@ impl<C, F> PacketForwarder<C, F> {
self.packet_sender.clone()
}
fn forward_packet(&mut self, packet: MixPacket)
fn forward_packet(&mut self, packet: Traced<MixPacket>)
where
C: SendWithoutResponse,
F: RoutingFilter,
{
let next_hop = packet.next_hop_address();
let next_hop = packet.inner.next_hop_address();
if let Err(err) = self.mixnet_client.send_without_response(packet) {
if err.kind() == io::ErrorKind::WouldBlock {
@@ -73,20 +74,29 @@ impl<C, F> PacketForwarder<C, F> {
}
/// Upon packet being finished getting delayed, forward it to the mixnet.
fn handle_done_delaying(&mut self, packet: Expired<MixPacket>)
fn handle_done_delaying(&mut self, packet: Expired<Traced<MixPacket>>)
where
C: SendWithoutResponse,
F: RoutingFilter,
{
let delayed_packet = packet.into_inner();
// how late beyond the target release the queue actually handed the packet back: the
// delay-queue's own scheduling/retrieval overhead (timer granularity + task wakeup)
let overrun = Instant::now().saturating_duration_since(packet.deadline());
let mut delayed_packet = packet.into_inner();
// close out the DelayQueue stage (the full wait: intended mix delay + overrun)
delayed_packet.record(TraceStage::DelayQueue);
delayed_packet.record_value(TraceStage::DelayQueueOverrun, overrun.as_secs_f64());
self.forward_packet(delayed_packet);
}
fn handle_new_packet(&mut self, new_packet: PacketToForward)
fn handle_new_packet(&mut self, mut new_packet: PacketToForward)
where
C: SendWithoutResponse,
F: RoutingFilter,
{
// close out the ForwarderQueue stage (wait in the ingress -> forwarder channel)
new_packet.trace.record(TraceStage::ForwarderQueue);
let next_hop = new_packet.packet.next_hop();
if !self
@@ -104,18 +114,25 @@ impl<C, F> PacketForwarder<C, F> {
return;
}
let delay_target = new_packet.forward_delay_target;
let traced = Traced::new(new_packet.packet, new_packet.trace);
// in case of a zero delay packet, don't bother putting it in the delay queue,
// just forward it immediately
if let Some(instant) = new_packet.forward_delay_target {
if let Some(instant) = delay_target {
// check if the delay has already expired, if so, don't bother putting it through
// the delay queue only to retrieve it immediately. Just forward it.
if instant.checked_duration_since(Instant::now()).is_none() {
self.forward_packet(new_packet.packet)
// the target elapsed before we could even queue it: upstream overhead already
// ate the whole intended delay, so the overrun is now - target
let overrun = Instant::now().saturating_duration_since(instant);
traced.record_value(TraceStage::DelayQueueOverrun, overrun.as_secs_f64());
self.forward_packet(traced)
} else {
self.delay_queue.insert_at(new_packet.packet, instant);
self.delay_queue.insert_at(traced, instant);
}
} else {
self.forward_packet(new_packet.packet)
self.forward_packet(traced)
}
}
+18 -3
View File
@@ -9,6 +9,7 @@ use crate::node::replay_protection::bloomfilter::ReplayProtectionBloomfilters;
use crate::node::routing_filter::network_filter::RoutableNetworkMonitors;
use nym_gateway::node::GatewayStorageError;
use nym_mixnet_client::forwarder::{MixForwardingSender, PacketToForward};
use nym_mixnet_client::trace::PacketTrace;
use nym_node_metrics::NymNodeMetrics;
use nym_node_metrics::mixnet::PacketKind;
use nym_noise::config::NoiseConfig;
@@ -24,7 +25,7 @@ use std::time::Duration;
use tokio::net::TcpStream;
use tokio::task::JoinHandle;
use tokio::time::Instant;
use tracing::{debug, error};
use tracing::{debug, error, warn};
pub(crate) mod final_hop;
@@ -41,6 +42,9 @@ pub(crate) struct ProcessingConfig {
pub(crate) forward_hop_processing_enabled: bool,
pub(crate) final_hop_processing_enabled: bool,
/// sample 1-in-N forwarded packets for per-stage latency tracing (0 disables)
pub(crate) egress_trace_sample_rate: u64,
}
impl ProcessingConfig {
@@ -60,6 +64,7 @@ impl ProcessingConfig {
forward_hop_processing_enabled: config.modes.mixnode,
final_hop_processing_enabled: config.modes.expects_final_hop_traffic()
|| config.wireguard.enabled,
egress_trace_sample_rate: config.mixnet.debug.egress_trace_sample_rate,
}
}
}
@@ -192,6 +197,12 @@ impl SharedData {
match accepted {
Ok((socket, remote_addr)) => {
debug!("accepted incoming mixnet connection from: {remote_addr}");
// disable Nagle: mix packets are latency-sensitive and flushed one at a time.
if let Err(err) = socket.set_nodelay(true) {
warn!(
"failed to set TCP_NODELAY on mixnet connection from {remote_addr}: {err}"
);
}
let mut handler = ConnectionHandler::new(self, remote_addr);
let join_handle =
tokio::spawn(async move { handler.handle_connection(socket).await });
@@ -210,6 +221,7 @@ impl SharedData {
packet: MixPacket,
delay_until: Option<Instant>,
network_monitor_packet: bool,
trace: PacketTrace,
) {
let has_delay = delay_until.is_some();
if self
@@ -218,6 +230,7 @@ impl SharedData {
packet,
delay_until,
network_monitor_packet,
trace,
))
.is_err()
&& !self.shutdown_token.is_cancelled()
@@ -231,9 +244,11 @@ impl SharedData {
}
}
pub(super) fn forward_ack_packet(&self, forward_ack: Option<MixPacket>) {
pub(super) fn forward_ack_packet(&self, forward_ack: Option<MixPacket>, trace: PacketTrace) {
if let Some(forward_ack) = forward_ack {
self.forward_mix_packet(forward_ack, None, false);
// an ack is forwarded just like a normal packet, carrying the trace of the
// final-hop packet it was derived from
self.forward_mix_packet(forward_ack, None, false, trace);
self.metrics.mixnet.egress_sent_ack();
}
}
+5
View File
@@ -1270,6 +1270,11 @@ impl NymNode {
{
let processing_config = ProcessingConfig::new(&self.config);
// pre-register the per-stage packet-latency histograms so the whole mixnet_packet_* family
// is present on the prometheus endpoint at zero from boot (not just after the first
// sampled packet)
nym_mixnet_client::trace::register_stage_metrics();
// we're ALWAYS listening for mixnet packets, either for forward or final hops (or both)
info!(
"Starting the mixnet listener... on {} (forward: {}, final hop: {}))",
+13
View File
@@ -9,6 +9,7 @@ use crate::throughput_tester::global_stats::GlobalStatsUpdater;
use crate::throughput_tester::stats::ClientStats;
use futures::future::join_all;
use human_repr::HumanDuration;
use indicatif::{ProgressState, ProgressStyle};
use nym_task::ShutdownToken;
use rand::{Rng, thread_rng};
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
@@ -123,6 +124,18 @@ pub(crate) fn test_mixing_throughput(
}
let header_span = info_span!("header");
header_span.pb_set_style(
&ProgressStyle::with_template(
"testing mixing throughput of this machine... {wide_msg} {elapsed}\n{wide_bar}",
)?
.with_key(
"elapsed",
|state: &ProgressState, writer: &mut dyn std::fmt::Write| {
let _ = writer.write_str(&format!("{}", state.elapsed().human_duration()));
},
)
.progress_chars("---"),
);
header_span.pb_start();
// Bit of a hack to show a full "-----" line underneath the header.
@@ -32,7 +32,8 @@ pub const NETWORK_MONITORS_CONTRACT_ADDRESS: &str =
"n1x5krtvyqklj360x38v62ze42g8s8trfsfqzlv8c9296chcpvqadssqnem5";
// \/ TODO: this has to be updated once the contract is deployed
pub(crate) const NODE_FAMILIES_CONTRACT_ADDRESS: &str = "";
pub(crate) const NODE_FAMILIES_CONTRACT_ADDRESS: &str =
"n13clyapdqk5umyynp20kqwf59rxlwlp24yf2ltzasflhsdhrxq7fsahyr6z";
// /\ TODO: this has to be updated once the contract is deployed
// -- Constructor functions --
+12 -7
View File
@@ -214,9 +214,10 @@ The `family_members` map SHALL be keyed by `NodeId` alone; the value SHALL carry
- reject `validity == 0` with `ZeroInvitationValidity`;
- verify `node_id` refers to a currently-bonded, not-unbonding node in the mixnet contract via `MixnetContractQuerier::check_node_existence` (which returns `false` both when no bond exists and when the bond is in the unbonding state), failing with `NodeDoesntExist { node_id }` otherwise;
- verify the node is not already in any family (see "A node belongs to at most one family");
- reject `(family_id, node_id)` pairs that already have a **still-valid** pending invitation (`env.block.time.seconds() < existing.expires_at`) with `PendingInvitationAlreadyExists { family_id, node_id }`;
- when a pending invitation for the pair exists but has already expired (`env.block.time.seconds() >= existing.expires_at`), archive it as `PastFamilyInvitation { invitation, status: Expired { at: env.block.time.seconds() } }` using the next free per-`(family, node)` archive slot, then let the fresh invitation supersede it;
- persist a `FamilyInvitation` with `expires_at = env.block.time.seconds() + validity`;
- reject `(family_id, node_id)` pairs that already have a pending invitation with `PendingInvitationAlreadyExists { family_id, node_id }`;
- emit a `family_invitation` event with `family_id`, `node_id`, `expires_at`.
- emit a `family_invitation` event with `family_id`, `node_id`, `expires_at` (the same event whether or not it superseded an expired invitation).
#### Scenario: Successful invitation persists with the computed expiry
- **WHEN** family owner sends `InviteToFamily { node_id, validity_secs: Some(v) }` and all preconditions hold
@@ -235,9 +236,13 @@ The `family_members` map SHALL be keyed by `NodeId` alone; the value SHALL carry
- **WHEN** `InviteToFamily { node_id }` targets a `node_id` for which the mixnet contract's `check_node_existence` returns `false`
- **THEN** the call fails with `NodeDoesntExist { node_id }`
#### Scenario: Duplicate pending invitation is rejected
- **WHEN** family `F` already has a pending invitation for node `n` and `InviteToFamily { node_id: n }` is sent again by `F`'s owner
- **THEN** the call fails with `PendingInvitationAlreadyExists { family_id: F.id, node_id: n }` (the existing invitation is preserved)
#### Scenario: Duplicate still-valid pending invitation is rejected
- **WHEN** family `F` already has a still-valid (not yet expired) pending invitation for node `n` and `InviteToFamily { node_id: n }` is sent again by `F`'s owner
- **THEN** the call fails with `PendingInvitationAlreadyExists { family_id: F.id, node_id: n }` (the existing invitation is preserved and nothing is archived)
#### Scenario: Re-inviting after the previous invitation expired supersedes it
- **WHEN** family `F` has a pending invitation for node `n` whose `expires_at <= env.block.time.seconds()` and `InviteToFamily { node_id: n }` is sent again by `F`'s owner
- **THEN** the call succeeds: the stale invitation is archived under `past_family_invitations` with `status = Expired { at: env.block.time.seconds() }`, and a fresh `FamilyInvitation` for `(F.id, n)` is persisted with the newly computed `expires_at`
### Requirement: Acceptance and rejection of an invitation are gated on node control
@@ -288,7 +293,7 @@ A successful `AcceptFamilyInvitation` SHALL:
- emit `family_invitation_rejected` or `family_invitation_revoked` respectively, with `family_id` and `node_id` attributes;
- fail with `InvitationNotFound { family_id, node_id }` if no pending invitation exists.
These two paths SHALL be the only ways to clear an expired invitation out of `pending_family_invitations` — the contract performs no background sweep of expired entries.
The contract performs no background sweep of expired entries. Reject and revoke are the two *targeted* ways to clear a specific pending invitation; an expired one is additionally cleared if the family owner re-invites the same node (archiving the stale entry as `Expired { at: now }` before superseding it — see "Invitations require an existing family …") or if the family is disbanded.
#### Scenario: Owner revokes a still-pending invitation
- **WHEN** family owner sends `RevokeFamilyInvitation { node_id }` for a node currently in their pending invitations
@@ -361,7 +366,7 @@ The auto-cleared invitations share the `Rejected` terminal state with invitation
### Requirement: Expired pending invitations remain in storage until explicitly cleared
The contract SHALL NOT run any background sweep of expired pending invitations. A `FamilyInvitation` whose `expires_at <= env.block.time.seconds()` SHALL remain in `pending_family_invitations` until either the family owner revokes it, the node controller rejects it, or the family is disbanded. Accept attempts against such entries MUST fail with `InvitationExpired`. Read queries SHALL surface a boolean `expired` flag (`now >= invitation.expires_at`) on each returned `PendingFamilyInvitationDetails` so callers can filter without doing the comparison themselves.
The contract SHALL NOT run any background sweep of expired pending invitations. A `FamilyInvitation` whose `expires_at <= env.block.time.seconds()` SHALL remain in `pending_family_invitations` until either the family owner revokes it, the node controller rejects it, the family owner re-invites the same node (which archives the stale entry as `Expired { at: now }` and replaces it), or the family is disbanded. Accept attempts against such entries MUST fail with `InvitationExpired`. Read queries SHALL surface a boolean `expired` flag (`now >= invitation.expires_at`) on each returned `PendingFamilyInvitationDetails` so callers can filter without doing the comparison themselves.
#### Scenario: Expired invitation is still listed by pending queries
- **WHEN** family `F` has a pending invitation for node `n` whose `expires_at` is in the past and `GetPendingInvitationsForFamilyPaged { family_id: F.id }` is queried
@@ -3,7 +3,7 @@
[package]
name = "nym-network-requester"
version = "1.1.78"
version = "1.1.79"
authors.workspace = true
edition.workspace = true
license = "GPL-3.0"
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "nym-cli"
version = "1.1.77"
version = "1.1.78"
authors.workspace = true
edition = "2021"
license.workspace = true
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "nymvisor"
version = "0.1.42"
version = "0.1.43"
authors.workspace = true
edition.workspace = true
license.workspace = true