Merge branch 'release/2026.11-xynomizithra' into develop
This commit is contained in:
@@ -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
+1345
-1405
File diff suppressed because it is too large
Load Diff
+109
-110
@@ -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,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,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
|
||||
|
||||
@@ -218,6 +218,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 +251,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<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 +327,22 @@ 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 res = async {
|
||||
for packet in batch {
|
||||
sink.feed(packet).await?;
|
||||
}
|
||||
sink.flush().await
|
||||
}
|
||||
.await;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,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();
|
||||
}
|
||||
|
||||
Generated
+278
-265
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",
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+27
-3
@@ -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",
|
||||
|
||||
+27
-3
@@ -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",
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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,27 +310,39 @@ 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()
|
||||
{
|
||||
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 {
|
||||
family_id,
|
||||
node_id,
|
||||
@@ -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();
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
@@ -882,9 +887,11 @@ 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;
|
||||
}
|
||||
|
||||
impl Default for MixnetDebug {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -18,6 +18,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;
|
||||
@@ -59,6 +60,11 @@ impl PendingReplayCheckPackets {
|
||||
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 {
|
||||
@@ -289,7 +295,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
|
||||
@@ -706,13 +712,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 +748,7 @@ impl ConnectionHandler {
|
||||
);
|
||||
Span::current().record("exit_reason", "corrupted");
|
||||
Span::current().record("packets_processed", packets_processed);
|
||||
return
|
||||
break
|
||||
}
|
||||
None => {
|
||||
debug!(
|
||||
@@ -742,14 +758,101 @@ 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();
|
||||
pending.push(batch_start, pending_packet(&key));
|
||||
|
||||
let deferral = Duration::from_millis(50);
|
||||
assert_eq!(
|
||||
pending.flush_deadline(deferral),
|
||||
Some(batch_start + deferral)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,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;
|
||||
|
||||
@@ -192,6 +192,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 });
|
||||
|
||||
@@ -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 --
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
[package]
|
||||
name = "nym-cli"
|
||||
version = "1.1.77"
|
||||
version = "1.1.78"
|
||||
authors.workspace = true
|
||||
edition = "2021"
|
||||
license.workspace = true
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "nymvisor"
|
||||
version = "0.1.42"
|
||||
version = "0.1.43"
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
Reference in New Issue
Block a user