From f06eefe184e0f4eae5b5c7edfd273fda9b3e07b6 Mon Sep 17 00:00:00 2001 From: mfahampshire Date: Wed, 18 Mar 2026 14:01:24 +0000 Subject: [PATCH 01/23] Only publish mixfetch in script (#6560) --- sdk/typescript/scripts/publish.sh | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/sdk/typescript/scripts/publish.sh b/sdk/typescript/scripts/publish.sh index d03fe73942..b46579dc86 100755 --- a/sdk/typescript/scripts/publish.sh +++ b/sdk/typescript/scripts/publish.sh @@ -23,12 +23,12 @@ cd dist #react/sdk-react #) packages=( -"wasm/client" +# "wasm/client" "wasm/mix-fetch" -"wasm/node-tester" -"wasm/extension-storage" +# "wasm/node-tester" +# "wasm/extension-storage" -"node/wasm/client" +# "node/wasm/client" #"node/wasm/mix-fetch" "ts/sdk/mix-fetch/cjs" @@ -36,18 +36,18 @@ packages=( "ts/sdk/mix-fetch/esm" "ts/sdk/mix-fetch/esm-full-fat" -"ts/sdk/nodejs-client/cjs" +# "ts/sdk/nodejs-client/cjs" "ts/sdk/mix-fetch-node/cjs" -"ts/sdk/node-tester/cjs" -"ts/sdk/node-tester/cjs-full-fat" -"ts/sdk/node-tester/esm" -"ts/sdk/node-tester/esm-full-fat" +# "ts/sdk/node-tester/cjs" +# "ts/sdk/node-tester/cjs-full-fat" +# "ts/sdk/node-tester/esm" +# "ts/sdk/node-tester/esm-full-fat" -"ts/sdk/sdk/cjs" -"ts/sdk/sdk/cjs-full-fat" -"ts/sdk/sdk/esm" -"ts/sdk/sdk/esm-full-fat" +# "ts/sdk/sdk/cjs" +# "ts/sdk/sdk/cjs-full-fat" +# "ts/sdk/sdk/esm" +# "ts/sdk/sdk/esm-full-fat" #"ts/sdk/contract-clients" ) From c5488337da1eccafe339ecafde354ee03b0e4ed0 Mon Sep 17 00:00:00 2001 From: mfahampshire Date: Wed, 18 Mar 2026 14:23:51 +0000 Subject: [PATCH 02/23] Max/mixfetch docs tweak (#6523) * update mixfetch concurrency info * Update MixFetch version + update note * Update python3 install method on docs runners --- .github/workflows/cd-docs.yml | 4 +-- .github/workflows/ci-docs.yml | 4 +-- .../outputs/api-scraping-outputs/time-now.md | 2 +- .../outputs/command-outputs/nym-api-help.md | 12 +++---- .../outputs/command-outputs/nym-node-help.md | 12 +++---- .../command-outputs/nym-node-run-help.md | 33 ++++++++++++------- .../outputs/command-outputs/nymvisor-help.md | 9 ++--- documentation/docs/package.json | 2 +- .../docs/pages/developers/browsers.mdx | 9 +++-- documentation/docs/pnpm-lock.yaml | 13 +++----- 10 files changed, 49 insertions(+), 51 deletions(-) diff --git a/.github/workflows/cd-docs.yml b/.github/workflows/cd-docs.yml index 3cdcbd5fa7..c36e393b6a 100644 --- a/.github/workflows/cd-docs.yml +++ b/.github/workflows/cd-docs.yml @@ -15,10 +15,8 @@ jobs: - uses: actions/checkout@v6 - name: Install Dependencies (Linux) run: sudo apt-get update && sudo apt-get install -y build-essential curl wget libssl-dev libudev-dev squashfs-tools protobuf-compiler git python3 && sudo apt-get update --fix-missing - - name: Install pip3 - run: sudo apt install -y python3-pip - name: Install Python3 modules - run: sudo pip3 install pandas tabulate + run: sudo apt install -y python3-pandas python3-tabulate - name: Install rsync run: sudo apt-get install -y rsync - uses: rlespinasse/github-slug-action@v3.x diff --git a/.github/workflows/ci-docs.yml b/.github/workflows/ci-docs.yml index 2c68468e3a..1501111f46 100644 --- a/.github/workflows/ci-docs.yml +++ b/.github/workflows/ci-docs.yml @@ -20,10 +20,8 @@ jobs: - uses: actions/checkout@v6 - name: Install Dependencies (Linux) run: sudo apt-get update && sudo apt-get install -y build-essential curl wget libssl-dev libudev-dev squashfs-tools protobuf-compiler git python3 && sudo apt-get update --fix-missing - - name: Install pip3 - run: sudo apt install -y python3-pip - name: Install Python3 modules - run: sudo pip3 install pandas tabulate + run: sudo apt install -y python3-pandas python3-tabulate - name: Install rsync run: sudo apt-get install -y rsync - uses: rlespinasse/github-slug-action@v3.x diff --git a/documentation/docs/components/outputs/api-scraping-outputs/time-now.md b/documentation/docs/components/outputs/api-scraping-outputs/time-now.md index 99b1edc85f..d0e186efdb 100644 --- a/documentation/docs/components/outputs/api-scraping-outputs/time-now.md +++ b/documentation/docs/components/outputs/api-scraping-outputs/time-now.md @@ -1 +1 @@ -Thursday, March 12th 2026, 13:23:46 UTC +Wednesday, March 18th 2026, 14:02:49 UTC diff --git a/documentation/docs/components/outputs/command-outputs/nym-api-help.md b/documentation/docs/components/outputs/command-outputs/nym-api-help.md index 1ba192c6e8..da2cedcfe5 100644 --- a/documentation/docs/components/outputs/command-outputs/nym-api-help.md +++ b/documentation/docs/components/outputs/command-outputs/nym-api-help.md @@ -8,12 +8,8 @@ Commands: help Print this message or the help of the given subcommand(s) Options: - -c, --config-env-file - Path pointing to an env file that configures the Nym API [env: NYMAPI_CONFIG_ENV_FILE_ARG=] - --no-banner - A no-op flag included for consistency with other binaries (and compatibility with nymvisor, oops) [env: NYMAPI_NO_BANNER_ARG=] - -h, --help - Print help - -V, --version - Print version + -c, --config-env-file Path pointing to an env file that configures the Nym API [env: NYMAPI_CONFIG_ENV_FILE_ARG=] + --no-banner A no-op flag included for consistency with other binaries (and compatibility with nymvisor, oops) [env: NYMAPI_NO_BANNER_ARG=] + -h, --help Print help + -V, --version Print version ``` diff --git a/documentation/docs/components/outputs/command-outputs/nym-node-help.md b/documentation/docs/components/outputs/command-outputs/nym-node-help.md index f1af88721d..7eaa0fdaa3 100644 --- a/documentation/docs/components/outputs/command-outputs/nym-node-help.md +++ b/documentation/docs/components/outputs/command-outputs/nym-node-help.md @@ -12,12 +12,8 @@ Commands: help Print this message or the help of the given subcommand(s) Options: - -c, --config-env-file - Path pointing to an env file that configures the nym-node and overrides any preconfigured values [env: NYMNODE_CONFIG_ENV_FILE_ARG=] - --no-banner - Flag used for disabling the printed banner in tty [env: NYMNODE_NO_BANNER=] - -h, --help - Print help - -V, --version - Print version + -c, --config-env-file Path pointing to an env file that configures the nym-node and overrides any preconfigured values [env: NYMNODE_CONFIG_ENV_FILE_ARG=] + --no-banner Flag used for disabling the printed banner in tty [env: NYMNODE_NO_BANNER=] + -h, --help Print help + -V, --version Print version ``` diff --git a/documentation/docs/components/outputs/command-outputs/nym-node-run-help.md b/documentation/docs/components/outputs/command-outputs/nym-node-run-help.md index 1f43cff534..f10b058c61 100644 --- a/documentation/docs/components/outputs/command-outputs/nym-node-run-help.md +++ b/documentation/docs/components/outputs/command-outputs/nym-node-run-help.md @@ -9,7 +9,8 @@ Options: --config-file Path to a configuration file of this node [env: NYMNODE_CONFIG=] --accept-operator-terms-and-conditions - Explicitly specify whether you agree with the terms and conditions of a nym node operator as defined at [env: NYMNODE_ACCEPT_OPERATOR_TERMS=] + Explicitly specify whether you agree with the terms and conditions of a nym node operator as defined at [env: + NYMNODE_ACCEPT_OPERATOR_TERMS=] --deny-init Forbid a new node from being initialised if configuration file for the provided specification doesn't already exist [env: NYMNODE_DENY_INIT=] --init-only @@ -23,15 +24,18 @@ Options: -w, --write-changes If this node has been initialised before, specify whether to write any new changes to the config file [env: NYMNODE_WRITE_CONFIG_CHANGES=] --bonding-information-output - Specify output file for bonding information of this nym-node, i.e. its encoded keys. NOTE: the required bonding information is still a subject to change and this argument should be treated only as a preview of future features [env: NYMNODE_BONDING_INFORMATION_OUTPUT=] + Specify output file for bonding information of this nym-node, i.e. its encoded keys. NOTE: the required bonding information is still a subject to change and this argument should be + treated only as a preview of future features [env: NYMNODE_BONDING_INFORMATION_OUTPUT=] -o, --output Specify the output format of the bonding information (`text` or `json`) [env: NYMNODE_OUTPUT=] [default: text] [possible values: text, json] --public-ips - Comma separated list of public ip addresses that will be announced to the nym-api and subsequently to the clients. In nearly all circumstances, it's going to be identical to the address you're going to use for bonding [env: NYMNODE_PUBLIC_IPS=] + Comma separated list of public ip addresses that will be announced to the nym-api and subsequently to the clients. In nearly all circumstances, it's going to be identical to the + address you're going to use for bonding [env: NYMNODE_PUBLIC_IPS=] --hostname Optional hostname associated with this gateway that will be announced to the nym-api and subsequently to the clients [env: NYMNODE_HOSTNAME=] --location - Optional **physical** location of this node's server. Either full country name (e.g. 'Poland'), two-letter alpha2 (e.g. 'PL'), three-letter alpha3 (e.g. 'POL') or three-digit numeric-3 (e.g. '616') can be provided [env: NYMNODE_LOCATION=] + Optional **physical** location of this node's server. Either full country name (e.g. 'Poland'), two-letter alpha2 (e.g. 'PL'), three-letter alpha3 (e.g. 'POL') or three-digit + numeric-3 (e.g. '616') can be provided [env: NYMNODE_LOCATION=] --http-bind-address Socket address this node will use for binding its http API. default: `[::]:8080` [env: NYMNODE_HTTP_BIND_ADDRESS=] --landing-page-assets-path @@ -61,9 +65,11 @@ Options: --wireguard-tunnel-announced-port Tunnel port announced to external clients wishing to connect to the wireguard interface. Useful in the instances where the node is behind a proxy [env: NYMNODE_WG_ANNOUNCED_PORT=] --wireguard-private-network-prefix - The prefix denoting the maximum number of the clients that can be connected via Wireguard. The maximum value for IPv4 is 32 and for IPv6 is 128 [env: NYMNODE_WG_PRIVATE_NETWORK_PREFIX=] + The prefix denoting the maximum number of the clients that can be connected via Wireguard. The maximum value for IPv4 is 32 and for IPv6 is 128 [env: + NYMNODE_WG_PRIVATE_NETWORK_PREFIX=] --wireguard-userspace - Use userspace implementation of WireGuard (wireguard-go) instead of kernel module. Useful in containerized environments without kernel WireGuard support [env: NYMNODE_WG_USERSPACE=] [possible values: true, false] + Use userspace implementation of WireGuard (wireguard-go) instead of kernel module. Useful in containerized environments without kernel WireGuard support [env: NYMNODE_WG_USERSPACE=] + [possible values: true, false] --verloc-bind-address Socket address this node will use for binding its verloc API. default: `[::]:1790` [env: NYMNODE_VERLOC_BIND_ADDRESS=] --verloc-announce-port @@ -75,27 +81,32 @@ Options: --announce-wss-port If applicable, announced port for listening for secure websocket client traffic [env: NYMNODE_ENTRY_ANNOUNCE_WSS_PORT=] --enforce-zk-nyms - Indicates whether this gateway is accepting only coconut credentials for accessing the mixnet or if it also accepts non-paying clients [env: NYMNODE_ENFORCE_ZK_NYMS=] [possible values: true, false] + Indicates whether this gateway is accepting only coconut credentials for accessing the mixnet or if it also accepts non-paying clients [env: NYMNODE_ENFORCE_ZK_NYMS=] [possible + values: true, false] --mnemonic Custom cosmos wallet mnemonic used for zk-nym redemption. If no value is provided, a fresh mnemonic is going to be generated [env: NYMNODE_MNEMONIC=] --upgrade-mode-attestation-url Endpoint to query to retrieve current upgrade mode attestation. This argument should never be set outside testnets and local networks [env: NYMNODE_UPGRADE_MODE_ATTESTATION_URL=] --upgrade-mode-attester-public-key - Expected public key of the entity signing the published attestation. This argument should never be set outside testnets and local networks [env: NYMNODE_UPGRADE_MODE_ATTESTER_PUBKEY=] + Expected public key of the entity signing the published attestation. This argument should never be set outside testnets and local networks [env: + NYMNODE_UPGRADE_MODE_ATTESTER_PUBKEY=] --upstream-exit-policy-url Specifies the url for an upstream source of the exit policy used by this node [env: NYMNODE_UPSTREAM_EXIT_POLICY=] --open-proxy - Specifies whether this exit node should run in 'open-proxy' mode and thus would attempt to resolve **ANY** request it receives [env: NYMNODE_OPEN_PROXY=] [possible values: true, false] + Specifies whether this exit node should run in 'open-proxy' mode and thus would attempt to resolve **ANY** request it receives [env: NYMNODE_OPEN_PROXY=] [possible values: true, + false] --lp-control-bind-address Bind address for the TCP LP control traffic. default: `[::]:41264` [env: NYMNODE_LP_CONTROL_BIND_ADDRESS=] --lp-control-announce-port - Custom announced port for listening for the TCP LP control traffic. If unspecified, the value from the `lp_control_bind_address` will be used instead [env: NYMNODE_LP_CONTROL_ANNOUNCE_PORT=] + Custom announced port for listening for the TCP LP control traffic. If unspecified, the value from the `lp_control_bind_address` will be used instead [env: + NYMNODE_LP_CONTROL_ANNOUNCE_PORT=] --lp-data-bind-address Bind address for the UDP LP data traffic. default: `[::]:51264` [env: NYMNODE_LP_DATA_BIND_ADDRESS=] --lp-data-announce-port Custom announced port for listening for the UDP LP data traffic. If unspecified, the value from the `lp_data_bind_address` will be used instead [env: NYMNODE_LP_DATA_ANNOUNCE_PORT=] --lp-use-mock-ecash - Use mock ecash manager for LP testing. WARNING: Only use this for local testing! Never enable in production. When enabled, the LP listener will accept any credential without blockchain verification [env: NYMNODE_LP_USE_MOCK_ECASH=] [possible values: true, false] + Use mock ecash manager for LP testing. WARNING: Only use this for local testing! Never enable in production. When enabled, the LP listener will accept any credential without + blockchain verification [env: NYMNODE_LP_USE_MOCK_ECASH=] [possible values: true, false] -h, --help Print help ``` diff --git a/documentation/docs/components/outputs/command-outputs/nymvisor-help.md b/documentation/docs/components/outputs/command-outputs/nymvisor-help.md index e4124a3ea9..e3c23ff77d 100644 --- a/documentation/docs/components/outputs/command-outputs/nymvisor-help.md +++ b/documentation/docs/components/outputs/command-outputs/nymvisor-help.md @@ -11,10 +11,7 @@ Commands: help Print this message or the help of the given subcommand(s) Options: - -c, --config-env-file - Path pointing to an env file that configures the nymvisor and overrides any preconfigured values - -h, --help - Print help - -V, --version - Print version + -c, --config-env-file Path pointing to an env file that configures the nymvisor and overrides any preconfigured values + -h, --help Print help + -V, --version Print version ``` diff --git a/documentation/docs/package.json b/documentation/docs/package.json index 40a77f6600..8d2a07a1ac 100644 --- a/documentation/docs/package.json +++ b/documentation/docs/package.json @@ -38,7 +38,7 @@ "@nextui-org/accordion": "^2.0.40", "@nextui-org/react": "^2.4.8", "@nymproject/contract-clients": ">=1.2.4-rc.2 || ^1", - "@nymproject/mix-fetch-full-fat": "^1.4.2", + "@nymproject/mix-fetch-full-fat": "^1.4.3", "@nymproject/sdk-full-fat": ">=1.5.1-rc.0 || ^1.4.1", "@redocly/cli": "^1.25.15", "@types/mdx": "^2.0.13", diff --git a/documentation/docs/pages/developers/browsers.mdx b/documentation/docs/pages/developers/browsers.mdx index b1b07f4edc..b8453ed32d 100644 --- a/documentation/docs/pages/developers/browsers.mdx +++ b/documentation/docs/pages/developers/browsers.mdx @@ -19,11 +19,16 @@ Internally it uses the WASM client. - [docs](./typescript/start#mixfetch) - [example](./typescript/playground/mixfetch) - + ### Current Limitations of `mixFetch` - It cannot currently handle concurrent requests, and comes with a pre-bundled CA store. `mixFetchv2` - which will act more like a general-purpose userspace IP stack - is currently in development. + `mixFetch` can currently only perform 10 concurrent requests (i.e. in-flight requests where a request has been sent to a remote endpoint, but no result has been recieved). + + `mixFetchv2` - which will act more like a general-purpose userspace IP stack - is currently in development. + + It is shipped with a pre-bundled CA store. + diff --git a/documentation/docs/pnpm-lock.yaml b/documentation/docs/pnpm-lock.yaml index bac8e0871d..39200508fb 100644 --- a/documentation/docs/pnpm-lock.yaml +++ b/documentation/docs/pnpm-lock.yaml @@ -78,8 +78,8 @@ importers: specifier: '>=1.2.4-rc.2 || ^1' version: 1.4.1 '@nymproject/mix-fetch-full-fat': - specifier: ^1.4.2 - version: 1.4.2 + specifier: ^1.4.3 + version: 1.4.3 '@nymproject/sdk-full-fat': specifier: '>=1.5.1-rc.0 || ^1.4.1' version: 1.4.1 @@ -104,9 +104,6 @@ importers: next: specifier: 15.5.10 version: 15.5.10(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - next-sitemap: - specifier: 4.2.3 - version: 4.2.3(next@15.5.10(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) nextra: specifier: '2' version: 2.13.4(next@15.5.10(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -1778,8 +1775,8 @@ packages: '@nymproject/contract-clients@1.4.1': resolution: {integrity: sha512-HuJZ4Hv+Rl6ZZEtCHKgurNLJapM+QQRJlGkevFH2a4UdqUqF9omUkUi3AVes4679dPoSFgvA7plyVSDBdbgV6w==} - '@nymproject/mix-fetch-full-fat@1.4.2': - resolution: {integrity: sha512-QHPwa7A+c/2VUm4Imq2I21toFiZhbZxcjHud1sFsE9hN5BWxZ+QJKV2bg9oBUzulzoQabsk48RA13/hqU7c4KA==} + '@nymproject/mix-fetch-full-fat@1.4.3': + resolution: {integrity: sha512-r3WVZDDFv+eFWPxhkMDg2VvLWd3ws1OK6kFT6bEt6/qTpfA21vV2MouuuyVkBy8DEUKqyU4z/8MiVFxfpkWlsg==} '@nymproject/sdk-full-fat@1.4.1': resolution: {integrity: sha512-dh5bvMUj3m8nEssvO8Nl66WpcJAjwRZrGNwqfczJWLG4nX3Vt95tPLv4v0/Z1W3DQWQFW6WmEPPYHNjl18V/fA==} @@ -9536,7 +9533,7 @@ snapshots: '@nymproject/contract-clients@1.4.1': {} - '@nymproject/mix-fetch-full-fat@1.4.2': {} + '@nymproject/mix-fetch-full-fat@1.4.3': {} '@nymproject/sdk-full-fat@1.4.1': {} From abb4e3f9881000e9daa3ca764ab5963e115c89d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C4=99drzej=20Stuczy=C5=84ski?= Date: Thu, 19 Mar 2026 08:55:00 +0000 Subject: [PATCH 03/23] bugfix: make sure client keys are generated before requesting credentials (#6579) --- nym-gateway-probe/src/lib.rs | 1 + sdk/rust/nym-sdk/src/mixnet/client.rs | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/nym-gateway-probe/src/lib.rs b/nym-gateway-probe/src/lib.rs index 0ac823cf4b..ada594ed00 100644 --- a/nym-gateway-probe/src/lib.rs +++ b/nym-gateway-probe/src/lib.rs @@ -198,6 +198,7 @@ impl Probe { } let disconnected_mixnet_client = mixnet_client_builder.build()?; + disconnected_mixnet_client.setup_client_keys().await?; // Acquire credential if needed credential diff --git a/sdk/rust/nym-sdk/src/mixnet/client.rs b/sdk/rust/nym-sdk/src/mixnet/client.rs index bafc45b554..32165ef961 100644 --- a/sdk/rust/nym-sdk/src/mixnet/client.rs +++ b/sdk/rust/nym-sdk/src/mixnet/client.rs @@ -546,7 +546,7 @@ where .collect() } - async fn setup_client_keys(&self) -> Result<()> { + pub async fn setup_client_keys(&self) -> Result<()> { let mut rng = OsRng; let key_store = self.storage.key_store(); From 61d6acace8dce4453e2b1f136480f4662edfa434 Mon Sep 17 00:00:00 2001 From: Jack Wampler Date: Thu, 19 Mar 2026 07:38:48 -0600 Subject: [PATCH 04/23] HTTP domain rotation conditions (#6570) Add more explicit handling for df enable and domain rotations --- Cargo.lock | 43 +++++++++---------- Cargo.toml | 1 + common/http-api-client/Cargo.toml | 1 + common/http-api-client/src/lib.rs | 70 ++++++++++++++++++++++++++++--- 4 files changed, 89 insertions(+), 26 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c070c058fd..f4668822c1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2534,7 +2534,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3589,7 +3589,7 @@ dependencies = [ "once_cell", "rand 0.9.2", "ring", - "rustls 0.23.29", + "rustls 0.23.37", "thiserror 2.0.12", "tinyvec", "tokio", @@ -3614,7 +3614,7 @@ dependencies = [ "parking_lot", "rand 0.9.2", "resolv-conf", - "rustls 0.23.29", + "rustls 0.23.37", "smallvec", "thiserror 2.0.12", "tokio", @@ -3871,7 +3871,7 @@ dependencies = [ "http 1.3.1", "hyper 1.6.0", "hyper-util", - "rustls 0.23.29", + "rustls 0.23.37", "rustls-native-certs 0.8.3", "rustls-pki-types", "tokio", @@ -5448,7 +5448,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6849,6 +6849,7 @@ dependencies = [ "nym-network-defaults", "once_cell", "reqwest 0.13.1", + "rustls 0.23.37", "serde", "serde_json", "serde_plain", @@ -9651,7 +9652,7 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash", - "rustls 0.23.29", + "rustls 0.23.37", "socket2 0.5.10", "thiserror 2.0.12", "tokio", @@ -9672,7 +9673,7 @@ dependencies = [ "rand 0.9.2", "ring", "rustc-hash", - "rustls 0.23.29", + "rustls 0.23.37", "rustls-pki-types", "slab", "thiserror 2.0.12", @@ -9939,7 +9940,7 @@ dependencies = [ "percent-encoding", "pin-project-lite", "quinn", - "rustls 0.23.29", + "rustls 0.23.37", "rustls-native-certs 0.8.3", "rustls-pki-types", "serde", @@ -9980,7 +9981,7 @@ dependencies = [ "percent-encoding", "pin-project-lite", "quinn", - "rustls 0.23.29", + "rustls 0.23.37", "rustls-pki-types", "rustls-platform-verifier", "serde", @@ -10240,16 +10241,16 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.29" +version = "0.23.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2491382039b29b9b11ff08b76ff6c97cf287671dbb74f0be44bda389fffe9bd1" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" dependencies = [ "aws-lc-rs", "log", "once_cell", "ring", "rustls-pki-types", - "rustls-webpki 0.103.4", + "rustls-webpki 0.103.9", "subtle 2.6.1", "zeroize", ] @@ -10330,14 +10331,14 @@ dependencies = [ "jni", "log", "once_cell", - "rustls 0.23.29", + "rustls 0.23.37", "rustls-native-certs 0.8.3", "rustls-platform-verifier-android", - "rustls-webpki 0.103.4", + "rustls-webpki 0.103.9", "security-framework 3.6.0", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -10369,9 +10370,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.4" +version = "0.103.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a17884ae0c1b773f1ccd2bd4a8c72f16da897310a98b0e84bf349ad5ead92fc" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" dependencies = [ "aws-lc-rs", "ring", @@ -11202,7 +11203,7 @@ dependencies = [ "memchr", "once_cell", "percent-encoding", - "rustls 0.23.29", + "rustls 0.23.37", "serde", "serde_json", "sha2 0.10.9", @@ -12024,7 +12025,7 @@ version = "0.26.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b" dependencies = [ - "rustls 0.23.29", + "rustls 0.23.37", "tokio", ] @@ -12842,7 +12843,7 @@ dependencies = [ "serde", "tempfile", "textwrap", - "toml 0.8.23", + "toml 0.9.12+spec-1.1.0", "uniffi_internal_macros 0.31.0", "uniffi_meta 0.31.0", "uniffi_pipeline 0.31.0", @@ -12951,7 +12952,7 @@ dependencies = [ "quote", "serde", "syn 2.0.106", - "toml 0.8.23", + "toml 0.9.12+spec-1.1.0", "uniffi_meta 0.31.0", ] diff --git a/Cargo.toml b/Cargo.toml index 61411826b8..df0e4b6153 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -334,6 +334,7 @@ rayon = "1.5.1" regex = "1.10.6" reqwest = { version = "0.13.1", default-features = false } rs_merkle = "1.5.0" +rustls = { version = "0.23.37", default-features = false } schemars = "0.8.22" semver = "1.0.26" serde = "1.0.219" diff --git a/common/http-api-client/Cargo.toml b/common/http-api-client/Cargo.toml index cb297f004c..00d083bbf2 100644 --- a/common/http-api-client/Cargo.toml +++ b/common/http-api-client/Cargo.toml @@ -34,6 +34,7 @@ tracing = { workspace = true } itertools = { workspace = true } inventory = { workspace = true } tokio = { workspace = true, features = ["rt", "macros", "time"] } +rustls = { workspace=true } # used for decoding text responses (they were already implicitly included) bytes = { workspace = true } encoding_rs = { workspace = true } diff --git a/common/http-api-client/src/lib.rs b/common/http-api-client/src/lib.rs index c9baec7eb7..9d94dcf30e 100644 --- a/common/http-api-client/src/lib.rs +++ b/common/http-api-client/src/lib.rs @@ -161,6 +161,8 @@ use reqwest::{RequestBuilder, Response}; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use std::fmt::Display; +#[cfg(not(target_arch = "wasm32"))] +use std::io::ErrorKind; use std::sync::atomic::{AtomicUsize, Ordering}; use std::time::Duration; use thiserror::Error; @@ -1167,14 +1169,10 @@ impl ApiClientCore for Client { match response { Ok(resp) => return Ok(resp), Err(err) => { - // only if there was a network issue should we consider updating the host info - // - // note: for now this includes DNS resolution failure, I am not sure how I would go about - // segregating that based on the interface provided by request for errors. #[cfg(target_arch = "wasm32")] let is_network_err = err.is_timeout(); #[cfg(not(target_arch = "wasm32"))] - let is_network_err = err.is_timeout() || err.is_connect(); + let is_network_err = might_be_network_interference(&err); if is_network_err { // if we have multiple urls, update to the next @@ -1222,6 +1220,68 @@ impl ApiClientCore for Client { } } +#[cfg(not(target_arch = "wasm32"))] +const MAX_ERR_SOURCE_ITERATIONS: usize = 4; + +/// This functions attempts to check the error returned by reqwest to see if +/// rotating host informtion (for clients with mutliple hosts defined) could be +/// helpful. This looks for situations where the error could plausibly be caused +/// by a network adversary, or where rotating to an equival hostname might help. +/// +/// For example --> NetworkUnreachable will not be helped by rotating domains, +/// but ConnectionReset might be caused by a network adversary blocking by SNI +/// which could possibly benefit from rotating domains. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn might_be_network_interference(err: &reqwest::Error) -> bool { + if err.is_timeout() { + return true; + } + + if !(err.is_connect() || err.is_request()) { + return false; + } + + // The io::Error source is several layers deep, for clarity this is done as a loop + // * reqwest::Error -> hyper_util::Error + // * hyper_util::Error -> hyper_util::ClientError + // * hyper_util::ClientError -> io::Error + let mut inner = err.source(); + for _ in 0..MAX_ERR_SOURCE_ITERATIONS { + if let Some(e) = inner { + if let Some(io_err) = e.downcast_ref::() { + // try downcast to io::Error from + match io_err.kind() { + // device not connected to the internet + ErrorKind::NetworkUnreachable | ErrorKind::NetworkDown => return false, + // connection errors can indicate connection interference + ErrorKind::ConnectionReset + | ErrorKind::HostUnreachable + | ErrorKind::ConnectionRefused => return true, + // TLS errors get wrapped in custom io::Errors + ErrorKind::Other | ErrorKind::InvalidData => { + // io::Error get_ref works while source doesn't here -_- + // if you don't like it take it up with the rust devs https://users.rust-lang.org/t/question-about-implementation-of-std-source/121117 + inner = io_err.get_ref().map(|e| e as &dyn std::error::Error); + } + _ => return false, + } + } else if let Some(_tls_err) = e.downcast_ref::() { + // try downcast to TLS error + return true; + } else if let Some(resolve_err) = e.downcast_ref::() { + // try downcast to DNS error + return resolve_err.is_nx_domain(); + } else { + inner = e.source(); + } + } else { + break; + } + } + + false +} + /// Common usage functionality for the http client. /// /// These functions allow for cleaner downstream usage free of type parameters and unneeded imports. From bc3df3151821e1e948236318d9516077bf563952 Mon Sep 17 00:00:00 2001 From: Simon Wicky Date: Thu, 19 Mar 2026 15:09:20 +0100 Subject: [PATCH 05/23] move format_debug_bytes in common crate (#6580) * move format_debug_bytes in common crate * license change --- Cargo.lock | 1 + .../{nym-lp/src/packet/utils.rs => nym-common/src/debug.rs} | 3 +++ common/nym-common/src/lib.rs | 1 + common/nym-lp/Cargo.toml | 3 ++- common/nym-lp/src/packet/mod.rs | 4 ++-- 5 files changed, 9 insertions(+), 3 deletions(-) rename common/{nym-lp/src/packet/utils.rs => nym-common/src/debug.rs} (88%) diff --git a/Cargo.lock b/Cargo.lock index f4668822c1..d1abb0b765 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7087,6 +7087,7 @@ dependencies = [ "criterion", "libcrux-psq", "num_enum", + "nym-common", "nym-crypto", "nym-kkt", "nym-kkt-ciphersuite", diff --git a/common/nym-lp/src/packet/utils.rs b/common/nym-common/src/debug.rs similarity index 88% rename from common/nym-lp/src/packet/utils.rs rename to common/nym-common/src/debug.rs index 4abe90c213..4003daffef 100644 --- a/common/nym-lp/src/packet/utils.rs +++ b/common/nym-common/src/debug.rs @@ -1,3 +1,6 @@ +// Copyright 2026 Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + use std::fmt::{self, Write}; pub fn format_debug_bytes(bytes: &[u8]) -> Result { diff --git a/common/nym-common/src/lib.rs b/common/nym-common/src/lib.rs index b59be68666..a7db978223 100644 --- a/common/nym-common/src/lib.rs +++ b/common/nym-common/src/lib.rs @@ -2,6 +2,7 @@ // Copyright 2024 Nym Technologies SA // SPDX-License-Identifier: GPL-3.0-only +pub mod debug; mod error; pub mod flood; diff --git a/common/nym-lp/Cargo.toml b/common/nym-lp/Cargo.toml index 645b8f8ef2..56ba2c2c11 100644 --- a/common/nym-lp/Cargo.toml +++ b/common/nym-lp/Cargo.toml @@ -21,6 +21,7 @@ tls_codec = { workspace = true } tokio = { workspace = true, features = ["net", "io-util"] } nym-crypto = { workspace = true, features = ["hashing"] } +nym-common.workspace = true nym-kkt = { workspace = true } nym-kkt-ciphersuite = { workspace = true } @@ -45,4 +46,4 @@ mock = ["nym-test-utils"] [[bench]] name = "replay_protection" -harness = false \ No newline at end of file +harness = false diff --git a/common/nym-lp/src/packet/mod.rs b/common/nym-lp/src/packet/mod.rs index 34fddb9973..e5e8687c4b 100644 --- a/common/nym-lp/src/packet/mod.rs +++ b/common/nym-lp/src/packet/mod.rs @@ -1,10 +1,11 @@ // Copyright 2026 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 -use crate::packet::utils::format_debug_bytes; use bytes::{BufMut, BytesMut}; use std::fmt::{Debug, Formatter}; +use nym_common::debug::format_debug_bytes; + pub use error::MalformedLpPacketError; pub use frame::{ForwardPacketData, LpFrame}; pub use header::{InnerHeader, LpHeader, OuterHeader}; @@ -13,7 +14,6 @@ pub mod error; pub mod frame; pub mod header; pub mod replay; -pub mod utils; pub mod version { /// The current version of the Lewes Protocol that is put into each new constructed header. From 4077717d3a28314fbcd6de488e21a1288b050ed0 Mon Sep 17 00:00:00 2001 From: mfahampshire Date: Thu, 19 Mar 2026 15:30:59 +0000 Subject: [PATCH 06/23] Max/lp stream framing (#6573) * Add LpFrameKind::Stream variant with StreamFrameAttributes - Define LP wire format for stream multiplexing - Handle new variant in entry gateway match arm * Replace MixnetStream with LP framing - Replace custom header with LpFrameHeader - Added sequence number for message ordering * Revert accidental vergen bump * Revert accidental bumps * Rename Stream to SphinxStream and split match arms in client_handler * Add LpFrameAttributes type alias for [u8; 14] --- Cargo.lock | 1 + common/nym-lp/src/packet/frame.rs | 71 ++++++- .../node/lp/control/ingress/client_handler.rs | 17 +- sdk/rust/nym-sdk/Cargo.toml | 1 + .../src/mixnet/stream/mixnet_stream.rs | 11 +- sdk/rust/nym-sdk/src/mixnet/stream/mod.rs | 20 +- .../nym-sdk/src/mixnet/stream/protocol.rs | 198 +++++++++--------- 7 files changed, 203 insertions(+), 116 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d1abb0b765..c9a475dd78 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7792,6 +7792,7 @@ dependencies = [ "nym-crypto", "nym-gateway-requests", "nym-http-api-client", + "nym-lp", "nym-network-defaults", "nym-ordered-buffer", "nym-service-providers-common", diff --git a/common/nym-lp/src/packet/frame.rs b/common/nym-lp/src/packet/frame.rs index 5472b45d1b..81b73de04f 100644 --- a/common/nym-lp/src/packet/frame.rs +++ b/common/nym-lp/src/packet/frame.rs @@ -9,13 +9,13 @@ use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; #[derive(Debug, Clone, PartialEq)] pub struct LpFrameHeader { pub kind: LpFrameKind, - pub frame_attributes: [u8; 14], + pub frame_attributes: LpFrameAttributes, } impl LpFrameHeader { pub const SIZE: usize = 16; // message_kind(2) + message_attributes(14) - pub fn new(kind: LpFrameKind, frame_attributes: [u8; 14]) -> Self { + pub fn new(kind: LpFrameKind, frame_attributes: LpFrameAttributes) -> Self { Self { kind, frame_attributes, @@ -103,6 +103,13 @@ impl LpFrame { Self::new(LpFrameKind::Forward, data) } + pub fn new_stream(attrs: SphinxStreamFrameAttributes, content: impl Into) -> Self { + Self { + header: LpFrameHeader::new(LpFrameKind::SphinxStream, attrs.encode()), + content: content.into(), + } + } + pub(crate) fn len(&self) -> usize { LpFrameHeader::SIZE + self.content.len() } @@ -115,6 +122,66 @@ pub enum LpFrameKind { Opaque = 0, Registration = 1, Forward = 2, + SphinxStream = 3, +} + +/// Message type within a `LpFrameKind::SphinxStream` frame. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum SphinxStreamMsgType { + /// Open a new stream. Content is optional initial data. + Open = 0, + /// Data on an existing stream. + Data = 1, +} + +/// Parsed form of the 14-byte `frame_attributes` for `LpFrameKind::SphinxStream`. +/// +/// Wire layout (big-endian): +/// ```text +/// [0..8 ) stream_id : u64 +/// [8 ) msg_type : u8 (0 = Open, 1 = Data) +/// [9..13) sequence_num : u32 +/// [13 ) reserved : u8 +/// ``` +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SphinxStreamFrameAttributes { + pub stream_id: u64, + pub msg_type: SphinxStreamMsgType, + pub sequence_num: u32, +} + +/// Raw 14-byte frame attributes field in every [`LpFrameHeader`]. +/// Interpretation depends on the [`LpFrameKind`]. +pub type LpFrameAttributes = [u8; 14]; + +impl SphinxStreamFrameAttributes { + pub fn encode(&self) -> LpFrameAttributes { + let mut buf = [0u8; 14]; + buf[0..8].copy_from_slice(&self.stream_id.to_be_bytes()); + buf[8] = self.msg_type as u8; + buf[9..13].copy_from_slice(&self.sequence_num.to_be_bytes()); + buf + } + + pub fn parse(attrs: &LpFrameAttributes) -> Result { + let stream_id = u64::from_be_bytes(attrs[0..8].try_into().unwrap()); + let msg_type = match attrs[8] { + 0 => SphinxStreamMsgType::Open, + 1 => SphinxStreamMsgType::Data, + other => { + return Err(MalformedLpPacketError::DeserialisationFailure(format!( + "invalid stream msg_type: {other}" + ))); + } + }; + let sequence_num = u32::from_be_bytes(attrs[9..13].try_into().unwrap()); + Ok(Self { + stream_id, + msg_type, + sequence_num, + }) + } } #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/nym-node/src/node/lp/control/ingress/client_handler.rs b/nym-node/src/node/lp/control/ingress/client_handler.rs index 5fc23caf0d..6cd8a6c1b7 100644 --- a/nym-node/src/node/lp/control/ingress/client_handler.rs +++ b/nym-node/src/node/lp/control/ingress/client_handler.rs @@ -261,14 +261,27 @@ where self.handle_forwarding_request(receiver_idx, forward_data) .await } - typ @ LpFrameKind::Opaque => { + LpFrameKind::Opaque => { // Neither registration nor forwarding - unknown payload type warn!( "Unknown transport payload type from {remote} (receiver_idx={receiver_idx}). dropping {} bytes", bytes.len() ); inc!("lp_errors_unknown_payload_type"); - Err(LpHandlerError::UnexpectedLpPayload { typ }) + Err(LpHandlerError::UnexpectedLpPayload { + typ: LpFrameKind::Opaque, + }) + } + LpFrameKind::SphinxStream => { + // Neither registration nor forwarding - unknown payload type + warn!( + "Unknown transport payload type from {remote} (receiver_idx={receiver_idx}). dropping {} bytes", + bytes.len() + ); + inc!("lp_errors_unknown_payload_type"); + Err(LpHandlerError::UnexpectedLpPayload { + typ: LpFrameKind::SphinxStream, + }) } } } diff --git a/sdk/rust/nym-sdk/Cargo.toml b/sdk/rust/nym-sdk/Cargo.toml index 59bd347d2f..ded83e807a 100644 --- a/sdk/rust/nym-sdk/Cargo.toml +++ b/sdk/rust/nym-sdk/Cargo.toml @@ -33,6 +33,7 @@ nym-credentials-interface = { workspace = true } nym-credential-storage = { workspace = true } nym-credential-utils = { workspace = true } nym-network-defaults = { workspace = true } +nym-lp = { workspace = true } nym-sphinx = { workspace = true } nym-statistics-common = { workspace = true } nym-task = { workspace = true } diff --git a/sdk/rust/nym-sdk/src/mixnet/stream/mixnet_stream.rs b/sdk/rust/nym-sdk/src/mixnet/stream/mixnet_stream.rs index 1d3358aeea..5ba1d08853 100644 --- a/sdk/rust/nym-sdk/src/mixnet/stream/mixnet_stream.rs +++ b/sdk/rust/nym-sdk/src/mixnet/stream/mixnet_stream.rs @@ -16,7 +16,9 @@ use nym_sphinx::params::PacketType; use nym_task::connections::TransmissionLane; use tokio_util::sync::PollSender; -use super::protocol::{encode_stream_message, StreamId, StreamMessageType}; +use nym_lp::packet::frame::SphinxStreamMsgType; + +use super::protocol::{encode_stream_message, StreamId}; use super::StreamMap; /// How to address outbound messages on this stream. @@ -45,6 +47,7 @@ pub struct MixnetStream { inbound_rx: mpsc::UnboundedReceiver>, read_buf: BytesMut, deregistered: bool, + next_seq: u32, } impl MixnetStream { @@ -71,6 +74,7 @@ impl MixnetStream { inbound_rx, read_buf: BytesMut::new(), deregistered: false, + next_seq: 0, } } @@ -98,6 +102,7 @@ impl MixnetStream { inbound_rx, read_buf, deregistered: false, + next_seq: 0, } } @@ -177,7 +182,9 @@ impl AsyncWrite for MixnetStream { ready!(self.sender.poll_ready_unpin(cx)) .map_err(|_| std::io::Error::other("mixnet input channel closed"))?; - let wire = encode_stream_message(&self.id, StreamMessageType::Data, buf); + let seq = self.next_seq; + self.next_seq = self.next_seq.wrapping_add(1); + let wire = encode_stream_message(&self.id, SphinxStreamMsgType::Data, seq, buf); let msg = self.make_input_message(wire); self.sender diff --git a/sdk/rust/nym-sdk/src/mixnet/stream/mod.rs b/sdk/rust/nym-sdk/src/mixnet/stream/mod.rs index 2948837309..2e648099a9 100644 --- a/sdk/rust/nym-sdk/src/mixnet/stream/mod.rs +++ b/sdk/rust/nym-sdk/src/mixnet/stream/mod.rs @@ -32,7 +32,8 @@ use nym_sphinx::addressing::clients::Recipient; use nym_sphinx::anonymous_replies::requests::AnonymousSenderTag; use nym_task::connections::TransmissionLane; -use protocol::{decode_stream_message, encode_stream_message, StreamMessageType}; +use nym_lp::packet::frame::SphinxStreamMsgType; +use protocol::{decode_stream_message, encode_stream_message}; use crate::mixnet::native_client::MixnetClient; use crate::{Error, Result}; @@ -230,20 +231,20 @@ async fn run_router( continue; }; - let stream_id = frame.header.stream_id; - match frame.header.message_type { - StreamMessageType::Open => { + let stream_id = frame.stream_id; + match frame.msg_type { + SphinxStreamMsgType::Open => { let _ = listener_tx.send(InboundOpen { stream_id, sender_tag: msg.sender_tag, initial_data: frame.data.to_vec(), }); } - StreamMessageType::Data => { + SphinxStreamMsgType::Data => { streams .send_to_stream(&stream_id, frame.data.to_vec()) .await; - } // TODO: if we decide we need close logic add another enum member + } } } } @@ -294,8 +295,11 @@ pub(crate) async fn open_stream( let stream_id = StreamId::random(); let rx = streams.register_stream(stream_id).await; - // Send Open to the peer - let wire = encode_stream_message(&stream_id, StreamMessageType::Open, &[]); + // Currently hardcoded as we don't have message ordering in place *yet* in the SDK + // streams - when it *is* added then it will set the receiver's expected starting seq + // number. Gives us the ability down the road to e.g. pick up a dropped stream from + // where it left off. + let wire = encode_stream_message(&stream_id, SphinxStreamMsgType::Open, 0, &[]); let msg = InputMessage::new_anonymous( recipient, wire, diff --git a/sdk/rust/nym-sdk/src/mixnet/stream/protocol.rs b/sdk/rust/nym-sdk/src/mixnet/stream/protocol.rs index 0cd6891ee8..68e34176fc 100644 --- a/sdk/rust/nym-sdk/src/mixnet/stream/protocol.rs +++ b/sdk/rust/nym-sdk/src/mixnet/stream/protocol.rs @@ -1,24 +1,22 @@ //! Wire protocol for stream multiplexing. //! -//! Every message between streams carries a fixed header prepended to +//! Every message between streams carries an LP frame header prepended to //! the payload inside the mixnet message body: //! //! ```text -//! [Version: 1 byte][StreamId: 8 bytes][MessageType: 1 byte][payload: N bytes] +//! [LpFrameKind: 2 bytes LE][SphinxStreamFrameAttributes: 14 bytes][payload: N bytes] //! ``` //! -//! This header sits inside the sphinx packet payload. +//! The `SphinxStreamFrameAttributes` encode stream_id, message type, and sequence +//! number inside the LP header's `frame_attributes` field. This is the same +//! LP frame format used across the system (IPR detection, gateway dispatch). use std::fmt; -/// Current stream protocol version. -pub const STREAM_PROTOCOL_VERSION: u8 = 1; - -/// Length of a StreamId in bytes (u64, big-endian). -pub const STREAM_ID_LEN: usize = 8; - -/// Total header length: Version (1) + StreamId (8) + MessageType (1). -pub const STREAM_HEADER_LEN: usize = 1 + STREAM_ID_LEN + 1; +use bytes::BytesMut; +use nym_lp::packet::frame::{ + LpFrame, LpFrameHeader, LpFrameKind, SphinxStreamFrameAttributes, SphinxStreamMsgType, +}; /// Identifies a stream within a MixnetClient. #[derive(Clone, Copy, PartialEq, Eq, Hash)] @@ -29,12 +27,12 @@ impl StreamId { Self(rand::random::()) } - pub fn to_bytes(self) -> [u8; STREAM_ID_LEN] { - self.0.to_be_bytes() + pub fn as_u64(self) -> u64 { + self.0 } - pub fn from_bytes(bytes: [u8; STREAM_ID_LEN]) -> Self { - Self(u64::from_be_bytes(bytes)) + pub fn from_u64(v: u64) -> Self { + Self(v) } } @@ -50,88 +48,55 @@ impl fmt::Display for StreamId { } } -/// Message types within the stream protocol. -/// -/// Note: there is no Close variant. Without message sequencing, a close -/// message races ahead of in-flight data and arrives before the data is -/// reconstructed. Streams clean up locally via Drop. If ordered close/EOF -/// is needed in future, add sequencing + reorder buffering (see the -/// tcp_proxy's `MessageBuffer` for a working example). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[repr(u8)] -pub enum StreamMessageType { - /// Open a new stream. Payload is optional initial data. - Open = 0, - /// Data on an existing stream. - Data = 1, -} - -impl StreamMessageType { - pub fn from_byte(b: u8) -> Option { - match b { - 0 => Some(Self::Open), - 1 => Some(Self::Data), - _ => None, - } - } -} - -/// The fixed-size header prepended to every stream message. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct MixStreamHeader { - pub version: u8, - pub stream_id: StreamId, - pub message_type: StreamMessageType, -} - -/// A decoded stream frame: header + payload reference. +/// A decoded stream frame: LP header fields + payload reference. #[derive(Debug)] -pub struct MixStreamFrame<'a> { - pub header: MixStreamHeader, +pub struct StreamFrame<'a> { + pub stream_id: StreamId, + pub msg_type: SphinxStreamMsgType, + #[allow(dead_code)] // will be used for reordering + pub sequence_num: u32, pub data: &'a [u8], } -/// Encode a stream message: `[version][stream_id][msg_type][payload]`. +/// Encode a stream message as an LP frame: `[LpFrameHeader][payload]`. pub fn encode_stream_message( id: &StreamId, - msg_type: StreamMessageType, + msg_type: SphinxStreamMsgType, + sequence_num: u32, payload: &[u8], ) -> Vec { - let mut buf = Vec::with_capacity(STREAM_HEADER_LEN + payload.len()); - buf.push(STREAM_PROTOCOL_VERSION); - buf.extend_from_slice(&id.to_bytes()); - buf.push(msg_type as u8); - buf.extend_from_slice(payload); - buf + let attrs = SphinxStreamFrameAttributes { + stream_id: id.as_u64(), + msg_type, + sequence_num, + }; + let frame = LpFrame::new_stream(attrs, payload.to_vec()); + let mut buf = BytesMut::with_capacity(LpFrameHeader::SIZE + payload.len()); + frame.encode(&mut buf); + buf.to_vec() } -/// Decode a stream message into a [`MixStreamFrame`]. +/// Decode a stream message from LP frame bytes. /// -/// Returns `None` if the buffer is too short, the version is unknown, -/// or the message type byte is invalid. -pub fn decode_stream_message(bytes: &[u8]) -> Option> { - if bytes.len() < STREAM_HEADER_LEN { +/// Returns `None` if the buffer is too short, the frame kind is not `Stream`, +/// or the stream attributes are invalid. +pub fn decode_stream_message(bytes: &[u8]) -> Option> { + if bytes.len() < LpFrameHeader::SIZE { return None; } - let version = bytes[0]; - if version != STREAM_PROTOCOL_VERSION { + let header = LpFrameHeader::parse(bytes).ok()?; + if header.kind != LpFrameKind::SphinxStream { return None; } - let mut id_bytes = [0u8; STREAM_ID_LEN]; - id_bytes.copy_from_slice(&bytes[1..1 + STREAM_ID_LEN]); - let stream_id = StreamId::from_bytes(id_bytes); + let attrs = SphinxStreamFrameAttributes::parse(&header.frame_attributes).ok()?; + let data = &bytes[LpFrameHeader::SIZE..]; - let message_type = StreamMessageType::from_byte(bytes[1 + STREAM_ID_LEN])?; - let data = &bytes[STREAM_HEADER_LEN..]; - - Some(MixStreamFrame { - header: MixStreamHeader { - version, - stream_id, - message_type, - }, + Some(StreamFrame { + stream_id: StreamId::from_u64(attrs.stream_id), + msg_type: attrs.msg_type, + sequence_num: attrs.sequence_num, data, }) } @@ -144,11 +109,11 @@ mod tests { fn roundtrip() { let id = StreamId::random(); let payload = b"hello world"; - let encoded = encode_stream_message(&id, StreamMessageType::Data, payload); + let encoded = encode_stream_message(&id, SphinxStreamMsgType::Data, 42, payload); let frame = decode_stream_message(&encoded).unwrap(); - assert_eq!(frame.header.version, STREAM_PROTOCOL_VERSION); - assert_eq!(frame.header.stream_id, id); - assert_eq!(frame.header.message_type, StreamMessageType::Data); + assert_eq!(frame.stream_id, id); + assert_eq!(frame.msg_type, SphinxStreamMsgType::Data); + assert_eq!(frame.sequence_num, 42); assert_eq!(frame.data, payload); } @@ -158,41 +123,70 @@ mod tests { } #[test] - fn bad_version() { - let id = StreamId::random(); - let mut encoded = encode_stream_message(&id, StreamMessageType::Data, b"x"); - encoded[0] = 0xFF; - assert!(decode_stream_message(&encoded).is_none()); + fn wrong_frame_kind() { + // Opaque frame kind (0x00, 0x00) should not parse as stream + let mut buf = vec![0u8; LpFrameHeader::SIZE + 1]; + buf[LpFrameHeader::SIZE] = 0xAA; + assert!(decode_stream_message(&buf).is_none()); } #[test] - fn bad_message_type() { - let mut buf = [0u8; STREAM_HEADER_LEN]; - buf[0] = STREAM_PROTOCOL_VERSION; - buf[1 + STREAM_ID_LEN] = 0xFF; - assert!(decode_stream_message(&buf).is_none()); + fn bad_msg_type() { + let id = StreamId::random(); + let mut encoded = encode_stream_message(&id, SphinxStreamMsgType::Data, 0, b"x"); + // msg_type is at byte offset 2 + 8 = 10 (inside frame_attributes) + encoded[10] = 0xFF; + assert!(decode_stream_message(&encoded).is_none()); } #[test] fn empty_payload() { let id = StreamId::random(); - let encoded = encode_stream_message(&id, StreamMessageType::Open, &[]); + let encoded = encode_stream_message(&id, SphinxStreamMsgType::Open, 0, &[]); let frame = decode_stream_message(&encoded).unwrap(); - assert_eq!(frame.header.message_type, StreamMessageType::Open); + assert_eq!(frame.msg_type, SphinxStreamMsgType::Open); + assert_eq!(frame.sequence_num, 0); assert!(frame.data.is_empty()); } #[test] fn header_wire_format() { - let id = StreamId::from_bytes([0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77]); - let encoded = encode_stream_message(&id, StreamMessageType::Open, &[0xAA]); - assert_eq!(encoded.len(), STREAM_HEADER_LEN + 1); - assert_eq!(encoded[0], STREAM_PROTOCOL_VERSION); + let id = StreamId::from_u64(0x0011223344556677); + let encoded = encode_stream_message(&id, SphinxStreamMsgType::Open, 1, &[0xAA]); + + // LpFrameHeader::SIZE (16) + 1 byte payload + assert_eq!(encoded.len(), LpFrameHeader::SIZE + 1); + + // First 2 bytes: LpFrameKind::SphinxStream = 3, LE + assert_eq!(encoded[0], 0x03); + assert_eq!(encoded[1], 0x00); + + // Bytes 2..10: stream_id BE assert_eq!( - &encoded[1..9], + &encoded[2..10], &[0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77] ); - assert_eq!(encoded[9], StreamMessageType::Open as u8); - assert_eq!(encoded[10], 0xAA); + + // Byte 10: msg_type = Open = 0 + assert_eq!(encoded[10], SphinxStreamMsgType::Open as u8); + + // Bytes 11..15: sequence_num = 1, BE + assert_eq!(&encoded[11..15], &[0x00, 0x00, 0x00, 0x01]); + + // Byte 15: reserved = 0 + assert_eq!(encoded[15], 0x00); + + // Byte 16: payload + assert_eq!(encoded[16], 0xAA); + } + + #[test] + fn sequence_num_roundtrip() { + let id = StreamId::random(); + for seq in [0, 1, 255, 65535, u32::MAX] { + let encoded = encode_stream_message(&id, SphinxStreamMsgType::Data, seq, b"test"); + let frame = decode_stream_message(&encoded).unwrap(); + assert_eq!(frame.sequence_num, seq); + } } } From 180802feb8c3c64f61af39ff058cfe3aaac3fc13 Mon Sep 17 00:00:00 2001 From: dynco-nym <173912580+dynco-nym@users.noreply.github.com> Date: Fri, 20 Mar 2026 10:36:32 +0100 Subject: [PATCH 07/23] Fix socks5 GW probe regression (#6576) * Restore tested gateway into topology * Bump agent version * Update .sqlx files * Clean up code in probe test * Probe error & logging improvements * Fix clippy, improve log line * Improve logging in ns agent * Better tooling in NS API * Bump agent * Bump NS agent version --- Cargo.lock | 2 +- nym-gateway-probe/src/common/helpers.rs | 6 +-- nym-gateway-probe/src/common/probe_tests.rs | 31 ++++++++------- .../src/common/socks5_test/json_rpc_client.rs | 36 ++++++++++++------ .../src/common/socks5_test/mod.rs | 27 +++++++------ nym-gateway-probe/src/lib.rs | 2 - .../nym-node-status-agent/Cargo.toml | 2 +- .../nym-node-status-agent/run.sh | 2 +- .../src/cli/run_probe.rs | 35 +++++++++++++---- ...afa2525da6c0b871633aad80ad555db9cf47c.json | 32 ++++++++++++++++ ...b19f15d22fff72490473587a14cdc046fda32.json | 20 ++++++++++ ...3797a6a9ee7db2aab9f1b5e55f7c13c53bcc1.json | 12 ++++++ ...4c270fd5dd7f00eaab0fe82e6fe927e604294.json | 12 ++++++ ...af7974d7795b4cbdba234273345fe161df233.json | 12 ++++++ ...b5905d4dd6b7ac0e20f1cf100a51a1a40b35d.json | 12 ++++++ ...82b4dc00ba6a580de1faf83b137365f44b36d.json | 12 ++++++ ...105614dea7fd301f0ec38bfe85bfe546dad40.json | 12 ++++++ ...997139172411b10c7444dea74a1438de4f343.json | 12 ++++++ ...08cb6c3b99c6b808ba50c67b066fc3f36073b.json | 12 ++++++ ...765854899103fd3c0fa670eb6809492270e02.json | 12 ++++++ ...a317aabce063a650be362d3a8ed83cc7c3549.json | 12 ++++++ ...9f166ad7f57681f76a1d0c7723d007c1f2c1e.json | 12 ++++++ ...38edcdf149acfd38c56a4db3bbf97bdb13210.json | 12 ++++++ ...15212a6f526f78258e173c96ec177988696ef.json | 12 ++++++ ...2038a77ec37cca9f9aef18008dcd03030c2c4.json | 12 ++++++ ...bd97845350f4fb9f806c60b93c7cebd5e410d.json | 20 ++++++++++ ...91d2a2af57656b405212af414d765b2263347.json | 26 +++++++++++++ ...1bbace1bcf110e048e594e8bd03fa45290732.json | 12 ++++++ ...7cec1442967e53764b83ba0a66cd9a72494f9.json | 12 ++++++ ...6b2a5bec1c191993ff58f0c71c03e3ef6d92d.json | 12 ++++++ ...11e5732ece0bf84ea98f2328b20add8f2b5ef.json | 12 ++++++ ...5e03504afa58e345ae16a128629d8e76f0a11.json | 26 +++++++++++++ ...a8787133ee41814bd3efdd501f9ee0c050f2b.json | 20 ++++++++++ ...e38b7f6ceb8e9191a7b9201922efcf6b07966.json | 12 ++++++ ...751a5ca340ac9c6dd86da1a5e9a3e65ea589f.json | 26 +++++++++++++ ...0fd083e87cc9a18d798b2cf8d6774224aebed.json | 20 ++++++++++ ...6067dbf82d0dc376ad3f0df835b77dfcc3c37.json | 20 ++++++++++ ...0d5ebd37d62612b65b8ac4c9eb5d7b67a8884.json | 26 +++++++++++++ ...8d18a0c6792d7a4244a65243d4b22d578a1e5.json | 20 ++++++++++ ...7fca89d80869484f2f3c66cabb898f0298c62.json | 12 ++++++ ...2b60ccb76d6468f359891428c0bfb96ddd7ef.json | 12 ++++++ ...de04e5ece789fb0b0cd27ad09858fbf5c5e27.json | 12 ++++++ ...682169b87f488b6927fa67fade6bf5abf7582.json | 12 ++++++ ...6156311713d2e9f69d39c868cd16296b56326.json | 12 ++++++ ...4d63462e9ea0049e2edafea1dc3f8476b33e4.json | 12 ++++++ ...de3eb76fed954b7336530401db72cd008aff3.json | 12 ++++++ ...b57e6ea8685e9473a3a8bd635552810493a58.json | 32 ++++++++++++++++ ...57f91a11f5057273cb70bd0e629712d17dd41.json | 12 ++++++ ...9db66d1dc1307c0873627b39cb22a245b5a8c.json | 12 ++++++ ...15dcfcffb502ba8398caefd56c481f44eb84e.json | 12 ++++++ ...13ab15fed747fb0cee1c9f949fb58461b3f79.json | 32 ++++++++++++++++ ...4adec312abffa604fa805cc362a2c206107b4.json | 12 ++++++ ...6a811c593aea8febf1f891117e5e84213f147.json | 12 ++++++ ...eafc9b5f64061b6c9a829e2912945b6cffc82.json | 32 ++++++++++++++++ ...89dde284b38a3a4960afed758206d03ca1cf4.json | 12 ++++++ ...b253c071e04eb853d4b0f3b21782ea57c2f68.json | 12 ++++++ ...5ebe0222a2a0ba96f28e3a3aa58fdc4b689db.json | 12 ++++++ ...125c24750b523cfcad1e5e9069dbf7050e2a1.json | 20 ++++++++++ ...c9f560017b4da6b0303ea0397d9568229e167.json | 38 +++++++++++++++++++ ...abd806877ce914b514d4f7cd6be318c4debe6.json | 12 ++++++ ...1da2809ec4bd66ad49d2e3b8d67b741835475.json | 12 ++++++ ...8f633a94d18a19b7f0f96935a62560def7d0f.json | 12 ++++++ ...35677b9d9a1b29e4d89437134e92967fa98d1.json | 12 ++++++ ...52f2bfed2d918b894ec0f588e38dd5e8ad726.json | 12 ++++++ ...6a2e314787586a46cffd074abb67f2f4d109e.json | 38 +++++++++++++++++++ ...ac000904bb7fe3934786d652adc80fde78add.json | 20 ++++++++++ ...3d41724666bf92357bf5faf42120a2aab131c.json | 12 ++++++ ...18a1f62fa67dfe8f130deb876ebee11bf1602.json | 20 ++++++++++ ...bb91cd5046a8c9d4434cf085a4fa2ebf03e93.json | 12 ++++++ .../nym-node-status-api/Makefile | 1 + .../nym-node-status-api/docker-compose.yml | 4 +- 71 files changed, 1074 insertions(+), 54 deletions(-) create mode 100644 nym-node-status-api/nym-node-status-api/.sqlx/query-0112296b190328a3856d1adf51aafa2525da6c0b871633aad80ad555db9cf47c.json create mode 100644 nym-node-status-api/nym-node-status-api/.sqlx/query-06e743d143fcc4be20ca2af5e99b19f15d22fff72490473587a14cdc046fda32.json create mode 100644 nym-node-status-api/nym-node-status-api/.sqlx/query-16d10f0ac0ed9ce4239937f46df3797a6a9ee7db2aab9f1b5e55f7c13c53bcc1.json create mode 100644 nym-node-status-api/nym-node-status-api/.sqlx/query-1b43cc81ce6b6007ccc59172fc64c270fd5dd7f00eaab0fe82e6fe927e604294.json create mode 100644 nym-node-status-api/nym-node-status-api/.sqlx/query-1da6904e72b5abb9abf75affb13af7974d7795b4cbdba234273345fe161df233.json create mode 100644 nym-node-status-api/nym-node-status-api/.sqlx/query-1f4fde2cafa3b5fae95ab5fb653b5905d4dd6b7ac0e20f1cf100a51a1a40b35d.json create mode 100644 nym-node-status-api/nym-node-status-api/.sqlx/query-1fa61c23a1504de280d67c7943282b4dc00ba6a580de1faf83b137365f44b36d.json create mode 100644 nym-node-status-api/nym-node-status-api/.sqlx/query-1fc72f8ba24039548047e1766c9105614dea7fd301f0ec38bfe85bfe546dad40.json create mode 100644 nym-node-status-api/nym-node-status-api/.sqlx/query-20797a3e0bb951ed77216a906d7997139172411b10c7444dea74a1438de4f343.json create mode 100644 nym-node-status-api/nym-node-status-api/.sqlx/query-22bc9c7bcf96ec817c83c26104408cb6c3b99c6b808ba50c67b066fc3f36073b.json create mode 100644 nym-node-status-api/nym-node-status-api/.sqlx/query-284b3ceae42f9320c30323dde47765854899103fd3c0fa670eb6809492270e02.json create mode 100644 nym-node-status-api/nym-node-status-api/.sqlx/query-28681fcd8e2d4326f628681b8f2a317aabce063a650be362d3a8ed83cc7c3549.json create mode 100644 nym-node-status-api/nym-node-status-api/.sqlx/query-2930ca6e3875c74acb7abb9ad889f166ad7f57681f76a1d0c7723d007c1f2c1e.json create mode 100644 nym-node-status-api/nym-node-status-api/.sqlx/query-2c113b37864f9fec7e64c0f8fdd38edcdf149acfd38c56a4db3bbf97bdb13210.json create mode 100644 nym-node-status-api/nym-node-status-api/.sqlx/query-2f3c12cb0c48084b569e12ecb0315212a6f526f78258e173c96ec177988696ef.json create mode 100644 nym-node-status-api/nym-node-status-api/.sqlx/query-37f82c9ec26b53d01601a2d6df82038a77ec37cca9f9aef18008dcd03030c2c4.json create mode 100644 nym-node-status-api/nym-node-status-api/.sqlx/query-396f40c33f0f62796eb7449d640bd97845350f4fb9f806c60b93c7cebd5e410d.json create mode 100644 nym-node-status-api/nym-node-status-api/.sqlx/query-3cc446220668fb3e02f0578104291d2a2af57656b405212af414d765b2263347.json create mode 100644 nym-node-status-api/nym-node-status-api/.sqlx/query-42dfc51aea793fc60484cff6c481bbace1bcf110e048e594e8bd03fa45290732.json create mode 100644 nym-node-status-api/nym-node-status-api/.sqlx/query-4a4f3b32b313f7fbc6eb579659e7cec1442967e53764b83ba0a66cd9a72494f9.json create mode 100644 nym-node-status-api/nym-node-status-api/.sqlx/query-4f78619aca933484cd67cb89a376b2a5bec1c191993ff58f0c71c03e3ef6d92d.json create mode 100644 nym-node-status-api/nym-node-status-api/.sqlx/query-52b378e282d93db941eff53b5b311e5732ece0bf84ea98f2328b20add8f2b5ef.json create mode 100644 nym-node-status-api/nym-node-status-api/.sqlx/query-54f552a9dbe95236f946ac2b6615e03504afa58e345ae16a128629d8e76f0a11.json create mode 100644 nym-node-status-api/nym-node-status-api/.sqlx/query-5661cf1ad8bd5ca062e855e1971a8787133ee41814bd3efdd501f9ee0c050f2b.json create mode 100644 nym-node-status-api/nym-node-status-api/.sqlx/query-5c5d4bfabf18bc6fa56e76a9b98e38b7f6ceb8e9191a7b9201922efcf6b07966.json create mode 100644 nym-node-status-api/nym-node-status-api/.sqlx/query-5d3b8ad051ab6f46c702308c2fc751a5ca340ac9c6dd86da1a5e9a3e65ea589f.json create mode 100644 nym-node-status-api/nym-node-status-api/.sqlx/query-5e51396c534409a4b55c08170e00fd083e87cc9a18d798b2cf8d6774224aebed.json create mode 100644 nym-node-status-api/nym-node-status-api/.sqlx/query-62f471858c831ac762a0ecf60876067dbf82d0dc376ad3f0df835b77dfcc3c37.json create mode 100644 nym-node-status-api/nym-node-status-api/.sqlx/query-648fff18c4016fb356c164f813e0d5ebd37d62612b65b8ac4c9eb5d7b67a8884.json create mode 100644 nym-node-status-api/nym-node-status-api/.sqlx/query-67db5e91bcdc831e0d1659eda358d18a0c6792d7a4244a65243d4b22d578a1e5.json create mode 100644 nym-node-status-api/nym-node-status-api/.sqlx/query-70d8f240ad6edda6b8c7f2e800e7fca89d80869484f2f3c66cabb898f0298c62.json create mode 100644 nym-node-status-api/nym-node-status-api/.sqlx/query-727598e516090da6d26e36d09062b60ccb76d6468f359891428c0bfb96ddd7ef.json create mode 100644 nym-node-status-api/nym-node-status-api/.sqlx/query-74d77a3e15f49bde7b8b17dd638de04e5ece789fb0b0cd27ad09858fbf5c5e27.json create mode 100644 nym-node-status-api/nym-node-status-api/.sqlx/query-80476cf2906eb0ecf7f66c16bc5682169b87f488b6927fa67fade6bf5abf7582.json create mode 100644 nym-node-status-api/nym-node-status-api/.sqlx/query-805ad4f26e0234d7f482a263e186156311713d2e9f69d39c868cd16296b56326.json create mode 100644 nym-node-status-api/nym-node-status-api/.sqlx/query-81a12a8a419c88b1c28a5533fde4d63462e9ea0049e2edafea1dc3f8476b33e4.json create mode 100644 nym-node-status-api/nym-node-status-api/.sqlx/query-84cad8b1078a4000830835e6349de3eb76fed954b7336530401db72cd008aff3.json create mode 100644 nym-node-status-api/nym-node-status-api/.sqlx/query-85d542a8cb2e1fbba6142729056b57e6ea8685e9473a3a8bd635552810493a58.json create mode 100644 nym-node-status-api/nym-node-status-api/.sqlx/query-97d97ebb6bc8f4114fdea9ebc9f57f91a11f5057273cb70bd0e629712d17dd41.json create mode 100644 nym-node-status-api/nym-node-status-api/.sqlx/query-a5a71e480118639397f7464abb09db66d1dc1307c0873627b39cb22a245b5a8c.json create mode 100644 nym-node-status-api/nym-node-status-api/.sqlx/query-a5b18e66d77ff802e274623605e15dcfcffb502ba8398caefd56c481f44eb84e.json create mode 100644 nym-node-status-api/nym-node-status-api/.sqlx/query-a8b7ce0fe4755c28b96d1e503e313ab15fed747fb0cee1c9f949fb58461b3f79.json create mode 100644 nym-node-status-api/nym-node-status-api/.sqlx/query-b6ce77fcffda2ee24ba181213e04adec312abffa604fa805cc362a2c206107b4.json create mode 100644 nym-node-status-api/nym-node-status-api/.sqlx/query-b8257a0832d0124f0a8aaaf81dc6a811c593aea8febf1f891117e5e84213f147.json create mode 100644 nym-node-status-api/nym-node-status-api/.sqlx/query-ba96344db31b0f2155e2af53eaaeafc9b5f64061b6c9a829e2912945b6cffc82.json create mode 100644 nym-node-status-api/nym-node-status-api/.sqlx/query-bc823c54143e2dc590b91347cd089dde284b38a3a4960afed758206d03ca1cf4.json create mode 100644 nym-node-status-api/nym-node-status-api/.sqlx/query-bd1973696121b6128bd75ae80fab253c071e04eb853d4b0f3b21782ea57c2f68.json create mode 100644 nym-node-status-api/nym-node-status-api/.sqlx/query-bdaf8e8dd711ff420807659456f5ebe0222a2a0ba96f28e3a3aa58fdc4b689db.json create mode 100644 nym-node-status-api/nym-node-status-api/.sqlx/query-bf249752f08c283bf5942b6ff48125c24750b523cfcad1e5e9069dbf7050e2a1.json create mode 100644 nym-node-status-api/nym-node-status-api/.sqlx/query-c2b841762bdb963fff337ef5c8ec9f560017b4da6b0303ea0397d9568229e167.json create mode 100644 nym-node-status-api/nym-node-status-api/.sqlx/query-d3510846941fa2525926b9bfbcdabd806877ce914b514d4f7cd6be318c4debe6.json create mode 100644 nym-node-status-api/nym-node-status-api/.sqlx/query-daa9ffcbe13178d773e538e96971da2809ec4bd66ad49d2e3b8d67b741835475.json create mode 100644 nym-node-status-api/nym-node-status-api/.sqlx/query-db176e98198fe594d88eb860d918f633a94d18a19b7f0f96935a62560def7d0f.json create mode 100644 nym-node-status-api/nym-node-status-api/.sqlx/query-e29e0c82d034626f9b369348b8e35677b9d9a1b29e4d89437134e92967fa98d1.json create mode 100644 nym-node-status-api/nym-node-status-api/.sqlx/query-e584253e3856355899537eb8fc152f2bfed2d918b894ec0f588e38dd5e8ad726.json create mode 100644 nym-node-status-api/nym-node-status-api/.sqlx/query-e77ffab19b099b84470fe5611716a2e314787586a46cffd074abb67f2f4d109e.json create mode 100644 nym-node-status-api/nym-node-status-api/.sqlx/query-e8717ef6523ef9bcf37a0e81570ac000904bb7fe3934786d652adc80fde78add.json create mode 100644 nym-node-status-api/nym-node-status-api/.sqlx/query-eded014a5fffbd9c359f8557bcd3d41724666bf92357bf5faf42120a2aab131c.json create mode 100644 nym-node-status-api/nym-node-status-api/.sqlx/query-ef60c2683211cc4ec2d3e46392518a1f62fa67dfe8f130deb876ebee11bf1602.json create mode 100644 nym-node-status-api/nym-node-status-api/.sqlx/query-f3ebe259e26c05ecdd33bd9085dbb91cd5046a8c9d4434cf085a4fa2ebf03e93.json diff --git a/Cargo.lock b/Cargo.lock index c9a475dd78..81694ec79c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7468,7 +7468,7 @@ dependencies = [ [[package]] name = "nym-node-status-agent" -version = "1.1.3" +version = "1.1.4" dependencies = [ "anyhow", "clap", diff --git a/nym-gateway-probe/src/common/helpers.rs b/nym-gateway-probe/src/common/helpers.rs index 7f90fca60a..5dc330e9cc 100644 --- a/nym-gateway-probe/src/common/helpers.rs +++ b/nym-gateway-probe/src/common/helpers.rs @@ -76,9 +76,9 @@ pub async fn fetch_topology( }) .unwrap_or_default(); - if nym_api_urls.is_empty() { + let Some(nym_api_url) = nym_api_urls.first() else { return Err(String::from("No nym-api URLs available to fetch topology")); - } + }; let topology_config = NymApiTopologyProviderConfig { min_mixnode_performance: debug_config.topology.minimum_mixnode_performance, @@ -87,7 +87,7 @@ pub async fn fetch_topology( ignore_egress_epoch_role: debug_config.topology.ignore_egress_epoch_role, }; - let api_client = nym_http_api_client::Client::new_url(nym_api_urls[0].clone(), None) + let api_client = nym_http_api_client::Client::new_url(nym_api_url.clone(), None) .map_err(|e| e.to_string())?; let mut provider = NymApiTopologyProvider::new(topology_config, nym_api_urls, api_client); diff --git a/nym-gateway-probe/src/common/probe_tests.rs b/nym-gateway-probe/src/common/probe_tests.rs index e52b30fc1b..ed87c8be0d 100644 --- a/nym-gateway-probe/src/common/probe_tests.rs +++ b/nym-gateway-probe/src/common/probe_tests.rs @@ -29,7 +29,7 @@ use nym_lp::peer::DHKeyPair; use nym_registration_client::LpRegistrationClient; use nym_sdk::NymNetworkDetails; use nym_sdk::mixnet::{MixnetClient, MixnetClientBuilder, NodeIdentity, Recipient, Socks5}; -use nym_topology::{HardcodedTopologyProvider, NymTopology}; +use nym_topology::HardcodedTopologyProvider; use rand09::SeedableRng; use std::{ net::{IpAddr, Ipv4Addr, Ipv6Addr}, @@ -442,9 +442,7 @@ pub(crate) async fn do_socks5_connectivity_test( nr_recipient: &Recipient, entry_gateway_id: NodeIdentity, network_details: NymNetworkDetails, - min_gw_performance: Option, socks5_args: Socks5Args, - maybe_topology: Option, ) -> anyhow::Result { info!( "Starting SOCKS5 test through Network Requester: {}", @@ -463,23 +461,31 @@ pub(crate) async fn do_socks5_connectivity_test( nr_recipient.identity().to_base58_string() ); + // since we define both entry & exit gateways to be the same tested GW, + // this shouldn't negatively affect mixnet layers but it will force route + // construction in case GW would get filtered out of topology + let min_gw_performance = Some(0); + // create ephemeral SOCKS5 client let socks5_config = Socks5::new(nr_recipient.to_string()); - // debug config similar to main probe let debug_config = mixnet_debug_config(min_gw_performance, true); - let mut socks5_client_builder = MixnetClientBuilder::new_ephemeral() + // don't reuse topology: our gateway might be filtered out in it + info!("Fetching topology for SOCKS5 client..."); + let topology_provider: Box = + match helpers::fetch_topology(&network_details, &debug_config).await { + Ok(topology) => Box::new(HardcodedTopologyProvider::new(topology)), + Err(e) => return Ok(Socks5ProbeResults::error_before_connecting(e)), + }; + + let socks5_client_builder = MixnetClientBuilder::new_ephemeral() // Specify entry gateway explicitly .request_gateway(entry_gateway_id.to_base58_string()) .socks5_config(socks5_config) .network_details(network_details) - .debug_config(debug_config); - - if let Some(topology) = maybe_topology { - socks5_client_builder = socks5_client_builder - .custom_topology_provider(Box::new(HardcodedTopologyProvider::new(topology))); - } + .debug_config(debug_config) + .custom_topology_provider(topology_provider); let disconnected_socks5_client = socks5_client_builder.build()?; @@ -489,9 +495,8 @@ pub(crate) async fn do_socks5_connectivity_test( .await { Ok(client) => { - info!("🌐 Successfully connected to mixnet via SOCKS5 proxy"); info!( - "Connected via entry gateway: {}", + "🌐 Successfully connected to mixnet via SOCKS5 on entry gateway: {}", client.nym_address().gateway().to_base58_string() ); client diff --git a/nym-gateway-probe/src/common/socks5_test/json_rpc_client.rs b/nym-gateway-probe/src/common/socks5_test/json_rpc_client.rs index e38269ed04..1ccd1cb653 100644 --- a/nym-gateway-probe/src/common/socks5_test/json_rpc_client.rs +++ b/nym-gateway-probe/src/common/socks5_test/json_rpc_client.rs @@ -1,4 +1,4 @@ -use anyhow::bail; +use anyhow::{Context, bail}; use rand::Rng; use reqwest::Proxy; use serde::{Deserialize, Serialize}; @@ -35,7 +35,7 @@ impl JsonRpcClient { } pub(super) async fn https_request_with_fallbacks(&self) -> SingleHttpsTestResult { - let mut error_msg = String::new(); + let mut error_msg = Vec::new(); // endpoints are used as fallbacks: in case of success, return early for endpoint in self.test_endpoints.iter() { @@ -65,11 +65,11 @@ impl JsonRpcClient { } Ok((_, JsonRpcResponse::Err { error, .. })) => { warn!("JSON-RPC error: {} (code: {})", error.message, error.code); - error_msg = format!("JSON-RPC error: {}", error.message); + error_msg.push(format!("JSON-RPC error: {}", error.message)); } Err(e) => { - error_msg = e.to_string(); - error!("{}", &error_msg); + error_msg.push(e.to_string()); + error!("{}", &e); } } } @@ -78,8 +78,8 @@ impl JsonRpcClient { success: false, status_code: None, latency_ms: None, - endpoint_used: self.test_endpoints.last().cloned(), - error: Some(error_msg), + endpoint_used: Some(self.test_endpoints.join(",")), + error: Some(error_msg.join(",")), } } @@ -98,20 +98,32 @@ impl JsonRpcClient { { Ok(response) => { let status = response.status(); + let response_text = response + .text() + .await + .context("Failed to extract response text") + .unwrap_or_else(|e| e.to_string()); if status.is_success() { // Deserialize body into JsonRpcResponse - response - .json::() - .await + serde_json::from_str::(&response_text) .map(|res| (status, res)) .map_err(From::from) } else { - bail!("HTTP error status: {}", status.as_u16()); + bail!( + "HTTP error: {}\n{}", + status.as_u16(), + // truncate for logs in case response is too long + response_text.chars().take(200).collect::() + ); } } Err(e) => { + let status = e + .status() + .map(|s| s.to_string()) + .unwrap_or_else(|| "no HTTP status".to_string()); error!("HTTPS request failed: {}", e); - bail!("HTTPS request failed: {}", e); + bail!("HTTPS request failed: {} ({})", e, status); } } } diff --git a/nym-gateway-probe/src/common/socks5_test/mod.rs b/nym-gateway-probe/src/common/socks5_test/mod.rs index 21a0cbbb2f..7aa094e5c6 100644 --- a/nym-gateway-probe/src/common/socks5_test/mod.rs +++ b/nym-gateway-probe/src/common/socks5_test/mod.rs @@ -106,17 +106,26 @@ impl HttpsConnectivityResult { fn from_results(results: Vec) -> Self { let (successes, errors): (Vec, Vec) = results.into_iter().partition(|r| r.success); + let last_error_endpoint = errors + .iter() + .last() + .as_ref() + .and_then(|test| test.endpoint_used.clone()); let errors = errors .into_iter() .map(|r| r.error) - .collect::>>() - // partition above guarantees this vec is non-empty - .unwrap_or_default(); + .collect::>>(); - // use the last successful result for status_code and endpoint - // this works as an empty check as well: if there is no last success, array must be empty hence only errors are present + // this works as an empty check as well: if there is no last success, + // array must be empty hence only errors are present let Some(last_success) = successes.last() else { - return Self::with_errors(errors); + return Self { + https_success: false, + https_status_code: None, + https_latency_ms: None, + endpoint_used: last_error_endpoint, + errors, + }; }; // average latency from successful runs @@ -136,11 +145,7 @@ impl HttpsConnectivityResult { https_latency_ms: Some(avg_latency), endpoint_used: last_success.endpoint_used.clone(), // even in case of success, some errors were possible - errors: if errors.is_empty() { - None - } else { - Some(errors) - }, + errors, } } diff --git a/nym-gateway-probe/src/lib.rs b/nym-gateway-probe/src/lib.rs index ada594ed00..68bb3edd85 100644 --- a/nym-gateway-probe/src/lib.rs +++ b/nym-gateway-probe/src/lib.rs @@ -407,9 +407,7 @@ impl Probe { &network_requester, self.entry_node.identity, self.network.clone(), - self.config.min_gateway_mixnet_performance, self.config.socks5_args, - self.topology, ) .await { diff --git a/nym-node-status-api/nym-node-status-agent/Cargo.toml b/nym-node-status-api/nym-node-status-agent/Cargo.toml index 6adc231711..28ce1ba960 100644 --- a/nym-node-status-api/nym-node-status-agent/Cargo.toml +++ b/nym-node-status-api/nym-node-status-agent/Cargo.toml @@ -3,7 +3,7 @@ [package] name = "nym-node-status-agent" -version = "1.1.3" +version = "1.1.4" authors.workspace = true repository.workspace = true homepage.workspace = true diff --git a/nym-node-status-api/nym-node-status-agent/run.sh b/nym-node-status-api/nym-node-status-agent/run.sh index 99ebb1867a..98f684ea41 100755 --- a/nym-node-status-api/nym-node-status-agent/run.sh +++ b/nym-node-status-api/nym-node-status-agent/run.sh @@ -25,7 +25,7 @@ SERVER="${NODE_STATUS_AGENT_SERVER_ADDRESS}|${NODE_STATUS_AGENT_SERVER_PORT}" # hardcoded key used only for LOCAL TESTING export NODE_STATUS_AGENT_AUTH_KEY=${NODE_STATUS_AGENT_AUTH_KEY_STAGING:-"BjyC9SsHAZUzPRkQR4sPTvVrp4GgaquTh5YfSJksvvWT"} export NODE_STATUS_AGENT_PROBE_PATH="$crate_root/nym-gateway-probe" -export NODE_STATUS_AGENT_PROBE_EXTRA_ARGS="netstack-download-timeout-sec=30,netstack-num-ping=2,netstack-send-timeout-sec=1,netstack-recv-timeout-sec=1,socks5-json-rpc-url-list=https://cloudflare-eth.com;https://ethereum.publicnode.com" +export NODE_STATUS_AGENT_PROBE_EXTRA_ARGS="netstack-download-timeout-sec=30,netstack-num-ping=2,netstack-send-timeout-sec=1,netstack-recv-timeout-sec=1,socks5-json-rpc-url-list=https://cloudflare-eth.com;https://ethereum-rpc.publicnode.com" workers=${1:-1} echo "Running $workers workers in parallel" diff --git a/nym-node-status-api/nym-node-status-agent/src/cli/run_probe.rs b/nym-node-status-api/nym-node-status-agent/src/cli/run_probe.rs index 820136b3f0..889b0b4f08 100644 --- a/nym-node-status-api/nym-node-status-agent/src/cli/run_probe.rs +++ b/nym-node-status-api/nym-node-status-agent/src/cli/run_probe.rs @@ -1,3 +1,5 @@ +use tracing::instrument; + use crate::cli::{GwProbe, ServerConfig}; pub(crate) async fn run_probe( @@ -46,7 +48,7 @@ pub(crate) async fn run_probe( let testrun_id = testrun.assignment.testrun_id; let testrun_assigned_at = testrun.assignment.assigned_at_utc; - let gateway_identity_key = testrun.assignment.gateway_identity_key; + let gateway_identity_key = testrun.assignment.gateway_identity_key.clone(); tracing::info!("Received testrun {testrun_id} for gateway {gateway_identity_key} from primary",); @@ -61,7 +63,7 @@ pub(crate) async fn run_probe( // Extract JSON from log output (probe outputs logs followed by JSON) let json_str = extract_json_from_log(&log); if json_str.is_empty() { - tracing::error!("Failed to extract JSON from probe output"); + tracing::warn!("Failed to extract JSON from probe output"); } else { match serde_json::from_str::(&json_str) { Ok(json) => { @@ -87,13 +89,32 @@ pub(crate) async fn run_probe( } } - // Submit to ALL servers in parallel + submit_results_to_servers( + servers, + testrun_id, + testrun_assigned_at, + &gateway_identity_key, + log, + ) + .await; + + Ok(()) +} + +#[instrument(level = "info", skip_all, fields(gateway_id = %gateway_identity_key, testrun = testrun_id))] +async fn submit_results_to_servers( + servers: &[ServerConfig], + testrun_id: i32, + testrun_assigned_at: i64, + gateway_identity_key: &str, + log: String, +) { let handles = servers .iter() .enumerate() - .map(move |(idx, server)| { + .map(|(idx, server)| { let log = log.clone(); - let gateway_identity_key = gateway_identity_key.clone(); + let gateway_identity_key = gateway_identity_key.to_string(); async move { let auth_key = nym_crypto::asymmetric::ed25519::PrivateKey::from_bytes( @@ -139,7 +160,7 @@ pub(crate) async fn run_probe( match result { Ok(()) => { tracing::info!( - "✅ Successfully submitted {method} to server[{index}] {server_address}:{server_port}" + "✅ Successfully submitted {method} to server[{index}] {server_address}:{server_port}", ); } Err(e) => { @@ -149,8 +170,6 @@ pub(crate) async fn run_probe( } } } - - Ok(()) } /// Extract JSON from probe log output. diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-0112296b190328a3856d1adf51aafa2525da6c0b871633aad80ad555db9cf47c.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-0112296b190328a3856d1adf51aafa2525da6c0b871633aad80ad555db9cf47c.json new file mode 100644 index 0000000000..068bc54b4b --- /dev/null +++ b/nym-node-status-api/nym-node-status-api/.sqlx/query-0112296b190328a3856d1adf51aafa2525da6c0b871633aad80ad555db9cf47c.json @@ -0,0 +1,32 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT epoch_id as \"epoch_id: u32\", serialised_key, serialization_revision as \"serialization_revision: u8\"\n FROM master_verification_key WHERE epoch_id = ?\n ", + "describe": { + "columns": [ + { + "name": "epoch_id: u32", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "serialised_key", + "ordinal": 1, + "type_info": "Blob" + }, + { + "name": "serialization_revision: u8", + "ordinal": 2, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "0112296b190328a3856d1adf51aafa2525da6c0b871633aad80ad555db9cf47c" +} diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-06e743d143fcc4be20ca2af5e99b19f15d22fff72490473587a14cdc046fda32.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-06e743d143fcc4be20ca2af5e99b19f15d22fff72490473587a14cdc046fda32.json new file mode 100644 index 0000000000..20c47048d8 --- /dev/null +++ b/nym-node-status-api/nym-node-status-api/.sqlx/query-06e743d143fcc4be20ca2af5e99b19f15d22fff72490473587a14cdc046fda32.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT EXISTS (SELECT 1 FROM registered_gateway WHERE gateway_id_bs58 = ?) AS 'exists'", + "describe": { + "columns": [ + { + "name": "exists", + "ordinal": 0, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "06e743d143fcc4be20ca2af5e99b19f15d22fff72490473587a14cdc046fda32" +} diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-16d10f0ac0ed9ce4239937f46df3797a6a9ee7db2aab9f1b5e55f7c13c53bcc1.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-16d10f0ac0ed9ce4239937f46df3797a6a9ee7db2aab9f1b5e55f7c13c53bcc1.json new file mode 100644 index 0000000000..e4f26c5053 --- /dev/null +++ b/nym-node-status-api/nym-node-status-api/.sqlx/query-16d10f0ac0ed9ce4239937f46df3797a6a9ee7db2aab9f1b5e55f7c13c53bcc1.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n INSERT OR IGNORE INTO expiration_date_signatures(expiration_date, epoch_id, serialised_signatures, serialization_revision)\n VALUES (?, ?, ?, ?);\n UPDATE expiration_date_signatures\n SET\n serialised_signatures = ?,\n serialization_revision = ?\n WHERE expiration_date = ?\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 7 + }, + "nullable": [] + }, + "hash": "16d10f0ac0ed9ce4239937f46df3797a6a9ee7db2aab9f1b5e55f7c13c53bcc1" +} diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-1b43cc81ce6b6007ccc59172fc64c270fd5dd7f00eaab0fe82e6fe927e604294.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-1b43cc81ce6b6007ccc59172fc64c270fd5dd7f00eaab0fe82e6fe927e604294.json new file mode 100644 index 0000000000..6d2842a9df --- /dev/null +++ b/nym-node-status-api/nym-node-status-api/.sqlx/query-1b43cc81ce6b6007ccc59172fc64c270fd5dd7f00eaab0fe82e6fe927e604294.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n INSERT INTO remote_gateway_details(gateway_id_bs58, derived_aes256_gcm_siv_key, gateway_listener, fallback_listener, expiration_timestamp)\n VALUES (?, ?, ?, ?, ?)\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 5 + }, + "nullable": [] + }, + "hash": "1b43cc81ce6b6007ccc59172fc64c270fd5dd7f00eaab0fe82e6fe927e604294" +} diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-1da6904e72b5abb9abf75affb13af7974d7795b4cbdba234273345fe161df233.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-1da6904e72b5abb9abf75affb13af7974d7795b4cbdba234273345fe161df233.json new file mode 100644 index 0000000000..9d4281137b --- /dev/null +++ b/nym-node-status-api/nym-node-status-api/.sqlx/query-1da6904e72b5abb9abf75affb13af7974d7795b4cbdba234273345fe161df233.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM remote_gateway_details WHERE gateway_id_bs58 = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "1da6904e72b5abb9abf75affb13af7974d7795b4cbdba234273345fe161df233" +} diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-1f4fde2cafa3b5fae95ab5fb653b5905d4dd6b7ac0e20f1cf100a51a1a40b35d.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-1f4fde2cafa3b5fae95ab5fb653b5905d4dd6b7ac0e20f1cf100a51a1a40b35d.json new file mode 100644 index 0000000000..d891d0d97a --- /dev/null +++ b/nym-node-status-api/nym-node-status-api/.sqlx/query-1f4fde2cafa3b5fae95ab5fb653b5905d4dd6b7ac0e20f1cf100a51a1a40b35d.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM reply_key;", + "describe": { + "columns": [], + "parameters": { + "Right": 0 + }, + "nullable": [] + }, + "hash": "1f4fde2cafa3b5fae95ab5fb653b5905d4dd6b7ac0e20f1cf100a51a1a40b35d" +} diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-1fa61c23a1504de280d67c7943282b4dc00ba6a580de1faf83b137365f44b36d.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-1fa61c23a1504de280d67c7943282b4dc00ba6a580de1faf83b137365f44b36d.json new file mode 100644 index 0000000000..096aa7a892 --- /dev/null +++ b/nym-node-status-api/nym-node-status-api/.sqlx/query-1fa61c23a1504de280d67c7943282b4dc00ba6a580de1faf83b137365f44b36d.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE status SET previous_flush = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "1fa61c23a1504de280d67c7943282b4dc00ba6a580de1faf83b137365f44b36d" +} diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-1fc72f8ba24039548047e1766c9105614dea7fd301f0ec38bfe85bfe546dad40.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-1fc72f8ba24039548047e1766c9105614dea7fd301f0ec38bfe85bfe546dad40.json new file mode 100644 index 0000000000..cdc057df57 --- /dev/null +++ b/nym-node-status-api/nym-node-status-api/.sqlx/query-1fc72f8ba24039548047e1766c9105614dea7fd301f0ec38bfe85bfe546dad40.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n INSERT INTO ecash_deposit_usage (deposit_id, ticketbooks_requested_on, client_pubkey, request_uuid)\n VALUES (?, ?, ?, ?)\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 4 + }, + "nullable": [] + }, + "hash": "1fc72f8ba24039548047e1766c9105614dea7fd301f0ec38bfe85bfe546dad40" +} diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-20797a3e0bb951ed77216a906d7997139172411b10c7444dea74a1438de4f343.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-20797a3e0bb951ed77216a906d7997139172411b10c7444dea74a1438de4f343.json new file mode 100644 index 0000000000..eb6e8b8904 --- /dev/null +++ b/nym-node-status-api/nym-node-status-api/.sqlx/query-20797a3e0bb951ed77216a906d7997139172411b10c7444dea74a1438de4f343.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n UPDATE remote_gateway_details SET gateway_listener = ?, fallback_listener = ?, expiration_timestamp = ? WHERE gateway_id_bs58 = ?\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 4 + }, + "nullable": [] + }, + "hash": "20797a3e0bb951ed77216a906d7997139172411b10c7444dea74a1438de4f343" +} diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-22bc9c7bcf96ec817c83c26104408cb6c3b99c6b808ba50c67b066fc3f36073b.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-22bc9c7bcf96ec817c83c26104408cb6c3b99c6b808ba50c67b066fc3f36073b.json new file mode 100644 index 0000000000..3fbb4e474d --- /dev/null +++ b/nym-node-status-api/nym-node-status-api/.sqlx/query-22bc9c7bcf96ec817c83c26104408cb6c3b99c6b808ba50c67b066fc3f36073b.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n INSERT INTO reply_surb(reply_surb_sender_id, reply_surb, encoded_key_rotation) VALUES (?, ?, ?);\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "22bc9c7bcf96ec817c83c26104408cb6c3b99c6b808ba50c67b066fc3f36073b" +} diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-284b3ceae42f9320c30323dde47765854899103fd3c0fa670eb6809492270e02.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-284b3ceae42f9320c30323dde47765854899103fd3c0fa670eb6809492270e02.json new file mode 100644 index 0000000000..6ee25bbc1a --- /dev/null +++ b/nym-node-status-api/nym-node-status-api/.sqlx/query-284b3ceae42f9320c30323dde47765854899103fd3c0fa670eb6809492270e02.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n INSERT INTO ecash_ticketbook\n (serialization_revision, ticketbook_data, expiration_date, ticketbook_type, epoch_id, total_tickets, used_tickets)\n VALUES (?, ?, ?, ?, ?, ?, ?)\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 7 + }, + "nullable": [] + }, + "hash": "284b3ceae42f9320c30323dde47765854899103fd3c0fa670eb6809492270e02" +} diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-28681fcd8e2d4326f628681b8f2a317aabce063a650be362d3a8ed83cc7c3549.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-28681fcd8e2d4326f628681b8f2a317aabce063a650be362d3a8ed83cc7c3549.json new file mode 100644 index 0000000000..32f3f9958c --- /dev/null +++ b/nym-node-status-api/nym-node-status-api/.sqlx/query-28681fcd8e2d4326f628681b8f2a317aabce063a650be362d3a8ed83cc7c3549.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n DELETE FROM blinded_shares WHERE created < ?\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "28681fcd8e2d4326f628681b8f2a317aabce063a650be362d3a8ed83cc7c3549" +} diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-2930ca6e3875c74acb7abb9ad889f166ad7f57681f76a1d0c7723d007c1f2c1e.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-2930ca6e3875c74acb7abb9ad889f166ad7f57681f76a1d0c7723d007c1f2c1e.json new file mode 100644 index 0000000000..9ef7d96cca --- /dev/null +++ b/nym-node-status-api/nym-node-status-api/.sqlx/query-2930ca6e3875c74acb7abb9ad889f166ad7f57681f76a1d0c7723d007c1f2c1e.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n INSERT INTO global_expiration_date_signatures(expiration_date, epoch_id, serialised_signatures, serialization_revision)\n VALUES (?, ?, ?, ?)\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 4 + }, + "nullable": [] + }, + "hash": "2930ca6e3875c74acb7abb9ad889f166ad7f57681f76a1d0c7723d007c1f2c1e" +} diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-2c113b37864f9fec7e64c0f8fdd38edcdf149acfd38c56a4db3bbf97bdb13210.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-2c113b37864f9fec7e64c0f8fdd38edcdf149acfd38c56a4db3bbf97bdb13210.json new file mode 100644 index 0000000000..4fd84fdf93 --- /dev/null +++ b/nym-node-status-api/nym-node-status-api/.sqlx/query-2c113b37864f9fec7e64c0f8fdd38edcdf149acfd38c56a4db3bbf97bdb13210.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n INSERT INTO custom_gateway_details(gateway_id_bs58, data)\n VALUES (?, ?)\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "2c113b37864f9fec7e64c0f8fdd38edcdf149acfd38c56a4db3bbf97bdb13210" +} diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-2f3c12cb0c48084b569e12ecb0315212a6f526f78258e173c96ec177988696ef.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-2f3c12cb0c48084b569e12ecb0315212a6f526f78258e173c96ec177988696ef.json new file mode 100644 index 0000000000..405049043d --- /dev/null +++ b/nym-node-status-api/nym-node-status-api/.sqlx/query-2f3c12cb0c48084b569e12ecb0315212a6f526f78258e173c96ec177988696ef.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n DELETE FROM emergency_credential\n WHERE id = ?\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "2f3c12cb0c48084b569e12ecb0315212a6f526f78258e173c96ec177988696ef" +} diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-37f82c9ec26b53d01601a2d6df82038a77ec37cca9f9aef18008dcd03030c2c4.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-37f82c9ec26b53d01601a2d6df82038a77ec37cca9f9aef18008dcd03030c2c4.json new file mode 100644 index 0000000000..ea79b11bbd --- /dev/null +++ b/nym-node-status-api/nym-node-status-api/.sqlx/query-37f82c9ec26b53d01601a2d6df82038a77ec37cca9f9aef18008dcd03030c2c4.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM ecash_ticketbook WHERE expiration_date <= ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "37f82c9ec26b53d01601a2d6df82038a77ec37cca9f9aef18008dcd03030c2c4" +} diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-396f40c33f0f62796eb7449d640bd97845350f4fb9f806c60b93c7cebd5e410d.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-396f40c33f0f62796eb7449d640bd97845350f4fb9f806c60b93c7cebd5e410d.json new file mode 100644 index 0000000000..944c86e33e --- /dev/null +++ b/nym-node-status-api/nym-node-status-api/.sqlx/query-396f40c33f0f62796eb7449d640bd97845350f4fb9f806c60b93c7cebd5e410d.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT error_message\n FROM blinded_shares\n WHERE id = ?;\n ", + "describe": { + "columns": [ + { + "name": "error_message", + "ordinal": 0, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true + ] + }, + "hash": "396f40c33f0f62796eb7449d640bd97845350f4fb9f806c60b93c7cebd5e410d" +} diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-3cc446220668fb3e02f0578104291d2a2af57656b405212af414d765b2263347.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-3cc446220668fb3e02f0578104291d2a2af57656b405212af414d765b2263347.json new file mode 100644 index 0000000000..9d8cff1326 --- /dev/null +++ b/nym-node-status-api/nym-node-status-api/.sqlx/query-3cc446220668fb3e02f0578104291d2a2af57656b405212af414d765b2263347.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT serialised_signatures, serialization_revision as \"serialization_revision: u8\"\n FROM global_expiration_date_signatures\n WHERE expiration_date = ? AND epoch_id = ?\n ", + "describe": { + "columns": [ + { + "name": "serialised_signatures", + "ordinal": 0, + "type_info": "Blob" + }, + { + "name": "serialization_revision: u8", + "ordinal": 1, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + false, + false + ] + }, + "hash": "3cc446220668fb3e02f0578104291d2a2af57656b405212af414d765b2263347" +} diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-42dfc51aea793fc60484cff6c481bbace1bcf110e048e594e8bd03fa45290732.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-42dfc51aea793fc60484cff6c481bbace1bcf110e048e594e8bd03fa45290732.json new file mode 100644 index 0000000000..d1ea957e7f --- /dev/null +++ b/nym-node-status-api/nym-node-status-api/.sqlx/query-42dfc51aea793fc60484cff6c481bbace1bcf110e048e594e8bd03fa45290732.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE status SET client_in_use = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "42dfc51aea793fc60484cff6c481bbace1bcf110e048e594e8bd03fa45290732" +} diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-4a4f3b32b313f7fbc6eb579659e7cec1442967e53764b83ba0a66cd9a72494f9.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-4a4f3b32b313f7fbc6eb579659e7cec1442967e53764b83ba0a66cd9a72494f9.json new file mode 100644 index 0000000000..e71f300174 --- /dev/null +++ b/nym-node-status-api/nym-node-status-api/.sqlx/query-4a4f3b32b313f7fbc6eb579659e7cec1442967e53764b83ba0a66cd9a72494f9.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n DELETE FROM emergency_credential\n WHERE type = ?\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "4a4f3b32b313f7fbc6eb579659e7cec1442967e53764b83ba0a66cd9a72494f9" +} diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-4f78619aca933484cd67cb89a376b2a5bec1c191993ff58f0c71c03e3ef6d92d.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-4f78619aca933484cd67cb89a376b2a5bec1c191993ff58f0c71c03e3ef6d92d.json new file mode 100644 index 0000000000..eb2dc28e12 --- /dev/null +++ b/nym-node-status-api/nym-node-status-api/.sqlx/query-4f78619aca933484cd67cb89a376b2a5bec1c191993ff58f0c71c03e3ef6d92d.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM custom_gateway_details WHERE gateway_id_bs58 = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "4f78619aca933484cd67cb89a376b2a5bec1c191993ff58f0c71c03e3ef6d92d" +} diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-52b378e282d93db941eff53b5b311e5732ece0bf84ea98f2328b20add8f2b5ef.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-52b378e282d93db941eff53b5b311e5732ece0bf84ea98f2328b20add8f2b5ef.json new file mode 100644 index 0000000000..77a99816d4 --- /dev/null +++ b/nym-node-status-api/nym-node-status-api/.sqlx/query-52b378e282d93db941eff53b5b311e5732ece0bf84ea98f2328b20add8f2b5ef.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n DELETE FROM partial_blinded_wallet_failure WHERE created < ?\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "52b378e282d93db941eff53b5b311e5732ece0bf84ea98f2328b20add8f2b5ef" +} diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-54f552a9dbe95236f946ac2b6615e03504afa58e345ae16a128629d8e76f0a11.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-54f552a9dbe95236f946ac2b6615e03504afa58e345ae16a128629d8e76f0a11.json new file mode 100644 index 0000000000..b1d46fd96b --- /dev/null +++ b/nym-node-status-api/nym-node-status-api/.sqlx/query-54f552a9dbe95236f946ac2b6615e03504afa58e345ae16a128629d8e76f0a11.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "SELECT * FROM custom_gateway_details WHERE gateway_id_bs58 = ?", + "describe": { + "columns": [ + { + "name": "gateway_id_bs58", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "data", + "ordinal": 1, + "type_info": "Blob" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + true + ] + }, + "hash": "54f552a9dbe95236f946ac2b6615e03504afa58e345ae16a128629d8e76f0a11" +} diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-5661cf1ad8bd5ca062e855e1971a8787133ee41814bd3efdd501f9ee0c050f2b.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-5661cf1ad8bd5ca062e855e1971a8787133ee41814bd3efdd501f9ee0c050f2b.json new file mode 100644 index 0000000000..ad91647c9a --- /dev/null +++ b/nym-node-status-api/nym-node-status-api/.sqlx/query-5661cf1ad8bd5ca062e855e1971a8787133ee41814bd3efdd501f9ee0c050f2b.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT gateway_id_bs58 FROM registered_gateway", + "describe": { + "columns": [ + { + "name": "gateway_id_bs58", + "ordinal": 0, + "type_info": "Text" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false + ] + }, + "hash": "5661cf1ad8bd5ca062e855e1971a8787133ee41814bd3efdd501f9ee0c050f2b" +} diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-5c5d4bfabf18bc6fa56e76a9b98e38b7f6ceb8e9191a7b9201922efcf6b07966.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-5c5d4bfabf18bc6fa56e76a9b98e38b7f6ceb8e9191a7b9201922efcf6b07966.json new file mode 100644 index 0000000000..ea667b0bc6 --- /dev/null +++ b/nym-node-status-api/nym-node-status-api/.sqlx/query-5c5d4bfabf18bc6fa56e76a9b98e38b7f6ceb8e9191a7b9201922efcf6b07966.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM pending_issuance WHERE deposit_id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "5c5d4bfabf18bc6fa56e76a9b98e38b7f6ceb8e9191a7b9201922efcf6b07966" +} diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-5d3b8ad051ab6f46c702308c2fc751a5ca340ac9c6dd86da1a5e9a3e65ea589f.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-5d3b8ad051ab6f46c702308c2fc751a5ca340ac9c6dd86da1a5e9a3e65ea589f.json new file mode 100644 index 0000000000..eb6a62ba22 --- /dev/null +++ b/nym-node-status-api/nym-node-status-api/.sqlx/query-5d3b8ad051ab6f46c702308c2fc751a5ca340ac9c6dd86da1a5e9a3e65ea589f.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT serialised_signatures, serialization_revision as \"serialization_revision: u8\"\n FROM expiration_date_signatures\n WHERE expiration_date = ? AND epoch_id = ?\n ", + "describe": { + "columns": [ + { + "name": "serialised_signatures", + "ordinal": 0, + "type_info": "Blob" + }, + { + "name": "serialization_revision: u8", + "ordinal": 1, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + false, + false + ] + }, + "hash": "5d3b8ad051ab6f46c702308c2fc751a5ca340ac9c6dd86da1a5e9a3e65ea589f" +} diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-5e51396c534409a4b55c08170e00fd083e87cc9a18d798b2cf8d6774224aebed.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-5e51396c534409a4b55c08170e00fd083e87cc9a18d798b2cf8d6774224aebed.json new file mode 100644 index 0000000000..35fd74e6ee --- /dev/null +++ b/nym-node-status-api/nym-node-status-api/.sqlx/query-5e51396c534409a4b55c08170e00fd083e87cc9a18d798b2cf8d6774224aebed.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT name FROM sqlite_master WHERE type='table' AND name='status'", + "describe": { + "columns": [ + { + "name": "name", + "ordinal": 0, + "type_info": "Text" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + true + ] + }, + "hash": "5e51396c534409a4b55c08170e00fd083e87cc9a18d798b2cf8d6774224aebed" +} diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-62f471858c831ac762a0ecf60876067dbf82d0dc376ad3f0df835b77dfcc3c37.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-62f471858c831ac762a0ecf60876067dbf82d0dc376ad3f0df835b77dfcc3c37.json new file mode 100644 index 0000000000..632b688eeb --- /dev/null +++ b/nym-node-status-api/nym-node-status-api/.sqlx/query-62f471858c831ac762a0ecf60876067dbf82d0dc376ad3f0df835b77dfcc3c37.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT flush_in_progress FROM status;", + "describe": { + "columns": [ + { + "name": "flush_in_progress", + "ordinal": 0, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false + ] + }, + "hash": "62f471858c831ac762a0ecf60876067dbf82d0dc376ad3f0df835b77dfcc3c37" +} diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-648fff18c4016fb356c164f813e0d5ebd37d62612b65b8ac4c9eb5d7b67a8884.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-648fff18c4016fb356c164f813e0d5ebd37d62612b65b8ac4c9eb5d7b67a8884.json new file mode 100644 index 0000000000..974bbd1cd1 --- /dev/null +++ b/nym-node-status-api/nym-node-status-api/.sqlx/query-648fff18c4016fb356c164f813e0d5ebd37d62612b65b8ac4c9eb5d7b67a8884.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT min_reply_surb_threshold as \"min_reply_surb_threshold: u32\", max_reply_surb_threshold as \"max_reply_surb_threshold: u32\" FROM reply_surb_storage_metadata;\n ", + "describe": { + "columns": [ + { + "name": "min_reply_surb_threshold: u32", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "max_reply_surb_threshold: u32", + "ordinal": 1, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false + ] + }, + "hash": "648fff18c4016fb356c164f813e0d5ebd37d62612b65b8ac4c9eb5d7b67a8884" +} diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-67db5e91bcdc831e0d1659eda358d18a0c6792d7a4244a65243d4b22d578a1e5.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-67db5e91bcdc831e0d1659eda358d18a0c6792d7a4244a65243d4b22d578a1e5.json new file mode 100644 index 0000000000..01a4ca077c --- /dev/null +++ b/nym-node-status-api/nym-node-status-api/.sqlx/query-67db5e91bcdc831e0d1659eda358d18a0c6792d7a4244a65243d4b22d578a1e5.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT client_in_use FROM status;", + "describe": { + "columns": [ + { + "name": "client_in_use", + "ordinal": 0, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false + ] + }, + "hash": "67db5e91bcdc831e0d1659eda358d18a0c6792d7a4244a65243d4b22d578a1e5" +} diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-70d8f240ad6edda6b8c7f2e800e7fca89d80869484f2f3c66cabb898f0298c62.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-70d8f240ad6edda6b8c7f2e800e7fca89d80869484f2f3c66cabb898f0298c62.json new file mode 100644 index 0000000000..2f22ed93fa --- /dev/null +++ b/nym-node-status-api/nym-node-status-api/.sqlx/query-70d8f240ad6edda6b8c7f2e800e7fca89d80869484f2f3c66cabb898f0298c62.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO master_verification_key(epoch_id, serialised_key, serialization_revision) VALUES (?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "70d8f240ad6edda6b8c7f2e800e7fca89d80869484f2f3c66cabb898f0298c62" +} diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-727598e516090da6d26e36d09062b60ccb76d6468f359891428c0bfb96ddd7ef.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-727598e516090da6d26e36d09062b60ccb76d6468f359891428c0bfb96ddd7ef.json new file mode 100644 index 0000000000..389d07c6ea --- /dev/null +++ b/nym-node-status-api/nym-node-status-api/.sqlx/query-727598e516090da6d26e36d09062b60ccb76d6468f359891428c0bfb96ddd7ef.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n INSERT INTO registered_gateway(gateway_id_bs58, registration_timestamp, gateway_type)\n VALUES (?, ?, ?)\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "727598e516090da6d26e36d09062b60ccb76d6468f359891428c0bfb96ddd7ef" +} diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-74d77a3e15f49bde7b8b17dd638de04e5ece789fb0b0cd27ad09858fbf5c5e27.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-74d77a3e15f49bde7b8b17dd638de04e5ece789fb0b0cd27ad09858fbf5c5e27.json new file mode 100644 index 0000000000..caf7479a75 --- /dev/null +++ b/nym-node-status-api/nym-node-status-api/.sqlx/query-74d77a3e15f49bde7b8b17dd638de04e5ece789fb0b0cd27ad09858fbf5c5e27.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE status SET flush_in_progress = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "74d77a3e15f49bde7b8b17dd638de04e5ece789fb0b0cd27ad09858fbf5c5e27" +} diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-80476cf2906eb0ecf7f66c16bc5682169b87f488b6927fa67fade6bf5abf7582.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-80476cf2906eb0ecf7f66c16bc5682169b87f488b6927fa67fade6bf5abf7582.json new file mode 100644 index 0000000000..02b9ef7426 --- /dev/null +++ b/nym-node-status-api/nym-node-status-api/.sqlx/query-80476cf2906eb0ecf7f66c16bc5682169b87f488b6927fa67fade6bf5abf7582.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE active_gateway SET active_gateway_id_bs58 = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "80476cf2906eb0ecf7f66c16bc5682169b87f488b6927fa67fade6bf5abf7582" +} diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-805ad4f26e0234d7f482a263e186156311713d2e9f69d39c868cd16296b56326.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-805ad4f26e0234d7f482a263e186156311713d2e9f69d39c868cd16296b56326.json new file mode 100644 index 0000000000..e1eb54bb9c --- /dev/null +++ b/nym-node-status-api/nym-node-status-api/.sqlx/query-805ad4f26e0234d7f482a263e186156311713d2e9f69d39c868cd16296b56326.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n INSERT INTO emergency_credential\n (type, content, expiration)\n VALUES (?, ?, ?)\n ON CONFLICT(type, content) DO NOTHING;\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "805ad4f26e0234d7f482a263e186156311713d2e9f69d39c868cd16296b56326" +} diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-81a12a8a419c88b1c28a5533fde4d63462e9ea0049e2edafea1dc3f8476b33e4.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-81a12a8a419c88b1c28a5533fde4d63462e9ea0049e2edafea1dc3f8476b33e4.json new file mode 100644 index 0000000000..ae81f52d0e --- /dev/null +++ b/nym-node-status-api/nym-node-status-api/.sqlx/query-81a12a8a419c88b1c28a5533fde4d63462e9ea0049e2edafea1dc3f8476b33e4.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n INSERT INTO pending_issuance\n (deposit_id, serialization_revision, pending_ticketbook_data, expiration_date)\n VALUES (?, ?, ?, ?)\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 4 + }, + "nullable": [] + }, + "hash": "81a12a8a419c88b1c28a5533fde4d63462e9ea0049e2edafea1dc3f8476b33e4" +} diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-84cad8b1078a4000830835e6349de3eb76fed954b7336530401db72cd008aff3.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-84cad8b1078a4000830835e6349de3eb76fed954b7336530401db72cd008aff3.json new file mode 100644 index 0000000000..051c18bba2 --- /dev/null +++ b/nym-node-status-api/nym-node-status-api/.sqlx/query-84cad8b1078a4000830835e6349de3eb76fed954b7336530401db72cd008aff3.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE ecash_ticketbook SET used_tickets = used_tickets + ? WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "84cad8b1078a4000830835e6349de3eb76fed954b7336530401db72cd008aff3" +} diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-85d542a8cb2e1fbba6142729056b57e6ea8685e9473a3a8bd635552810493a58.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-85d542a8cb2e1fbba6142729056b57e6ea8685e9473a3a8bd635552810493a58.json new file mode 100644 index 0000000000..12145c00d4 --- /dev/null +++ b/nym-node-status-api/nym-node-status-api/.sqlx/query-85d542a8cb2e1fbba6142729056b57e6ea8685e9473a3a8bd635552810493a58.json @@ -0,0 +1,32 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT reply_surb_sender_id, reply_surb, encoded_key_rotation as \"encoded_key_rotation: u8\" FROM reply_surb\n WHERE reply_surb_sender_id = ?\n ", + "describe": { + "columns": [ + { + "name": "reply_surb_sender_id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "reply_surb", + "ordinal": 1, + "type_info": "Blob" + }, + { + "name": "encoded_key_rotation: u8", + "ordinal": 2, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "85d542a8cb2e1fbba6142729056b57e6ea8685e9473a3a8bd635552810493a58" +} diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-97d97ebb6bc8f4114fdea9ebc9f57f91a11f5057273cb70bd0e629712d17dd41.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-97d97ebb6bc8f4114fdea9ebc9f57f91a11f5057273cb70bd0e629712d17dd41.json new file mode 100644 index 0000000000..84e8ba9f16 --- /dev/null +++ b/nym-node-status-api/nym-node-status-api/.sqlx/query-97d97ebb6bc8f4114fdea9ebc9f57f91a11f5057273cb70bd0e629712d17dd41.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n INSERT INTO partial_blinded_wallet_failure(corresponding_deposit, epoch_id, expiration_date, node_id, created, failure_message)\n VALUES (?, ?, ?, ?, ?, ?)\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 6 + }, + "nullable": [] + }, + "hash": "97d97ebb6bc8f4114fdea9ebc9f57f91a11f5057273cb70bd0e629712d17dd41" +} diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-a5a71e480118639397f7464abb09db66d1dc1307c0873627b39cb22a245b5a8c.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-a5a71e480118639397f7464abb09db66d1dc1307c0873627b39cb22a245b5a8c.json new file mode 100644 index 0000000000..6cd1eab066 --- /dev/null +++ b/nym-node-status-api/nym-node-status-api/.sqlx/query-a5a71e480118639397f7464abb09db66d1dc1307c0873627b39cb22a245b5a8c.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO status(flush_in_progress, previous_flush, client_in_use) VALUES (0, 0, 1)", + "describe": { + "columns": [], + "parameters": { + "Right": 0 + }, + "nullable": [] + }, + "hash": "a5a71e480118639397f7464abb09db66d1dc1307c0873627b39cb22a245b5a8c" +} diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-a5b18e66d77ff802e274623605e15dcfcffb502ba8398caefd56c481f44eb84e.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-a5b18e66d77ff802e274623605e15dcfcffb502ba8398caefd56c481f44eb84e.json new file mode 100644 index 0000000000..5fd4e4f2e6 --- /dev/null +++ b/nym-node-status-api/nym-node-status-api/.sqlx/query-a5b18e66d77ff802e274623605e15dcfcffb502ba8398caefd56c481f44eb84e.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n INSERT OR IGNORE INTO master_verification_key(epoch_id, serialised_key, serialization_revision) VALUES (?, ?, ?);\n UPDATE master_verification_key\n SET\n serialised_key = ?,\n serialization_revision = ?\n WHERE epoch_id = ?\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 6 + }, + "nullable": [] + }, + "hash": "a5b18e66d77ff802e274623605e15dcfcffb502ba8398caefd56c481f44eb84e" +} diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-a8b7ce0fe4755c28b96d1e503e313ab15fed747fb0cee1c9f949fb58461b3f79.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-a8b7ce0fe4755c28b96d1e503e313ab15fed747fb0cee1c9f949fb58461b3f79.json new file mode 100644 index 0000000000..6cb8138cc5 --- /dev/null +++ b/nym-node-status-api/nym-node-status-api/.sqlx/query-a8b7ce0fe4755c28b96d1e503e313ab15fed747fb0cee1c9f949fb58461b3f79.json @@ -0,0 +1,32 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT epoch_id as \"epoch_id: u32\", serialised_signatures, serialization_revision as \"serialization_revision: u8\"\n FROM global_coin_index_signatures WHERE epoch_id = ?\n ", + "describe": { + "columns": [ + { + "name": "epoch_id: u32", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "serialised_signatures", + "ordinal": 1, + "type_info": "Blob" + }, + { + "name": "serialization_revision: u8", + "ordinal": 2, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "a8b7ce0fe4755c28b96d1e503e313ab15fed747fb0cee1c9f949fb58461b3f79" +} diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-b6ce77fcffda2ee24ba181213e04adec312abffa604fa805cc362a2c206107b4.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-b6ce77fcffda2ee24ba181213e04adec312abffa604fa805cc362a2c206107b4.json new file mode 100644 index 0000000000..7b8a1a92fc --- /dev/null +++ b/nym-node-status-api/nym-node-status-api/.sqlx/query-b6ce77fcffda2ee24ba181213e04adec312abffa604fa805cc362a2c206107b4.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n INSERT INTO reply_surb_storage_metadata(min_reply_surb_threshold, max_reply_surb_threshold)\n VALUES (?, ?);\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "b6ce77fcffda2ee24ba181213e04adec312abffa604fa805cc362a2c206107b4" +} diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-b8257a0832d0124f0a8aaaf81dc6a811c593aea8febf1f891117e5e84213f147.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-b8257a0832d0124f0a8aaaf81dc6a811c593aea8febf1f891117e5e84213f147.json new file mode 100644 index 0000000000..03efdc4734 --- /dev/null +++ b/nym-node-status-api/nym-node-status-api/.sqlx/query-b8257a0832d0124f0a8aaaf81dc6a811c593aea8febf1f891117e5e84213f147.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n DELETE FROM partial_blinded_wallet WHERE created < ?\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "b8257a0832d0124f0a8aaaf81dc6a811c593aea8febf1f891117e5e84213f147" +} diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-ba96344db31b0f2155e2af53eaaeafc9b5f64061b6c9a829e2912945b6cffc82.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-ba96344db31b0f2155e2af53eaaeafc9b5f64061b6c9a829e2912945b6cffc82.json new file mode 100644 index 0000000000..7c01c8d1fa --- /dev/null +++ b/nym-node-status-api/nym-node-status-api/.sqlx/query-ba96344db31b0f2155e2af53eaaeafc9b5f64061b6c9a829e2912945b6cffc82.json @@ -0,0 +1,32 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT epoch_id as \"epoch_id: u32\", serialised_signatures, serialization_revision as \"serialization_revision: u8\"\n FROM coin_indices_signatures WHERE epoch_id = ?\n ", + "describe": { + "columns": [ + { + "name": "epoch_id: u32", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "serialised_signatures", + "ordinal": 1, + "type_info": "Blob" + }, + { + "name": "serialization_revision: u8", + "ordinal": 2, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "ba96344db31b0f2155e2af53eaaeafc9b5f64061b6c9a829e2912945b6cffc82" +} diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-bc823c54143e2dc590b91347cd089dde284b38a3a4960afed758206d03ca1cf4.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-bc823c54143e2dc590b91347cd089dde284b38a3a4960afed758206d03ca1cf4.json new file mode 100644 index 0000000000..ec7111bc5a --- /dev/null +++ b/nym-node-status-api/nym-node-status-api/.sqlx/query-bc823c54143e2dc590b91347cd089dde284b38a3a4960afed758206d03ca1cf4.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n UPDATE ecash_ticketbook\n SET used_tickets = used_tickets - ?\n WHERE id = ?\n AND used_tickets = ?\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "bc823c54143e2dc590b91347cd089dde284b38a3a4960afed758206d03ca1cf4" +} diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-bd1973696121b6128bd75ae80fab253c071e04eb853d4b0f3b21782ea57c2f68.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-bd1973696121b6128bd75ae80fab253c071e04eb853d4b0f3b21782ea57c2f68.json new file mode 100644 index 0000000000..a2077f76ae --- /dev/null +++ b/nym-node-status-api/nym-node-status-api/.sqlx/query-bd1973696121b6128bd75ae80fab253c071e04eb853d4b0f3b21782ea57c2f68.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n INSERT OR IGNORE INTO coin_indices_signatures(epoch_id, serialised_signatures, serialization_revision) VALUES (?, ?, ?);\n UPDATE coin_indices_signatures\n SET\n serialised_signatures = ?,\n serialization_revision = ?\n WHERE epoch_id = ?\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 6 + }, + "nullable": [] + }, + "hash": "bd1973696121b6128bd75ae80fab253c071e04eb853d4b0f3b21782ea57c2f68" +} diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-bdaf8e8dd711ff420807659456f5ebe0222a2a0ba96f28e3a3aa58fdc4b689db.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-bdaf8e8dd711ff420807659456f5ebe0222a2a0ba96f28e3a3aa58fdc4b689db.json new file mode 100644 index 0000000000..383ca24d4e --- /dev/null +++ b/nym-node-status-api/nym-node-status-api/.sqlx/query-bdaf8e8dd711ff420807659456f5ebe0222a2a0ba96f28e3a3aa58fdc4b689db.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n INSERT INTO reply_surb_sender(tag, last_sent) VALUES (?, ?);\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "bdaf8e8dd711ff420807659456f5ebe0222a2a0ba96f28e3a3aa58fdc4b689db" +} diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-bf249752f08c283bf5942b6ff48125c24750b523cfcad1e5e9069dbf7050e2a1.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-bf249752f08c283bf5942b6ff48125c24750b523cfcad1e5e9069dbf7050e2a1.json new file mode 100644 index 0000000000..a1ae46b595 --- /dev/null +++ b/nym-node-status-api/nym-node-status-api/.sqlx/query-bf249752f08c283bf5942b6ff48125c24750b523cfcad1e5e9069dbf7050e2a1.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT active_gateway_id_bs58 FROM active_gateway", + "describe": { + "columns": [ + { + "name": "active_gateway_id_bs58", + "ordinal": 0, + "type_info": "Text" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + true + ] + }, + "hash": "bf249752f08c283bf5942b6ff48125c24750b523cfcad1e5e9069dbf7050e2a1" +} diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-c2b841762bdb963fff337ef5c8ec9f560017b4da6b0303ea0397d9568229e167.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-c2b841762bdb963fff337ef5c8ec9f560017b4da6b0303ea0397d9568229e167.json new file mode 100644 index 0000000000..6b70481c75 --- /dev/null +++ b/nym-node-status-api/nym-node-status-api/.sqlx/query-c2b841762bdb963fff337ef5c8ec9f560017b4da6b0303ea0397d9568229e167.json @@ -0,0 +1,38 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT\n t1.node_id as \"node_id!\",\n t1.blinded_signature as \"blinded_signature!\",\n t1.epoch_id as \"epoch_id!\",\n t1.expiration_date as \"expiration_date!: Date\"\n FROM partial_blinded_wallet as t1\n JOIN ecash_deposit_usage as t2\n on t1.corresponding_deposit = t2.deposit_id\n JOIN blinded_shares as t3\n ON t2.request_uuid = t3.request_uuid\n WHERE t3.device_id = ? AND t3.credential_id = ?;\n ", + "describe": { + "columns": [ + { + "name": "node_id!", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "blinded_signature!", + "ordinal": 1, + "type_info": "Blob" + }, + { + "name": "epoch_id!", + "ordinal": 2, + "type_info": "Integer" + }, + { + "name": "expiration_date!: Date", + "ordinal": 3, + "type_info": "Date" + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + true, + true, + true, + true + ] + }, + "hash": "c2b841762bdb963fff337ef5c8ec9f560017b4da6b0303ea0397d9568229e167" +} diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-d3510846941fa2525926b9bfbcdabd806877ce914b514d4f7cd6be318c4debe6.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-d3510846941fa2525926b9bfbcdabd806877ce914b514d4f7cd6be318c4debe6.json new file mode 100644 index 0000000000..9de4c7f230 --- /dev/null +++ b/nym-node-status-api/nym-node-status-api/.sqlx/query-d3510846941fa2525926b9bfbcdabd806877ce914b514d4f7cd6be318c4debe6.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO global_coin_index_signatures(epoch_id, serialised_signatures, serialization_revision) VALUES (?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "d3510846941fa2525926b9bfbcdabd806877ce914b514d4f7cd6be318c4debe6" +} diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-daa9ffcbe13178d773e538e96971da2809ec4bd66ad49d2e3b8d67b741835475.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-daa9ffcbe13178d773e538e96971da2809ec4bd66ad49d2e3b8d67b741835475.json new file mode 100644 index 0000000000..1faa4be465 --- /dev/null +++ b/nym-node-status-api/nym-node-status-api/.sqlx/query-daa9ffcbe13178d773e538e96971da2809ec4bd66ad49d2e3b8d67b741835475.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM reply_surb_sender;", + "describe": { + "columns": [], + "parameters": { + "Right": 0 + }, + "nullable": [] + }, + "hash": "daa9ffcbe13178d773e538e96971da2809ec4bd66ad49d2e3b8d67b741835475" +} diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-db176e98198fe594d88eb860d918f633a94d18a19b7f0f96935a62560def7d0f.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-db176e98198fe594d88eb860d918f633a94d18a19b7f0f96935a62560def7d0f.json new file mode 100644 index 0000000000..e5dd7c8413 --- /dev/null +++ b/nym-node-status-api/nym-node-status-api/.sqlx/query-db176e98198fe594d88eb860d918f633a94d18a19b7f0f96935a62560def7d0f.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n INSERT INTO partial_blinded_wallet(corresponding_deposit, epoch_id, expiration_date, node_id, created, blinded_signature)\n VALUES (?, ?, ?, ?, ?, ?)\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 6 + }, + "nullable": [] + }, + "hash": "db176e98198fe594d88eb860d918f633a94d18a19b7f0f96935a62560def7d0f" +} diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-e29e0c82d034626f9b369348b8e35677b9d9a1b29e4d89437134e92967fa98d1.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-e29e0c82d034626f9b369348b8e35677b9d9a1b29e4d89437134e92967fa98d1.json new file mode 100644 index 0000000000..ecab96d023 --- /dev/null +++ b/nym-node-status-api/nym-node-status-api/.sqlx/query-e29e0c82d034626f9b369348b8e35677b9d9a1b29e4d89437134e92967fa98d1.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM reply_surb;", + "describe": { + "columns": [], + "parameters": { + "Right": 0 + }, + "nullable": [] + }, + "hash": "e29e0c82d034626f9b369348b8e35677b9d9a1b29e4d89437134e92967fa98d1" +} diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-e584253e3856355899537eb8fc152f2bfed2d918b894ec0f588e38dd5e8ad726.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-e584253e3856355899537eb8fc152f2bfed2d918b894ec0f588e38dd5e8ad726.json new file mode 100644 index 0000000000..340f83a280 --- /dev/null +++ b/nym-node-status-api/nym-node-status-api/.sqlx/query-e584253e3856355899537eb8fc152f2bfed2d918b894ec0f588e38dd5e8ad726.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n UPDATE ecash_deposit_usage\n SET ticketbook_request_error = ?\n WHERE deposit_id = ?\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "e584253e3856355899537eb8fc152f2bfed2d918b894ec0f588e38dd5e8ad726" +} diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-e77ffab19b099b84470fe5611716a2e314787586a46cffd074abb67f2f4d109e.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-e77ffab19b099b84470fe5611716a2e314787586a46cffd074abb67f2f4d109e.json new file mode 100644 index 0000000000..449bc6869f --- /dev/null +++ b/nym-node-status-api/nym-node-status-api/.sqlx/query-e77ffab19b099b84470fe5611716a2e314787586a46cffd074abb67f2f4d109e.json @@ -0,0 +1,38 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT t1.node_id, t1.blinded_signature, t1.epoch_id, t1.expiration_date as \"expiration_date!: Date\"\n FROM partial_blinded_wallet as t1\n JOIN ecash_deposit_usage as t2\n on t1.corresponding_deposit = t2.deposit_id\n JOIN blinded_shares as t3\n ON t2.request_uuid = t3.request_uuid\n WHERE t3.id = ?;\n ", + "describe": { + "columns": [ + { + "name": "node_id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "blinded_signature", + "ordinal": 1, + "type_info": "Blob" + }, + { + "name": "epoch_id", + "ordinal": 2, + "type_info": "Integer" + }, + { + "name": "expiration_date!: Date", + "ordinal": 3, + "type_info": "Date" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "e77ffab19b099b84470fe5611716a2e314787586a46cffd074abb67f2f4d109e" +} diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-e8717ef6523ef9bcf37a0e81570ac000904bb7fe3934786d652adc80fde78add.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-e8717ef6523ef9bcf37a0e81570ac000904bb7fe3934786d652adc80fde78add.json new file mode 100644 index 0000000000..a7d1dee6ec --- /dev/null +++ b/nym-node-status-api/nym-node-status-api/.sqlx/query-e8717ef6523ef9bcf37a0e81570ac000904bb7fe3934786d652adc80fde78add.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT previous_flush AS \"previous_flush: OffsetDateTime\" FROM status", + "describe": { + "columns": [ + { + "name": "previous_flush: OffsetDateTime", + "ordinal": 0, + "type_info": "Null" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false + ] + }, + "hash": "e8717ef6523ef9bcf37a0e81570ac000904bb7fe3934786d652adc80fde78add" +} diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-eded014a5fffbd9c359f8557bcd3d41724666bf92357bf5faf42120a2aab131c.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-eded014a5fffbd9c359f8557bcd3d41724666bf92357bf5faf42120a2aab131c.json new file mode 100644 index 0000000000..7e0d68b0db --- /dev/null +++ b/nym-node-status-api/nym-node-status-api/.sqlx/query-eded014a5fffbd9c359f8557bcd3d41724666bf92357bf5faf42120a2aab131c.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n INSERT INTO reply_key(key_digest, reply_key, sent_at) VALUES (?, ?, ?);\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "eded014a5fffbd9c359f8557bcd3d41724666bf92357bf5faf42120a2aab131c" +} diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-ef60c2683211cc4ec2d3e46392518a1f62fa67dfe8f130deb876ebee11bf1602.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-ef60c2683211cc4ec2d3e46392518a1f62fa67dfe8f130deb876ebee11bf1602.json new file mode 100644 index 0000000000..c78ff30f50 --- /dev/null +++ b/nym-node-status-api/nym-node-status-api/.sqlx/query-ef60c2683211cc4ec2d3e46392518a1f62fa67dfe8f130deb876ebee11bf1602.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT error_message\n FROM blinded_shares\n WHERE device_id = ? AND credential_id = ?;\n ", + "describe": { + "columns": [ + { + "name": "error_message", + "ordinal": 0, + "type_info": "Text" + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + true + ] + }, + "hash": "ef60c2683211cc4ec2d3e46392518a1f62fa67dfe8f130deb876ebee11bf1602" +} diff --git a/nym-node-status-api/nym-node-status-api/.sqlx/query-f3ebe259e26c05ecdd33bd9085dbb91cd5046a8c9d4434cf085a4fa2ebf03e93.json b/nym-node-status-api/nym-node-status-api/.sqlx/query-f3ebe259e26c05ecdd33bd9085dbb91cd5046a8c9d4434cf085a4fa2ebf03e93.json new file mode 100644 index 0000000000..e2cfa29a82 --- /dev/null +++ b/nym-node-status-api/nym-node-status-api/.sqlx/query-f3ebe259e26c05ecdd33bd9085dbb91cd5046a8c9d4434cf085a4fa2ebf03e93.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM registered_gateway WHERE gateway_id_bs58 = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "f3ebe259e26c05ecdd33bd9085dbb91cd5046a8c9d4434cf085a4fa2ebf03e93" +} diff --git a/nym-node-status-api/nym-node-status-api/Makefile b/nym-node-status-api/nym-node-status-api/Makefile index 65e699d116..f0ae2fe951 100644 --- a/nym-node-status-api/nym-node-status-api/Makefile +++ b/nym-node-status-api/nym-node-status-api/Makefile @@ -59,6 +59,7 @@ test-db-prepare: ## Run sqlx prepare for compile-time query verification # --- Run Targets --- .PHONY: run run: ## Run the service (assumes database is already running) + @test -n "$$NYM_NODE_STATUS_API_MNEMONIC" || { echo "Error: NYM_NODE_STATUS_API_MNEMONIC is not set"; exit 1; } @echo "Starting nym-node-status-api with $(ENVIRONMENT) environment..." @set -a && source "$(MONOREPO_ROOT)/envs/$(ENVIRONMENT).env" && set +a && \ DATABASE_URL="$(TEST_DATABASE_URL)" \ diff --git a/nym-node-status-api/nym-node-status-api/docker-compose.yml b/nym-node-status-api/nym-node-status-api/docker-compose.yml index 12dbe51031..35090735c1 100644 --- a/nym-node-status-api/nym-node-status-api/docker-compose.yml +++ b/nym-node-status-api/nym-node-status-api/docker-compose.yml @@ -1,6 +1,6 @@ services: postgres-test: - image: postgres:16-alpine + image: postgres:17-alpine container_name: nym_node_status_api_postgres_test environment: POSTGRES_DB: nym_node_status_api_test @@ -18,4 +18,4 @@ services: # - postgres_test_data:/var/lib/postgresql/data # volumes: -# postgres_test_data: \ No newline at end of file +# postgres_test_data: From 3cdda8fdfd8881b397d09709457131f625af9d63 Mon Sep 17 00:00:00 2001 From: benedettadavico Date: Fri, 20 Mar 2026 15:33:16 +0100 Subject: [PATCH 08/23] bump NS version --- Cargo.lock | 4 ++-- nym-node-status-api/nym-node-status-agent/Cargo.toml | 2 +- nym-node-status-api/nym-node-status-api/Cargo.toml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index eff6a83b98..c44c3dd00f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7468,7 +7468,7 @@ dependencies = [ [[package]] name = "nym-node-status-agent" -version = "1.1.4" +version = "1.1.5" dependencies = [ "anyhow", "clap", @@ -7487,7 +7487,7 @@ dependencies = [ [[package]] name = "nym-node-status-api" -version = "4.3.0" +version = "4.3.1" dependencies = [ "ammonia", "anyhow", diff --git a/nym-node-status-api/nym-node-status-agent/Cargo.toml b/nym-node-status-api/nym-node-status-agent/Cargo.toml index 28ce1ba960..dbc7e877a5 100644 --- a/nym-node-status-api/nym-node-status-agent/Cargo.toml +++ b/nym-node-status-api/nym-node-status-agent/Cargo.toml @@ -3,7 +3,7 @@ [package] name = "nym-node-status-agent" -version = "1.1.4" +version = "1.1.5" authors.workspace = true repository.workspace = true homepage.workspace = true diff --git a/nym-node-status-api/nym-node-status-api/Cargo.toml b/nym-node-status-api/nym-node-status-api/Cargo.toml index 5c317a78c0..47a214d768 100644 --- a/nym-node-status-api/nym-node-status-api/Cargo.toml +++ b/nym-node-status-api/nym-node-status-api/Cargo.toml @@ -3,7 +3,7 @@ [package] name = "nym-node-status-api" -version = "4.3.0" +version = "4.3.1" authors.workspace = true repository.workspace = true homepage.workspace = true From b6df38358453c77a67d33b098b18d2072cd31fc0 Mon Sep 17 00:00:00 2001 From: mfahampshire Date: Tue, 24 Mar 2026 15:08:07 +0000 Subject: [PATCH 09/23] Max/docs theme rework (#6593) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Rawer landing page - Angular, clean docs styling inspired by Oxide - zero all border-radius globally (kill rounded corners) - sharp code blocks with subtle border - callouts: left-border accent instead of rounded pill - clean table grid lines, sharp search box and MUI buttons - tighter heading letter-spacing (-0.02em) - flat left-border sidebar active item instead of background blob * Add JetBrains Mono for headings/sidebar, push Oxide styling further - import JetBrains Mono via Google Fonts - apply mono font to headings, sidebar, nav bar, search, table headers - darken background (#181C1E), muted body text, h2 bottom border - subtle background tint on active sidebar item - inline code: background-only (no border), monospace table headers - fix active sidebar item font size (scope separator label rule) * Rework docs landing page: hero, ASCII cards, SDKs, get started - add hero section with subtitle covering all doc areas - replace PNG vector illustrations with ASCII art in primary green - add SDKs section with Rust and TypeScript links - add get started section: What is the Mixnet, Send a message, Run a node - add footer links to GitHub and Matrix - fix nav dropdown font (button + ul selectors) - add landing card hover style * Self-host JetBrains Mono, refine landing page - replace Google Fonts import with local @font-face (woff2) - add font files + OFL license to public/fonts/ - remove redundant "Nym Docs" hero heading (already in nav) - drop quick-links pills section - fix SDK box borders (negative margin collapse) - rewrite footer as simple link row (GitHub, Matrix, nym.com) * Light mode styling, dark-mode diagram invert, click-to-expand images - add full light mode CSS: pale grey bg, darker green links, mono fonts - invert diagram images in dark mode with mix-blend-mode: lighten - add click-to-expand overlay for content images - revert mermaid diagrams back to original PNGs * Fix Lychee config for fonts * Make light mode green darker * Animate landing page ASCII art, remove architecture diagram - Network: animated packet traversal through gw_e → M1/M2/M3 → gw_ex with diagonal cross-connections showing mixing paths - Developers: typewriter effect with blinking cursor - Operators: looping progress bar with continuously incrementing packet count - APIs: staged line-by-line response reveal - Remove architecture overview PNG from network/architecture.mdx * Small copy change to SDK headers * Fix links --- .../docs/components/landing-page.tsx | 600 ++++++++++++++---- .../outputs/api-scraping-outputs/time-now.md | 2 +- .../command-outputs/nym-node-run-help.md | 187 +++--- documentation/docs/pages/_app.tsx | 13 +- .../docs/pages/network/architecture.mdx | 4 +- documentation/docs/pages/styles.css | 453 +++++++++++-- .../public/fonts/JetBrainsMono-Bold.woff2 | Bin 0 -> 94588 bytes .../public/fonts/JetBrainsMono-LICENSE.txt | 93 +++ .../public/fonts/JetBrainsMono-Medium.woff2 | Bin 0 -> 93824 bytes .../public/fonts/JetBrainsMono-Regular.woff2 | Bin 0 -> 92164 bytes .../public/fonts/JetBrainsMono-SemiBold.woff2 | Bin 0 -> 94472 bytes documentation/docs/theme.config.tsx | 2 +- lychee.toml | 2 +- 13 files changed, 1093 insertions(+), 263 deletions(-) create mode 100644 documentation/docs/public/fonts/JetBrainsMono-Bold.woff2 create mode 100644 documentation/docs/public/fonts/JetBrainsMono-LICENSE.txt create mode 100644 documentation/docs/public/fonts/JetBrainsMono-Medium.woff2 create mode 100644 documentation/docs/public/fonts/JetBrainsMono-Regular.woff2 create mode 100644 documentation/docs/public/fonts/JetBrainsMono-SemiBold.woff2 diff --git a/documentation/docs/components/landing-page.tsx b/documentation/docs/components/landing-page.tsx index 699e704263..011ab24266 100644 --- a/documentation/docs/components/landing-page.tsx +++ b/documentation/docs/components/landing-page.tsx @@ -1,120 +1,504 @@ -import React from "react"; -import { Box, Grid, Typography } from "@mui/material"; -import useMediaQuery from "@mui/material/useMediaQuery"; -import { useTheme } from "@mui/material/styles"; - -import Image from "next/image"; +import React, { useState, useEffect, useRef } from "react"; import Link from "next/link"; -import networkDocs from "../public/images/landing/Vector1.png"; -import developerDocs from "../public/images/landing/Vector2.png"; -import sdkDocs from "../public/images/landing/Vector3.png"; -import operatorGuide from "../public/images/landing/Vector4.png"; -export const LandingPage = () => { - const theme = useTheme(); - const isTablet = useMediaQuery(theme.breakpoints.up("md")); - const isDesktop = useMediaQuery(theme.breakpoints.up("xl")); +const asciiStyle: React.CSSProperties = { + fontFamily: "var(--font-mono)", + fontSize: "0.72rem", + lineHeight: 1.4, + color: "var(--colorPrimary)", + opacity: 0.7, + whiteSpace: "pre", + margin: 0, +}; - const squares = [ - { - text: "Network Docs", - description: "Architecture, crypto systems, and how the Mixnet works", - href: "/network", - icon: developerDocs, - }, - { - text: "Operator Guides", - description: - "Guides and maintenance: if you want to run a node, start here", +// ── Animation components ── - href: "/operators/introduction", - icon: operatorGuide, - }, - { - text: "Developer Portal", - description: "Conceptual overview, clients, tools and SDKs", +const randomRow = () => Math.floor(Math.random() * 3); +const randomPath = () => [randomRow(), randomRow(), randomRow()]; - href: "/developers", - icon: sdkDocs, - }, - { - text: "APIs", - description: "Interactive API specs for Nym infrastructure", +const NetworkAnimation = () => { + // Packets traverse 5 stages: gw_e(0) → M1(1) → M2(2) → M3(3) → gw_ex(4) + // stage -1 = not yet mounted (SSR-safe, renders all ○) + const [packets, setPackets] = useState([ + { path: randomPath(), stage: -1 }, + { path: randomPath(), stage: -1 }, + ]); + useEffect(() => { + // kick off with staggered positions + setPackets([ + { path: randomPath(), stage: 0 }, + { path: randomPath(), stage: 3 }, + ]); - href: "/apis/introduction", - icon: networkDocs, - }, - ]; + const id = setInterval(() => { + setPackets((prev) => + prev.map((p) => { + const next = (p.stage + 1) % 5; + return { stage: next, path: next === 0 ? randomPath() : p.path }; + }) + ); + }, 300); + return () => clearInterval(id); + }, []); - const shortenDescription = (description: string) => { - return description.slice(0, 18) + "..."; + const gwNode = (stage: number) => { + const active = packets.some((p) => p.stage === stage); + return ( + + {active ? "\u25CF" : "\u25CB"} + + ); + }; + + const mixNode = (col: number, row: number) => { + const active = packets.some( + (p) => p.stage === col + 1 && p.path[col] === row + ); + const filled = active; + return ( + + {filled ? "\u25CF" : "\u25CB"} + + ); }; return ( - - {/* - Nym Docs - - - - Nym is a privacy platform. It provides strong network-level privacy - against sophisticated end-to-end attackers, and anonymous access control - using blinded, re-randomizable, decentralized credentials. Our goal is - to allow developers to build new applications, or upgrade existing apps, - with privacy features unavailable in other systems. - */} - - {squares.map((square, index) => ( - - - - - {square.text} - - - - {square.description} - - - {square.text} - - - - ))} - - +
+      {"gw_e  M1   M2   M3  gw_ex\n"}
+      {"       "}
+      {mixNode(0, 0)}
+      {" \u2500\u2500 "}
+      {mixNode(1, 0)}
+      {" \u2500\u2500 "}
+      {mixNode(2, 0)}
+      {"\n"}
+      {"        \\  / \\  /\n"}
+      {"  "}
+      {gwNode(0)}
+      {" \u2500\u2500 "}
+      {mixNode(0, 1)}
+      {" \u2500\u2500 "}
+      {mixNode(1, 1)}
+      {" \u2500\u2500 "}
+      {mixNode(2, 1)}
+      {" \u2500\u2500 "}
+      {gwNode(4)}
+      {"\n"}
+      {"        /  \\ /  \\\n"}
+      {"       "}
+      {mixNode(0, 2)}
+      {" \u2500\u2500 "}
+      {mixNode(1, 2)}
+      {" \u2500\u2500 "}
+      {mixNode(2, 2)}
+    
+ ); +}; + +const TypewriterAnimation = () => { + const text = + "let client = MixnetClient::connect_new().await?;\n" + + "\n" + + "client.send(msg).await;"; + const [charCount, setCharCount] = useState(0); + const [showCursor, setShowCursor] = useState(true); + + useEffect(() => { + let cancelled = false; + const run = () => { + setCharCount(0); + let i = 0; + const type = () => { + if (cancelled) return; + if (i <= text.length) { + setCharCount(i); + i++; + setTimeout(type, 40); + } else { + setTimeout(() => { + if (!cancelled) run(); + }, 2000); + } + }; + type(); + }; + run(); + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + const id = setInterval(() => setShowCursor((v) => !v), 530); + return () => clearInterval(id); + }, []); + + return ( +
+      {text.slice(0, charCount)}
+      {showCursor ? "\u258C" : " "}
+      {text.slice(charCount)}
+    
+ ); +}; + +const OperatorsAnimation = () => { + const totalBars = 10; + const [tick, setTick] = useState(0); + const mixRef = useRef(0); + const [mixCount, setMixCount] = useState(0); + + useEffect(() => { + const id = setInterval(() => { + setTick((t) => t + 1); + mixRef.current += Math.floor(Math.random() * 8) + 5; + setMixCount(mixRef.current); + }, 80); + return () => clearInterval(id); + }, []); + + const mixFilled = Math.min(tick % 12, totalBars); + const bar = (f: number) => + "\u25A0".repeat(f) + "\u25A1".repeat(totalBars - f); + const fmt = (n: number) => n.toLocaleString("en"); + + return ( +
+      {"> nym-node run\n\n"}
+      {"  mixing: "}
+      {bar(mixFilled)}
+      {"  "}
+      {fmt(mixCount)}
+      {" pkts"}
+    
+ ); +}; + +const ApiAnimation = () => { + const lines = [ + "GET /v1/mixnodes/active", + "", + '{ "count": 498,', + ' "nodes": [ ... ] }', + ]; + const [visibleLines, setVisibleLines] = useState(0); + + useEffect(() => { + let cancelled = false; + const run = () => { + setVisibleLines(0); + setTimeout(() => { + if (cancelled) return; + setVisibleLines(1); + setTimeout(() => { + if (cancelled) return; + let i = 2; + const reveal = () => { + if (cancelled) return; + if (i <= lines.length) { + setVisibleLines(i); + i++; + setTimeout(reveal, 300); + } else { + setTimeout(() => { + if (!cancelled) run(); + }, 2000); + } + }; + reveal(); + }, 800); + }, 100); + }; + run(); + return () => { + cancelled = true; + }; + }, []); + + return ( +
+      {lines.slice(0, visibleLines).map((line, i) => (
+        
+          {i > 0 && "\n"}
+          {line}
+        
+      ))}
+      
+        {lines.slice(visibleLines).map((line, i) => (
+          
+            {visibleLines > 0 || i > 0 ? "\n" : ""}
+            {line}
+          
+        ))}
+      
+    
+ ); +}; + +// ── Section data ── + +const sections = [ + { + title: "Network", + description: + "Architecture, cryptographic systems, and how the Mixnet protects your traffic.", + href: "/network", + animation: "network" as const, + }, + { + title: "Developers", + description: "SDKs, tutorials, and integration guides for building on Nym.", + href: "/developers", + animation: "typewriter" as const, + }, + { + title: "Operators", + description: + "Set up and maintain mix nodes, gateways, and network infrastructure.", + href: "/operators/introduction", + animation: "progress" as const, + }, + { + title: "APIs", + description: "Interactive specs for querying Nym infrastructure.", + href: "/apis/introduction", + animation: "api" as const, + }, +]; + +const AnimationBlock = ({ type }: { type: string }) => { + switch (type) { + case "network": + return ; + case "typewriter": + return ; + case "progress": + return ; + case "api": + return ; + default: + return null; + } +}; + +const sdks = [ + { + name: "Rust", + description: + "Native SDK with async Mixnet client, streams, and TcpProxy modules.", + href: "/developers/rust", + }, + { + name: "TypeScript", + description: + "Browser-based SDK with fetch API replacement and message-based WebSocket transport.", + href: "/developers/typescript", + }, +]; + +export const LandingPage = () => { + return ( +
+ {/* ── Section cards ── */} +
+ {sections.map((s, i) => ( + +
+
+
+

+ {s.title} +

+ + › + +
+

+ {s.description} +

+
+ +
+ + ))} +
+ + {/* ── SDKs ── */} +
+
+

+ SDKs +

+

+ Integrate Mixnet privacy into your application with our Rust and + TypeScript SDKs. +

+
+
+ {sdks.map((sdk, i) => ( + +
0 ? "-1px" : undefined, + display: "flex", + alignItems: "center", + justifyContent: "space-between", + transition: "background-color 0.15s", + cursor: "pointer", + }} + > +
+ + {sdk.name} + +

+ {sdk.description} +

+
+ + › + +
+ + ))} +
+
+ + {/* ── Links ── */} + +
); }; diff --git a/documentation/docs/components/outputs/api-scraping-outputs/time-now.md b/documentation/docs/components/outputs/api-scraping-outputs/time-now.md index d0e186efdb..597096bfa2 100644 --- a/documentation/docs/components/outputs/api-scraping-outputs/time-now.md +++ b/documentation/docs/components/outputs/api-scraping-outputs/time-now.md @@ -1 +1 @@ -Wednesday, March 18th 2026, 14:02:49 UTC +Tuesday, March 24th 2026, 13:05:57 UTC diff --git a/documentation/docs/components/outputs/command-outputs/nym-node-run-help.md b/documentation/docs/components/outputs/command-outputs/nym-node-run-help.md index f10b058c61..b3e26b1f56 100644 --- a/documentation/docs/components/outputs/command-outputs/nym-node-run-help.md +++ b/documentation/docs/components/outputs/command-outputs/nym-node-run-help.md @@ -4,109 +4,86 @@ Start this nym-node Usage: nym-node run [OPTIONS] Options: - --id - Id of the nym-node to use [env: NYMNODE_ID=] [default: default-nym-node] - --config-file - Path to a configuration file of this node [env: NYMNODE_CONFIG=] - --accept-operator-terms-and-conditions - Explicitly specify whether you agree with the terms and conditions of a nym node operator as defined at [env: - NYMNODE_ACCEPT_OPERATOR_TERMS=] - --deny-init - Forbid a new node from being initialised if configuration file for the provided specification doesn't already exist [env: NYMNODE_DENY_INIT=] - --init-only - If this is a brand new nym-node, specify whether it should only be initialised without actually running the subprocesses [env: NYMNODE_INIT_ONLY=] - --local - Flag specifying this node will be running in a local setting [env: NYMNODE_LOCAL=] - --mode [...] - Specifies the current mode(s) of this nym-node [env: NYMNODE_MODE=] [possible values: mixnode, entry-gateway, exit-gateway, exit-providers-only] - --modes - Specifies the current mode(s) of this nym-node as a single flag [env: NYMNODE_MODES=] [possible values: mixnode, entry-gateway, exit-gateway, exit-providers-only] - -w, --write-changes - If this node has been initialised before, specify whether to write any new changes to the config file [env: NYMNODE_WRITE_CONFIG_CHANGES=] - --bonding-information-output - Specify output file for bonding information of this nym-node, i.e. its encoded keys. NOTE: the required bonding information is still a subject to change and this argument should be - treated only as a preview of future features [env: NYMNODE_BONDING_INFORMATION_OUTPUT=] - -o, --output - Specify the output format of the bonding information (`text` or `json`) [env: NYMNODE_OUTPUT=] [default: text] [possible values: text, json] - --public-ips - Comma separated list of public ip addresses that will be announced to the nym-api and subsequently to the clients. In nearly all circumstances, it's going to be identical to the - address you're going to use for bonding [env: NYMNODE_PUBLIC_IPS=] - --hostname - Optional hostname associated with this gateway that will be announced to the nym-api and subsequently to the clients [env: NYMNODE_HOSTNAME=] - --location - Optional **physical** location of this node's server. Either full country name (e.g. 'Poland'), two-letter alpha2 (e.g. 'PL'), three-letter alpha3 (e.g. 'POL') or three-digit - numeric-3 (e.g. '616') can be provided [env: NYMNODE_LOCATION=] - --http-bind-address - Socket address this node will use for binding its http API. default: `[::]:8080` [env: NYMNODE_HTTP_BIND_ADDRESS=] - --landing-page-assets-path - Path to assets directory of custom landing page of this node [env: NYMNODE_HTTP_LANDING_ASSETS=] - --http-access-token - An optional bearer token for accessing certain http endpoints. Currently only used for prometheus metrics [env: NYMNODE_HTTP_ACCESS_TOKEN=] - --expose-system-info - Specify whether basic system information should be exposed. default: true [env: NYMNODE_HTTP_EXPOSE_SYSTEM_INFO=] [possible values: true, false] - --expose-system-hardware - Specify whether basic system hardware information should be exposed. default: true [env: NYMNODE_HTTP_EXPOSE_SYSTEM_HARDWARE=] [possible values: true, false] - --expose-crypto-hardware - Specify whether detailed system crypto hardware information should be exposed. default: true [env: NYMNODE_HTTP_EXPOSE_CRYPTO_HARDWARE=] [possible values: true, false] - --mixnet-bind-address - Address this node will bind to for listening for mixnet packets default: `[::]:1789` [env: NYMNODE_MIXNET_BIND_ADDRESS=] - --mixnet-announce-port - If applicable, custom port announced in the self-described API that other clients and nodes will use. Useful when the node is behind a proxy [env: NYMNODE_MIXNET_ANNOUNCE_PORT=] - --nym-api-urls - Addresses to nym APIs from which the node gets the view of the network [env: NYMNODE_NYM_APIS=] - --nyxd-urls - Addresses to nyxd chain endpoint which the node will use for chain interactions [env: NYMNODE_NYXD=] - --enable-console-logging - Specify whether running statistics of this node should be logged to the console [env: NYMNODE_ENABLE_CONSOLE_LOGGING=] [possible values: true, false] - --wireguard-enabled - Specifies whether the wireguard service is enabled on this node [env: NYMNODE_WG_ENABLED=] [possible values: true, false] - --wireguard-bind-address - Socket address this node will use for binding its wireguard interface. default: `[::]:51822` [env: NYMNODE_WG_BIND_ADDRESS=] - --wireguard-tunnel-announced-port - Tunnel port announced to external clients wishing to connect to the wireguard interface. Useful in the instances where the node is behind a proxy [env: NYMNODE_WG_ANNOUNCED_PORT=] - --wireguard-private-network-prefix - The prefix denoting the maximum number of the clients that can be connected via Wireguard. The maximum value for IPv4 is 32 and for IPv6 is 128 [env: - NYMNODE_WG_PRIVATE_NETWORK_PREFIX=] - --wireguard-userspace - Use userspace implementation of WireGuard (wireguard-go) instead of kernel module. Useful in containerized environments without kernel WireGuard support [env: NYMNODE_WG_USERSPACE=] - [possible values: true, false] - --verloc-bind-address - Socket address this node will use for binding its verloc API. default: `[::]:1790` [env: NYMNODE_VERLOC_BIND_ADDRESS=] - --verloc-announce-port - If applicable, custom port announced in the self-described API that other clients and nodes will use. Useful when the node is behind a proxy [env: NYMNODE_VERLOC_ANNOUNCE_PORT=] - --entry-bind-address - Socket address this node will use for binding its client websocket API. default: `[::]:9000` [env: NYMNODE_ENTRY_BIND_ADDRESS=] - --announce-ws-port - Custom announced port for listening for websocket client traffic. If unspecified, the value from the `bind_address` will be used instead [env: NYMNODE_ENTRY_ANNOUNCE_WS_PORT=] - --announce-wss-port - If applicable, announced port for listening for secure websocket client traffic [env: NYMNODE_ENTRY_ANNOUNCE_WSS_PORT=] - --enforce-zk-nyms - Indicates whether this gateway is accepting only coconut credentials for accessing the mixnet or if it also accepts non-paying clients [env: NYMNODE_ENFORCE_ZK_NYMS=] [possible - values: true, false] - --mnemonic - Custom cosmos wallet mnemonic used for zk-nym redemption. If no value is provided, a fresh mnemonic is going to be generated [env: NYMNODE_MNEMONIC=] - --upgrade-mode-attestation-url - Endpoint to query to retrieve current upgrade mode attestation. This argument should never be set outside testnets and local networks [env: NYMNODE_UPGRADE_MODE_ATTESTATION_URL=] - --upgrade-mode-attester-public-key - Expected public key of the entity signing the published attestation. This argument should never be set outside testnets and local networks [env: - NYMNODE_UPGRADE_MODE_ATTESTER_PUBKEY=] - --upstream-exit-policy-url - Specifies the url for an upstream source of the exit policy used by this node [env: NYMNODE_UPSTREAM_EXIT_POLICY=] - --open-proxy - Specifies whether this exit node should run in 'open-proxy' mode and thus would attempt to resolve **ANY** request it receives [env: NYMNODE_OPEN_PROXY=] [possible values: true, - false] - --lp-control-bind-address - Bind address for the TCP LP control traffic. default: `[::]:41264` [env: NYMNODE_LP_CONTROL_BIND_ADDRESS=] - --lp-control-announce-port - Custom announced port for listening for the TCP LP control traffic. If unspecified, the value from the `lp_control_bind_address` will be used instead [env: - NYMNODE_LP_CONTROL_ANNOUNCE_PORT=] - --lp-data-bind-address - Bind address for the UDP LP data traffic. default: `[::]:51264` [env: NYMNODE_LP_DATA_BIND_ADDRESS=] - --lp-data-announce-port - Custom announced port for listening for the UDP LP data traffic. If unspecified, the value from the `lp_data_bind_address` will be used instead [env: NYMNODE_LP_DATA_ANNOUNCE_PORT=] - --lp-use-mock-ecash - Use mock ecash manager for LP testing. WARNING: Only use this for local testing! Never enable in production. When enabled, the LP listener will accept any credential without - blockchain verification [env: NYMNODE_LP_USE_MOCK_ECASH=] [possible values: true, false] - -h, --help - Print help + --id Id of the nym-node to use [env: NYMNODE_ID=] [default: default-nym-node] + --config-file Path to a configuration file of this node [env: NYMNODE_CONFIG=] + --accept-operator-terms-and-conditions Explicitly specify whether you agree with the terms and conditions of a nym node operator as defined at + [env: NYMNODE_ACCEPT_OPERATOR_TERMS=] + --deny-init Forbid a new node from being initialised if configuration file for the provided specification doesn't already exist [env: + NYMNODE_DENY_INIT=] + --init-only If this is a brand new nym-node, specify whether it should only be initialised without actually running the subprocesses + [env: NYMNODE_INIT_ONLY=] + --local Flag specifying this node will be running in a local setting [env: NYMNODE_LOCAL=] + --mode [...] Specifies the current mode(s) of this nym-node [env: NYMNODE_MODE=] [possible values: mixnode, entry-gateway, + exit-gateway, exit-providers-only] + --modes Specifies the current mode(s) of this nym-node as a single flag [env: NYMNODE_MODES=] [possible values: mixnode, + entry-gateway, exit-gateway, exit-providers-only] + -w, --write-changes If this node has been initialised before, specify whether to write any new changes to the config file [env: + NYMNODE_WRITE_CONFIG_CHANGES=] + --bonding-information-output Specify output file for bonding information of this nym-node, i.e. its encoded keys. NOTE: the required bonding + information is still a subject to change and this argument should be treated only as a preview of future features [env: + NYMNODE_BONDING_INFORMATION_OUTPUT=] + -o, --output Specify the output format of the bonding information (`text` or `json`) [env: NYMNODE_OUTPUT=] [default: text] [possible + values: text, json] + --public-ips Comma separated list of public ip addresses that will be announced to the nym-api and subsequently to the clients. In + nearly all circumstances, it's going to be identical to the address you're going to use for bonding [env: + NYMNODE_PUBLIC_IPS=] + --hostname Optional hostname associated with this gateway that will be announced to the nym-api and subsequently to the clients [env: + NYMNODE_HOSTNAME=] + --location Optional **physical** location of this node's server. Either full country name (e.g. 'Poland'), two-letter alpha2 (e.g. + 'PL'), three-letter alpha3 (e.g. 'POL') or three-digit numeric-3 (e.g. '616') can be provided [env: NYMNODE_LOCATION=] + --http-bind-address Socket address this node will use for binding its http API. default: `[::]:8080` [env: NYMNODE_HTTP_BIND_ADDRESS=] + --landing-page-assets-path Path to assets directory of custom landing page of this node [env: NYMNODE_HTTP_LANDING_ASSETS=] + --http-access-token An optional bearer token for accessing certain http endpoints. Currently only used for prometheus metrics [env: + NYMNODE_HTTP_ACCESS_TOKEN=] + --expose-system-info Specify whether basic system information should be exposed. default: true [env: NYMNODE_HTTP_EXPOSE_SYSTEM_INFO=] + [possible values: true, false] + --expose-system-hardware Specify whether basic system hardware information should be exposed. default: true [env: + NYMNODE_HTTP_EXPOSE_SYSTEM_HARDWARE=] [possible values: true, false] + --expose-crypto-hardware Specify whether detailed system crypto hardware information should be exposed. default: true [env: + NYMNODE_HTTP_EXPOSE_CRYPTO_HARDWARE=] [possible values: true, false] + --mixnet-bind-address Address this node will bind to for listening for mixnet packets default: `[::]:1789` [env: NYMNODE_MIXNET_BIND_ADDRESS=] + --mixnet-announce-port If applicable, custom port announced in the self-described API that other clients and nodes will use. Useful when the node + is behind a proxy [env: NYMNODE_MIXNET_ANNOUNCE_PORT=] + --nym-api-urls Addresses to nym APIs from which the node gets the view of the network [env: NYMNODE_NYM_APIS=] + --nyxd-urls Addresses to nyxd chain endpoint which the node will use for chain interactions [env: NYMNODE_NYXD=] + --enable-console-logging Specify whether running statistics of this node should be logged to the console [env: NYMNODE_ENABLE_CONSOLE_LOGGING=] + [possible values: true, false] + --wireguard-enabled Specifies whether the wireguard service is enabled on this node [env: NYMNODE_WG_ENABLED=] [possible values: true, false] + --wireguard-bind-address Socket address this node will use for binding its wireguard interface. default: `[::]:51822` [env: + NYMNODE_WG_BIND_ADDRESS=] + --wireguard-tunnel-announced-port Tunnel port announced to external clients wishing to connect to the wireguard interface. Useful in the instances where the + node is behind a proxy [env: NYMNODE_WG_ANNOUNCED_PORT=] + --wireguard-private-network-prefix The prefix denoting the maximum number of the clients that can be connected via Wireguard. The maximum value for IPv4 is + 32 and for IPv6 is 128 [env: NYMNODE_WG_PRIVATE_NETWORK_PREFIX=] + --wireguard-userspace Use userspace implementation of WireGuard (wireguard-go) instead of kernel module. Useful in containerized environments + without kernel WireGuard support [env: NYMNODE_WG_USERSPACE=] [possible values: true, false] + --verloc-bind-address Socket address this node will use for binding its verloc API. default: `[::]:1790` [env: NYMNODE_VERLOC_BIND_ADDRESS=] + --verloc-announce-port If applicable, custom port announced in the self-described API that other clients and nodes will use. Useful when the node + is behind a proxy [env: NYMNODE_VERLOC_ANNOUNCE_PORT=] + --entry-bind-address Socket address this node will use for binding its client websocket API. default: `[::]:9000` [env: + NYMNODE_ENTRY_BIND_ADDRESS=] + --announce-ws-port Custom announced port for listening for websocket client traffic. If unspecified, the value from the `bind_address` will + be used instead [env: NYMNODE_ENTRY_ANNOUNCE_WS_PORT=] + --announce-wss-port If applicable, announced port for listening for secure websocket client traffic [env: NYMNODE_ENTRY_ANNOUNCE_WSS_PORT=] + --enforce-zk-nyms Indicates whether this gateway is accepting only coconut credentials for accessing the mixnet or if it also accepts + non-paying clients [env: NYMNODE_ENFORCE_ZK_NYMS=] [possible values: true, false] + --mnemonic Custom cosmos wallet mnemonic used for zk-nym redemption. If no value is provided, a fresh mnemonic is going to be + generated [env: NYMNODE_MNEMONIC=] + --upgrade-mode-attestation-url Endpoint to query to retrieve current upgrade mode attestation. This argument should never be set outside testnets and + local networks [env: NYMNODE_UPGRADE_MODE_ATTESTATION_URL=] + --upgrade-mode-attester-public-key Expected public key of the entity signing the published attestation. This argument should never be set outside testnets + and local networks [env: NYMNODE_UPGRADE_MODE_ATTESTER_PUBKEY=] + --upstream-exit-policy-url Specifies the url for an upstream source of the exit policy used by this node [env: NYMNODE_UPSTREAM_EXIT_POLICY=] + --open-proxy Specifies whether this exit node should run in 'open-proxy' mode and thus would attempt to resolve **ANY** request it + receives [env: NYMNODE_OPEN_PROXY=] [possible values: true, false] + --lp-control-bind-address Bind address for the TCP LP control traffic. default: `[::]:41264` [env: NYMNODE_LP_CONTROL_BIND_ADDRESS=] + --lp-control-announce-port Custom announced port for listening for the TCP LP control traffic. If unspecified, the value from the + `lp_control_bind_address` will be used instead [env: NYMNODE_LP_CONTROL_ANNOUNCE_PORT=] + --lp-data-bind-address Bind address for the UDP LP data traffic. default: `[::]:51264` [env: NYMNODE_LP_DATA_BIND_ADDRESS=] + --lp-data-announce-port Custom announced port for listening for the UDP LP data traffic. If unspecified, the value from the `lp_data_bind_address` + will be used instead [env: NYMNODE_LP_DATA_ANNOUNCE_PORT=] + --lp-use-mock-ecash Use mock ecash manager for LP testing. WARNING: Only use this for local testing! Never enable in production. When enabled, + the LP listener will accept any credential without blockchain verification [env: NYMNODE_LP_USE_MOCK_ECASH=] [possible + values: true, false] + -h, --help Print help ``` diff --git a/documentation/docs/pages/_app.tsx b/documentation/docs/pages/_app.tsx index f76d9a47ef..15e08f8a0a 100644 --- a/documentation/docs/pages/_app.tsx +++ b/documentation/docs/pages/_app.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react'; +import React, { useMemo, useEffect } from 'react'; import type { AppProps } from 'next/app'; import './styles.css'; import { ThemeProvider, createTheme } from '@mui/material/styles'; @@ -20,6 +20,17 @@ const MyApp: React.FC = ({ Component, pageProps }) => { }), [], ); + useEffect(() => { + const handler = (e: MouseEvent) => { + const img = e.target as HTMLElement; + if (img.tagName === 'IMG' && img.closest('.nextra-content')) { + img.classList.toggle('img-expanded'); + } + }; + document.addEventListener('click', handler); + return () => document.removeEventListener('click', handler); + }, []); + const AnyComponent = Component as any; return ( diff --git a/documentation/docs/pages/network/architecture.mdx b/documentation/docs/pages/network/architecture.mdx index 19560c0bb2..a3b8086b9b 100644 --- a/documentation/docs/pages/network/architecture.mdx +++ b/documentation/docs/pages/network/architecture.mdx @@ -3,6 +3,4 @@ Core components: * A **Mixnet**, which mixes Sphinx packet traffic so that it cannot be determined who is communicating with whom. Our mixnet is based on a modified version of the [**Loopix** design](concepts/loopix). This is made up of [Nym nodes](architecture/mixnet#nodes) runnning on servers around the world maintained by a decentralised group of Operators. * Various [**Nym clients**](architecture/mixnet#nym-clients) which manage sending and receiving Sphinx packets, encrypting/decrypting traffic, and providing [cover traffic](./concepts/cover-traffic) to hide 'real' traffic timing. -* A CosmWasm-enabled blockchain called [**Nyx**](architecture/nyx), the home of the various smart contracts used by the mixnet. A subset of Nyx Validators run [NymAPI](./architecture/nyx#nymapi) instances, taking part in also producing and verifying [zk-nym credentials](cryptography/zk-nym). - -![arch_overview](/images/network/arch/overall-arch.png) +* A CosmWasm-enabled blockchain called [**Nyx**](architecture/nyx), the home of the various smart contracts used by the mixnet. A subset of Nyx Validators run [NymAPI](./architecture/nyx#nymapi) instances, taking part in also producing and verifying [zk-nym credentials](cryptography/zk-nym). \ No newline at end of file diff --git a/documentation/docs/pages/styles.css b/documentation/docs/pages/styles.css index 0e5c829a30..1de040d5d1 100644 --- a/documentation/docs/pages/styles.css +++ b/documentation/docs/pages/styles.css @@ -1,21 +1,67 @@ -/* nym.com-aligned colour tokens */ -:root { - --colorPrimary: #85E89D; - --textPrimary: #FFFFFF; - --bg-dark: #1E2426; - --border-dark: #2E3538; +/* nym docs – angular, clean styling (oxide-inspired) */ + +@font-face { + font-family: 'JetBrains Mono'; + src: url('/docs/fonts/JetBrainsMono-Regular.woff2') format('woff2'); + font-weight: 400; + font-style: normal; + font-display: swap; } -/* dark mode background override */ +@font-face { + font-family: 'JetBrains Mono'; + src: url('/docs/fonts/JetBrainsMono-Medium.woff2') format('woff2'); + font-weight: 500; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'JetBrains Mono'; + src: url('/docs/fonts/JetBrainsMono-SemiBold.woff2') format('woff2'); + font-weight: 600; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'JetBrains Mono'; + src: url('/docs/fonts/JetBrainsMono-Bold.woff2') format('woff2'); + font-weight: 700; + font-style: normal; + font-display: swap; +} + +:root { + --colorPrimary: #85E89D; + --textPrimary: #E0E0E0; + --textMuted: #8B9295; + --bg-dark: #181C1E; + --bg-code: #1C2022; + --border-dark: #2A2F32; + --border: #2A2F32; + --font-mono: 'JetBrains Mono', 'SFMono-Regular', 'Consolas', 'Liberation Mono', 'Menlo', monospace; +} + +/* ── Global: kill all rounded corners ── */ + +*, +*::before, +*::after { + border-radius: 0 !important; +} + +/* ── Dark mode backgrounds ── */ + html.dark { background-color: var(--bg-dark); } html.dark body { background-color: var(--bg-dark); + color: var(--textPrimary); } -/* nextra main content area bg */ html.dark .nextra-nav-container, html.dark .nextra-sidebar-container, html.dark .nextra-content, @@ -24,28 +70,43 @@ html.dark .dark\:nx-bg-dark { background-color: var(--bg-dark) !important; } +/* ── Typography ── */ + +h1, h2, h3, h4, h5, h6 { + font-family: var(--font-mono); + letter-spacing: -0.02em; + font-weight: 600; +} + +html.dark h1 { + color: #FFFFFF; + font-weight: 700; +} + +html.dark h2 { + color: #F0F0F0; + padding-bottom: 0.3em; + border-bottom: 1px solid var(--border-dark); +} + +html.dark p, html.dark li { + color: var(--textPrimary); + line-height: 1.7; +} + +/* ── Layout ── */ + footer { text-align: center; } .nextra-code-block > pre { - /* max-height: 350px !important; */ max-height: none !important; scroll-y: auto !important; } -/* set width entire div */ .nx-mx-auto.nx-flex.nx-max-w-\[90rem\] { max-width: 110rem; - left-padding: 1%; -} - -.nextra-toc { - width: 10px !important; - padding-right: 0px; - padding-left: 0px; - /* text-align: right; */ - border-left: 1px solid var(--border-dark); } .nextra-content { @@ -56,23 +117,26 @@ footer { margin: 0 !important; } -:is(html[class~="dark"] .dark\:nx-bg-primary-300\/10) { - background-color: hsl(var(black) 100% 77%/0.1) !important; +/* ── Nav bar ── */ + +html.dark .nextra-nav-container { + border-bottom: 1px solid var(--border-dark) !important; } -/* Sidebar active item */ -:is(html .dark\:nx-bg-primary-400\/10) { - background: transparent !important; - border-left: 2px solid #85E89D; - color: #FFFFFF !important; +html.dark .nextra-nav-container nav a, +html.dark .nextra-nav-container nav button, +html.dark .nextra-nav-container nav ul a { + font-family: var(--font-mono); + font-size: 0.85rem; } -:is(html:not(.dark) .dark\:nx-bg-primary-400\/10) { - background: transparent !important; - border-left: 2px solid #4A9E5C; - color: #242B2D !important; +html.dark .nextra-nav-container nav a:hover, +html.dark .nextra-nav-container nav ul a:hover { + color: var(--colorPrimary) !important; } +/* ── Sidebar ── */ + .nextra-sidebar-container { border-right: 1px solid var(--border-dark); width: 300px !important; @@ -82,21 +146,171 @@ footer { column-width: 500px; } -/* Links*/ +/* Sidebar font */ +html.dark .nextra-sidebar-container { + font-family: var(--font-mono); + font-size: 0.82rem; +} + +/* Sidebar text: muted by default */ +html.dark .nextra-sidebar-container a { + color: var(--textMuted) !important; +} + +html.dark .nextra-sidebar-container a:hover { + color: var(--textPrimary) !important; +} + +/* Sidebar separator labels (not active items) */ +html.dark .nextra-sidebar-container li.nx-mt-5 .nx-font-semibold { + color: var(--textMuted) !important; + font-size: 0.7rem; + letter-spacing: 0.05em; +} + +/* Active sidebar item: left-border accent + subtle tint */ +:is(html .dark\:nx-bg-primary-400\/10) { + background: rgba(133, 232, 157, 0.06) !important; + border-left: 2px solid var(--colorPrimary); + color: #FFFFFF !important; +} + +html.dark .nextra-sidebar-container :is(.dark\:nx-bg-primary-400\/10) a { + color: #FFFFFF !important; +} + +:is(html:not(.dark) .dark\:nx-bg-primary-400\/10) { + background: rgba(74, 158, 92, 0.08) !important; + border-left: 2px solid #4A9E5C; + color: #242B2D !important; +} + +:is(html[class~="dark"] .dark\:nx-bg-primary-300\/10) { + background-color: transparent !important; +} + +/* ── TOC (hidden — subsections live in sidebar) ── */ + +.nextra-toc { + display: none !important; +} + +/* ── Code blocks ── */ + +html.dark .nextra-code-block > pre { + background-color: var(--bg-code) !important; + border: 1px solid var(--border-dark); +} + +/* Language tag in code blocks */ +html.dark .nextra-code-block [class*="nx-absolute"] { + color: var(--textMuted); + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +/* Inline code: subtle background, no border */ +html.dark code:not(pre code) { + background-color: rgba(255, 255, 255, 0.06) !important; + border: none !important; + padding: 0.15em 0.4em; + font-size: 0.88em; + color: var(--textPrimary); +} + +/* ── Callouts: left-border accent ── */ + +html.dark .nextra-callout { + border: none; + border-left: 3px solid; + background: rgba(255, 255, 255, 0.02); +} + +/* ── Tables: monospace uppercase headers ── */ + +html.dark table { + border-collapse: collapse; + width: 100%; +} + +html.dark th { + border-bottom: 1px solid var(--border-dark); + text-align: left; + font-weight: 600; + font-family: var(--font-mono); + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--textMuted); + padding: 0.6em 1em; +} + +html.dark td { + border-bottom: 1px solid var(--border-dark); + padding: 0.6em 1em; + color: var(--textPrimary); +} + +html.dark tr:hover td { + background-color: rgba(255, 255, 255, 0.02); +} + +/* ── Search box ── */ + +html.dark .nextra-search input { + border: 1px solid var(--border-dark); + background-color: var(--bg-dark) !important; + color: var(--textMuted); + font-family: var(--font-mono); + font-size: 0.8rem; +} + +html.dark .nextra-search input::placeholder { + text-transform: uppercase; + letter-spacing: 0.08em; + font-size: 0.72rem; +} + +/* ── Links ── */ + .nx-text-primary-600.nx-underline.nx-decoration-from-font { color: var(--colorPrimary) !important; } -.MuiTypography-root.MuiTypography-inherit.MuiLink-root.MuiLink-underlineAlways.css-1xr3c94-MuiTypography-root-MuiLink-root { +.nx-text-primary-600 { + color: var(--colorPrimary) !important; +} + +.nextra-scrollbar.nx-sticky { + color: var(--colorPrimary) !important; +} + +/* ── Breadcrumbs / navigation links ── */ + +html.dark .nextra-breadcrumb { + color: var(--textMuted); +} + +/* Prev/next page links */ +html.dark a.nx-flex.nx-items-center { + border-color: var(--border-dark) !important; +} + +html.dark a.nx-flex.nx-items-center:hover { + border-color: var(--colorPrimary) !important; +} + +/* ── Material UI overrides ── */ + +.MuiTypography-root.MuiTypography-inherit.MuiLink-root.MuiLink-underlineAlways { color: var(--colorPrimary) !important; } -/* Chips*/ .chipContained { background-color: var(--colorPrimary) !important; } -/* Buttons */ .MuiButton-root { color: var(--colorPrimary) !important; border-color: var(--colorPrimary) !important; @@ -111,11 +325,7 @@ footer { color: var(--colorPrimary) !important; } -.nextra-scrollbar.nx-sticky { - color: var(--colorPrimary) !important; -} - -.nx-text-primary-600 { +a.MuiLink-root { color: var(--colorPrimary) !important; } @@ -127,10 +337,167 @@ input:focus-visible { border-color: var(--colorPrimary) !important; } -a.MuiLink-root { - color: var(--colorPrimary) !important; +.MuiPaper-root.MuiAccordion-root { + border-radius: 0; } -.MuiPaper-root.MuiAccordion-root { - border-radius: 8px; +/* ── Landing page ── */ + +.landing-heading { + color: #FFFFFF; +} + +.landing-card:hover { + background-color: rgba(255, 255, 255, 0.03) !important; +} + +/* ── Invert diagrams in dark mode ── */ + +html.dark .nextra-content img:not([src*="landing"]) { + filter: invert(1) hue-rotate(180deg) brightness(0.92) contrast(1.1); + mix-blend-mode: lighten; + cursor: zoom-in; +} + +/* Expanded image overlay */ +.nextra-content img.img-expanded { + position: fixed !important; + top: 0; + left: 0; + width: 100vw !important; + height: 100vh !important; + max-width: none !important; + object-fit: contain; + background: rgba(0, 0, 0, 0.9); + z-index: 9999; + cursor: zoom-out; + padding: 2rem; + mix-blend-mode: normal; + filter: none; +} + +html.dark .nextra-content img.img-expanded { + filter: invert(1) hue-rotate(180deg) brightness(0.92) contrast(1.1); + mix-blend-mode: normal; +} + +/* ── Light mode ── */ + +html:not(.dark) { + --colorPrimary: #1a7a32; + --colorPrimary-light: #1a7a32; + --bg-light: #F5F5F5; + --border-light: #E0E0E0; + --border: #E0E0E0; + --textMuted: #6B7280; + --textMuted-light: #6B7280; +} + +html:not(.dark) body { + background-color: var(--bg-light); +} + +html:not(.dark) .nextra-nav-container, +html:not(.dark) .nextra-sidebar-container, +html:not(.dark) .nextra-content, +html:not(.dark) .nx-bg-white { + background-color: var(--bg-light) !important; +} + +/* Light mode links: darker green */ +html:not(.dark) .nx-text-primary-600 { + color: var(--colorPrimary-light) !important; +} + +html:not(.dark) .nx-text-primary-600.nx-underline.nx-decoration-from-font { + color: var(--colorPrimary-light) !important; +} + +/* Light mode sidebar */ +html:not(.dark) .nextra-sidebar-container { + font-family: var(--font-mono); + font-size: 0.82rem; + border-right: 1px solid var(--border-light); +} + +html:not(.dark) .nextra-sidebar-container a { + color: var(--textMuted-light) !important; +} + +html:not(.dark) .nextra-sidebar-container a:hover { + color: #111 !important; +} + +/* Light mode nav */ +html:not(.dark) .nextra-nav-container { + border-bottom: 1px solid var(--border-light) !important; +} + +html:not(.dark) .nextra-nav-container nav a, +html:not(.dark) .nextra-nav-container nav button, +html:not(.dark) .nextra-nav-container nav ul a { + font-family: var(--font-mono); + font-size: 0.85rem; +} + +html:not(.dark) .nextra-nav-container nav a:hover, +html:not(.dark) .nextra-nav-container nav ul a:hover { + color: var(--colorPrimary-light) !important; +} + +/* Light mode code */ +html:not(.dark) code:not(pre code) { + background-color: rgba(0, 0, 0, 0.05) !important; + border: none !important; + padding: 0.15em 0.4em; + font-size: 0.88em; +} + +/* Light mode callouts */ +html:not(.dark) .nextra-callout { + border: none; + border-left: 3px solid; + background: rgba(0, 0, 0, 0.02); +} + +/* Light mode tables */ +html:not(.dark) th { + font-family: var(--font-mono); + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--textMuted-light); +} + +/* Light mode search */ +html:not(.dark) .nextra-search input { + font-family: var(--font-mono); + font-size: 0.8rem; + border: 1px solid var(--border-light); + background-color: #FFFFFF !important; +} + +html:not(.dark) .nextra-search input::placeholder { + text-transform: uppercase; + letter-spacing: 0.08em; + font-size: 0.72rem; +} + +/* Light mode h2 border */ +html:not(.dark) h2 { + padding-bottom: 0.3em; + border-bottom: 1px solid var(--border-light); +} + +/* Light mode landing page */ +html:not(.dark) .landing-heading { + color: #1a1a1a !important; +} + +html:not(.dark) .landing-card:hover { + background-color: rgba(0, 0, 0, 0.02) !important; +} + +html:not(.dark) .landing-card { + border-color: var(--border-light) !important; } diff --git a/documentation/docs/public/fonts/JetBrainsMono-Bold.woff2 b/documentation/docs/public/fonts/JetBrainsMono-Bold.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..4917f43410757b48e4ad1eca81b868dfe405d25b GIT binary patch literal 94588 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 W6KeHYDRQ^d=CvO2o&f^^0sSA5t?+*U literal 0 HcmV?d00001 diff --git a/documentation/docs/public/fonts/JetBrainsMono-LICENSE.txt b/documentation/docs/public/fonts/JetBrainsMono-LICENSE.txt new file mode 100644 index 0000000000..8bee4148c1 --- /dev/null +++ b/documentation/docs/public/fonts/JetBrainsMono-LICENSE.txt @@ -0,0 +1,93 @@ +Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/documentation/docs/public/fonts/JetBrainsMono-Medium.woff2 b/documentation/docs/public/fonts/JetBrainsMono-Medium.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..669d04cdf2d841e79bbf13d46d90e280ecbfe82d GIT binary patch literal 93824 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 c7tX`3Y{L39dc2U}a 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 literal 0 HcmV?d00001 diff --git a/documentation/docs/public/fonts/JetBrainsMono-Regular.woff2 b/documentation/docs/public/fonts/JetBrainsMono-Regular.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..40da427651937ac3b65ea6842f9448a810f719c6 GIT binary patch literal 92164 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

n;(xiy1rmCEH9%+5AkK0c9Mmp$Bpno>X5 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%0A8;vgQ=!?I3ie`l+=pu?>UlrjFQ?&c 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

3}`083+mir3b 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

u0;?uWO_?-|8O8&D*j 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 Qsd`%9Nuz;I8UO&`KVo}q*Z=?k literal 0 HcmV?d00001 diff --git a/documentation/docs/theme.config.tsx b/documentation/docs/theme.config.tsx index 8906294882..c223e68178 100644 --- a/documentation/docs/theme.config.tsx +++ b/documentation/docs/theme.config.tsx @@ -139,7 +139,7 @@ const config: DocsThemeConfig = { ); }, - logo: Nym Docs, + logo: Nym Docs, project: { link: "https://github.com/nymtech/nym", }, diff --git a/lychee.toml b/lychee.toml index 42ea21812d..7dff1a851a 100644 --- a/lychee.toml +++ b/lychee.toml @@ -9,7 +9,7 @@ fallback_extensions = ["mdx", "md"] # Remap /images to public/images for NextJS static assets # Lychee resolves /images to pages/images, but Next.js serves from public/images -remap = ["pages/images public/images"] +remap = ["pages/images public/images", "pages/docs/fonts public/fonts"] # Exclude component snippets (TODO: verify which are still in use) exclude_path = ["components/"] From 5e0eeeddd6fe1531218cbdbabe2e2b4bf6f6f2cc Mon Sep 17 00:00:00 2001 From: mfahampshire Date: Tue, 24 Mar 2026 15:32:30 +0000 Subject: [PATCH 10/23] hotfix (#6603) --- documentation/docs/components/landing-page.tsx | 2 +- .../docs/components/outputs/api-scraping-outputs/time-now.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/documentation/docs/components/landing-page.tsx b/documentation/docs/components/landing-page.tsx index 011ab24266..e11abb8770 100644 --- a/documentation/docs/components/landing-page.tsx +++ b/documentation/docs/components/landing-page.tsx @@ -496,7 +496,7 @@ export const LandingPage = () => { rel="noopener noreferrer" style={{ color: "var(--textMuted)", textDecoration: "none" }} > - nymtech.net + nym.com diff --git a/documentation/docs/components/outputs/api-scraping-outputs/time-now.md b/documentation/docs/components/outputs/api-scraping-outputs/time-now.md index 597096bfa2..43a249b7de 100644 --- a/documentation/docs/components/outputs/api-scraping-outputs/time-now.md +++ b/documentation/docs/components/outputs/api-scraping-outputs/time-now.md @@ -1 +1 @@ -Tuesday, March 24th 2026, 13:05:57 UTC +Tuesday, March 24th 2026, 15:23:36 UTC From 9904f6b17cbd896d2581ece5b2bea122e38b8b00 Mon Sep 17 00:00:00 2001 From: mfahampshire Date: Tue, 24 Mar 2026 21:56:15 +0000 Subject: [PATCH 11/23] Make mobile friendly (#6605) - Add overflow:hidden on grid - Shrink `pre` font on mobile - Stack grid on narrow pages --- .../docs/components/landing-page.tsx | 3 ++ .../outputs/api-scraping-outputs/time-now.md | 2 +- .../command-outputs/nym-node-run-help.md | 24 ++++++------- documentation/docs/pages/styles.css | 36 +++++++++++++++++++ 4 files changed, 52 insertions(+), 13 deletions(-) diff --git a/documentation/docs/components/landing-page.tsx b/documentation/docs/components/landing-page.tsx index e11abb8770..9df5a95192 100644 --- a/documentation/docs/components/landing-page.tsx +++ b/documentation/docs/components/landing-page.tsx @@ -311,6 +311,7 @@ export const LandingPage = () => { > {/* ── Section cards ── */}
{ style={{ textDecoration: "none", display: "flex" }} >
{ {/* ── SDKs ── */}
...] Specifies the current mode(s) of this nym-node [env: NYMNODE_MODE=] [possible values: mixnode, entry-gateway, - exit-gateway, exit-providers-only] + --mode [...] Specifies the current mode(s) of this nym-node [env: NYMNODE_MODE=] [possible values: mixnode, entry-gateway, exit-gateway, + exit-providers-only] --modes Specifies the current mode(s) of this nym-node as a single flag [env: NYMNODE_MODES=] [possible values: mixnode, entry-gateway, exit-gateway, exit-providers-only] -w, --write-changes If this node has been initialised before, specify whether to write any new changes to the config file [env: NYMNODE_WRITE_CONFIG_CHANGES=] - --bonding-information-output Specify output file for bonding information of this nym-node, i.e. its encoded keys. NOTE: the required bonding - information is still a subject to change and this argument should be treated only as a preview of future features [env: + --bonding-information-output Specify output file for bonding information of this nym-node, i.e. its encoded keys. NOTE: the required bonding information + is still a subject to change and this argument should be treated only as a preview of future features [env: NYMNODE_BONDING_INFORMATION_OUTPUT=] -o, --output Specify the output format of the bonding information (`text` or `json`) [env: NYMNODE_OUTPUT=] [default: text] [possible values: text, json] @@ -35,8 +35,8 @@ Options: --landing-page-assets-path Path to assets directory of custom landing page of this node [env: NYMNODE_HTTP_LANDING_ASSETS=] --http-access-token An optional bearer token for accessing certain http endpoints. Currently only used for prometheus metrics [env: NYMNODE_HTTP_ACCESS_TOKEN=] - --expose-system-info Specify whether basic system information should be exposed. default: true [env: NYMNODE_HTTP_EXPOSE_SYSTEM_INFO=] - [possible values: true, false] + --expose-system-info Specify whether basic system information should be exposed. default: true [env: NYMNODE_HTTP_EXPOSE_SYSTEM_INFO=] [possible + values: true, false] --expose-system-hardware Specify whether basic system hardware information should be exposed. default: true [env: NYMNODE_HTTP_EXPOSE_SYSTEM_HARDWARE=] [possible values: true, false] --expose-crypto-hardware Specify whether detailed system crypto hardware information should be exposed. default: true [env: @@ -53,8 +53,8 @@ Options: NYMNODE_WG_BIND_ADDRESS=] --wireguard-tunnel-announced-port Tunnel port announced to external clients wishing to connect to the wireguard interface. Useful in the instances where the node is behind a proxy [env: NYMNODE_WG_ANNOUNCED_PORT=] - --wireguard-private-network-prefix The prefix denoting the maximum number of the clients that can be connected via Wireguard. The maximum value for IPv4 is - 32 and for IPv6 is 128 [env: NYMNODE_WG_PRIVATE_NETWORK_PREFIX=] + --wireguard-private-network-prefix The prefix denoting the maximum number of the clients that can be connected via Wireguard. The maximum value for IPv4 is 32 + and for IPv6 is 128 [env: NYMNODE_WG_PRIVATE_NETWORK_PREFIX=] --wireguard-userspace Use userspace implementation of WireGuard (wireguard-go) instead of kernel module. Useful in containerized environments without kernel WireGuard support [env: NYMNODE_WG_USERSPACE=] [possible values: true, false] --verloc-bind-address Socket address this node will use for binding its verloc API. default: `[::]:1790` [env: NYMNODE_VERLOC_BIND_ADDRESS=] @@ -62,8 +62,8 @@ Options: is behind a proxy [env: NYMNODE_VERLOC_ANNOUNCE_PORT=] --entry-bind-address Socket address this node will use for binding its client websocket API. default: `[::]:9000` [env: NYMNODE_ENTRY_BIND_ADDRESS=] - --announce-ws-port Custom announced port for listening for websocket client traffic. If unspecified, the value from the `bind_address` will - be used instead [env: NYMNODE_ENTRY_ANNOUNCE_WS_PORT=] + --announce-ws-port Custom announced port for listening for websocket client traffic. If unspecified, the value from the `bind_address` will be + used instead [env: NYMNODE_ENTRY_ANNOUNCE_WS_PORT=] --announce-wss-port If applicable, announced port for listening for secure websocket client traffic [env: NYMNODE_ENTRY_ANNOUNCE_WSS_PORT=] --enforce-zk-nyms Indicates whether this gateway is accepting only coconut credentials for accessing the mixnet or if it also accepts non-paying clients [env: NYMNODE_ENFORCE_ZK_NYMS=] [possible values: true, false] @@ -71,8 +71,8 @@ Options: generated [env: NYMNODE_MNEMONIC=] --upgrade-mode-attestation-url Endpoint to query to retrieve current upgrade mode attestation. This argument should never be set outside testnets and local networks [env: NYMNODE_UPGRADE_MODE_ATTESTATION_URL=] - --upgrade-mode-attester-public-key Expected public key of the entity signing the published attestation. This argument should never be set outside testnets - and local networks [env: NYMNODE_UPGRADE_MODE_ATTESTER_PUBKEY=] + --upgrade-mode-attester-public-key Expected public key of the entity signing the published attestation. This argument should never be set outside testnets and + local networks [env: NYMNODE_UPGRADE_MODE_ATTESTER_PUBKEY=] --upstream-exit-policy-url Specifies the url for an upstream source of the exit policy used by this node [env: NYMNODE_UPSTREAM_EXIT_POLICY=] --open-proxy Specifies whether this exit node should run in 'open-proxy' mode and thus would attempt to resolve **ANY** request it receives [env: NYMNODE_OPEN_PROXY=] [possible values: true, false] diff --git a/documentation/docs/pages/styles.css b/documentation/docs/pages/styles.css index 1de040d5d1..35664541ac 100644 --- a/documentation/docs/pages/styles.css +++ b/documentation/docs/pages/styles.css @@ -501,3 +501,39 @@ html:not(.dark) .landing-card:hover { html:not(.dark) .landing-card { border-color: var(--border-light) !important; } + +/* ── Landing page mobile responsiveness ── */ + +@media (max-width: 640px) { + .landing-grid { + grid-template-columns: 1fr !important; + overflow: hidden; + } + + /* In single-column mode every card gets a bottom border except the last */ + .landing-grid .landing-card { + border-right: none !important; + border-bottom: 1px solid var(--border) !important; + overflow: hidden; + } + + .landing-grid > a:last-child .landing-card { + border-bottom: none !important; + } + + /* Contain pre animations so they don't blow out the viewport */ + .landing-grid pre, + .landing-sdk-grid pre { + font-size: 0.6rem !important; + overflow: hidden !important; + } + + .landing-sdk-grid { + grid-template-columns: 1fr !important; + } + + .landing-sdk-grid > div:first-child { + padding-right: 0 !important; + margin-bottom: 1.5rem; + } +} From 2fae4414d299e72d8b0c6a66c8bd4ec943d03112 Mon Sep 17 00:00:00 2001 From: import this <97586125+serinko@users.noreply.github.com> Date: Thu, 26 Mar 2026 10:18:32 +0100 Subject: [PATCH 12/23] NTM Update: single port managment tool (#6607) * update ntm * update docs * add table for ports * cherry on the cake * polish ntm * quic cherry - add 4443 --- .../operators/snippets/ports-nym-node.mdx | 16 +- .../snippets/tabs-ports-nym-node.mdx | 79 ++++++++ .../outputs/api-scraping-outputs/time-now.md | 2 +- .../node-api-check-query-help.md | 2 +- .../outputs/command-outputs/nym-api-help.md | 12 +- .../nym-node-cli-install-help.md | 3 +- .../outputs/command-outputs/nym-node-help.md | 12 +- .../command-outputs/nym-node-run-help.md | 176 ++++++++++-------- .../outputs/command-outputs/nymvisor-help.md | 9 +- .../nodes/preliminary-steps/vps-setup.mdx | 32 +--- .../nym-node-setup/network-tunnel-manager.sh | 172 +++++++++++++++-- 11 files changed, 371 insertions(+), 144 deletions(-) create mode 100644 documentation/docs/components/operators/snippets/tabs-ports-nym-node.mdx diff --git a/documentation/docs/components/operators/snippets/ports-nym-node.mdx b/documentation/docs/components/operators/snippets/ports-nym-node.mdx index adc15fc6ab..076a038e1f 100644 --- a/documentation/docs/components/operators/snippets/ports-nym-node.mdx +++ b/documentation/docs/components/operators/snippets/ports-nym-node.mdx @@ -1,4 +1,4 @@ -Open the needed ports for `nym-node` by running these commands: +import { Callout } from 'nextra/components'; ```sh ufw allow 22/tcp # SSH - you're in control of these ports @@ -7,8 +7,16 @@ ufw allow 443/tcp # HTTPS ufw allow 1789/tcp # Nym specific - Mixnet ufw allow 1790/tcp # Nym specific - Verloc ufw allow 8080/tcp # Nym specific - nym-node-api -ufw allow 9000/tcp # Nym Specific - clients port +ufw allow 9000/tcp # Nym specific - clients port ufw allow 9001/tcp # Nym specific - wss port -ufw allow 51822/udp # WireGuard -ufw allow in on nymwg to any port 51830 proto tcp # bandwidth queries/topup - inside the tunnel +ufw allow 41264/tcp # Nym specific - Lewes Protocol registration +ufw allow 51264/udp # Nym specific - Lewes Protocol data ``` + + +Note that these ports were moved to [NTM](/operators/nodes/preliminary-steps/vps-setup#1-download-the-new-ntm) as they are for Gateways only +```sh +# ufw allow 51822/udp # WireGuard +# ufw allow in on nymwg to any port 51830 proto tcp # bandwidth queries/topup - inside the tunnel +``` + \ No newline at end of file diff --git a/documentation/docs/components/operators/snippets/tabs-ports-nym-node.mdx b/documentation/docs/components/operators/snippets/tabs-ports-nym-node.mdx new file mode 100644 index 0000000000..6b553d436e --- /dev/null +++ b/documentation/docs/components/operators/snippets/tabs-ports-nym-node.mdx @@ -0,0 +1,79 @@ +import { MyTab } from 'components/generic-tabs.tsx'; +import { Tabs, Steps } from 'nextra/components'; +import PortsNymNode from 'components/operators/snippets/ports-nym-node.mdx'; + +
+ + +For operators considering to eventually run `nym-node` in a Gateway mode or supporting WireGuard routing, use the tab for Gateways & WireGuard. + +For mixnode only open the needed ports for Nym services using Uncomplicated Firewall (`ufw`) by running these commands: + + + +###### 1. Ensure `ufw` functionality + +- Check if you have `ufw` installed: +```sh +ufw version +``` + +- If it's not installed, install with: +```sh +apt install ufw -y +``` + +- Enable ufw +```sh +ufw enable +``` + +- Check the status of the firewall +```sh +ufw status +``` + +###### 2. Open required ports for Nym services using `ufw` + +- Copy this whole block to open all ports for mixnode + + + +###### 3. Validate opened ports +- Re-check the status of the firewall +```sh +ufw status +``` + + + + + +Gateways (and WireGuard) nodes use [`network-tunnel-manager.sh` (NTM)](https://github.com/nymtech/nym/refs/heads/develop/scripts/nym-node-setup/network-tunnel-manager.sh) tool to open ports for Nym related services, setup and configure Nym networking devices, configure Nym exit policy and run tests. For more info, read the chapter [*Routing configuration*](/operators/nodes/nym-node/configuration#routing-configuration). + +Follow these steps to setup complete routing of your server hosting a Nym node. + + + +###### 1. Download the new NTM + +- Download the latest NTM and make it executable: +```sh +curl -L https://raw.githubusercontent.com/nymtech/nym/refs/heads/develop/scripts/nym-node-setup/network-tunnel-manager.sh -o ./network-tunnel-manager.sh && \ +chmod +x network-tunnel-manager.sh +``` + +###### 2. Update the exit policy + +- To ensure your routing is clean, run: +```sh +./network-tunnel-manager.sh complete_networking_configuration +``` + + + + +
\ No newline at end of file diff --git a/documentation/docs/components/outputs/api-scraping-outputs/time-now.md b/documentation/docs/components/outputs/api-scraping-outputs/time-now.md index cd1794ba8f..f713759556 100644 --- a/documentation/docs/components/outputs/api-scraping-outputs/time-now.md +++ b/documentation/docs/components/outputs/api-scraping-outputs/time-now.md @@ -1 +1 @@ -Tuesday, March 24th 2026, 17:54:17 UTC +Wednesday, March 25th 2026, 13:56:09 UTC diff --git a/documentation/docs/components/outputs/command-outputs/node-api-check-query-help.md b/documentation/docs/components/outputs/command-outputs/node-api-check-query-help.md index 999f3d4a6f..bfa068ddb0 100644 --- a/documentation/docs/components/outputs/command-outputs/node-api-check-query-help.md +++ b/documentation/docs/components/outputs/command-outputs/node-api-check-query-help.md @@ -11,7 +11,7 @@ options: --no_routing_history Display node stats without routing history --no_verloc_metrics Display node stats without verloc metrics -m, --markdown Display results in markdown format - -o, --output [OUTPUT] + -o [OUTPUT], --output [OUTPUT] Save results to file (in current dir or supply with path without filename) ``` diff --git a/documentation/docs/components/outputs/command-outputs/nym-api-help.md b/documentation/docs/components/outputs/command-outputs/nym-api-help.md index da2cedcfe5..1ba192c6e8 100644 --- a/documentation/docs/components/outputs/command-outputs/nym-api-help.md +++ b/documentation/docs/components/outputs/command-outputs/nym-api-help.md @@ -8,8 +8,12 @@ Commands: help Print this message or the help of the given subcommand(s) Options: - -c, --config-env-file Path pointing to an env file that configures the Nym API [env: NYMAPI_CONFIG_ENV_FILE_ARG=] - --no-banner A no-op flag included for consistency with other binaries (and compatibility with nymvisor, oops) [env: NYMAPI_NO_BANNER_ARG=] - -h, --help Print help - -V, --version Print version + -c, --config-env-file + Path pointing to an env file that configures the Nym API [env: NYMAPI_CONFIG_ENV_FILE_ARG=] + --no-banner + A no-op flag included for consistency with other binaries (and compatibility with nymvisor, oops) [env: NYMAPI_NO_BANNER_ARG=] + -h, --help + Print help + -V, --version + Print version ``` diff --git a/documentation/docs/components/outputs/command-outputs/nym-node-cli-install-help.md b/documentation/docs/components/outputs/command-outputs/nym-node-cli-install-help.md index ddc7c867b3..70baf6f736 100644 --- a/documentation/docs/components/outputs/command-outputs/nym-node-cli-install-help.md +++ b/documentation/docs/components/outputs/command-outputs/nym-node-cli-install-help.md @@ -12,7 +12,8 @@ usage: nym-node-cli install [-h] [-V] [-d BRANCH] [-v] options: -h, --help show this help message and exit -V, --version show program's version number and exit - -d, --dev BRANCH Define github branch (default: develop) + -d BRANCH, --dev BRANCH + Define github branch (default: develop) -v, --verbose Show full error tracebacks --mode {mixnode,entry-gateway,exit-gateway} Node mode: 'mixnode', 'entry-gateway', or 'exit- diff --git a/documentation/docs/components/outputs/command-outputs/nym-node-help.md b/documentation/docs/components/outputs/command-outputs/nym-node-help.md index 7eaa0fdaa3..f1af88721d 100644 --- a/documentation/docs/components/outputs/command-outputs/nym-node-help.md +++ b/documentation/docs/components/outputs/command-outputs/nym-node-help.md @@ -12,8 +12,12 @@ Commands: help Print this message or the help of the given subcommand(s) Options: - -c, --config-env-file Path pointing to an env file that configures the nym-node and overrides any preconfigured values [env: NYMNODE_CONFIG_ENV_FILE_ARG=] - --no-banner Flag used for disabling the printed banner in tty [env: NYMNODE_NO_BANNER=] - -h, --help Print help - -V, --version Print version + -c, --config-env-file + Path pointing to an env file that configures the nym-node and overrides any preconfigured values [env: NYMNODE_CONFIG_ENV_FILE_ARG=] + --no-banner + Flag used for disabling the printed banner in tty [env: NYMNODE_NO_BANNER=] + -h, --help + Print help + -V, --version + Print version ``` diff --git a/documentation/docs/components/outputs/command-outputs/nym-node-run-help.md b/documentation/docs/components/outputs/command-outputs/nym-node-run-help.md index c27742a196..1f43cff534 100644 --- a/documentation/docs/components/outputs/command-outputs/nym-node-run-help.md +++ b/documentation/docs/components/outputs/command-outputs/nym-node-run-help.md @@ -4,86 +4,98 @@ Start this nym-node Usage: nym-node run [OPTIONS] Options: - --id Id of the nym-node to use [env: NYMNODE_ID=] [default: default-nym-node] - --config-file Path to a configuration file of this node [env: NYMNODE_CONFIG=] - --accept-operator-terms-and-conditions Explicitly specify whether you agree with the terms and conditions of a nym node operator as defined at - [env: NYMNODE_ACCEPT_OPERATOR_TERMS=] - --deny-init Forbid a new node from being initialised if configuration file for the provided specification doesn't already exist [env: - NYMNODE_DENY_INIT=] - --init-only If this is a brand new nym-node, specify whether it should only be initialised without actually running the subprocesses - [env: NYMNODE_INIT_ONLY=] - --local Flag specifying this node will be running in a local setting [env: NYMNODE_LOCAL=] - --mode [...] Specifies the current mode(s) of this nym-node [env: NYMNODE_MODE=] [possible values: mixnode, entry-gateway, exit-gateway, - exit-providers-only] - --modes Specifies the current mode(s) of this nym-node as a single flag [env: NYMNODE_MODES=] [possible values: mixnode, - entry-gateway, exit-gateway, exit-providers-only] - -w, --write-changes If this node has been initialised before, specify whether to write any new changes to the config file [env: - NYMNODE_WRITE_CONFIG_CHANGES=] - --bonding-information-output Specify output file for bonding information of this nym-node, i.e. its encoded keys. NOTE: the required bonding information - is still a subject to change and this argument should be treated only as a preview of future features [env: - NYMNODE_BONDING_INFORMATION_OUTPUT=] - -o, --output Specify the output format of the bonding information (`text` or `json`) [env: NYMNODE_OUTPUT=] [default: text] [possible - values: text, json] - --public-ips Comma separated list of public ip addresses that will be announced to the nym-api and subsequently to the clients. In - nearly all circumstances, it's going to be identical to the address you're going to use for bonding [env: - NYMNODE_PUBLIC_IPS=] - --hostname Optional hostname associated with this gateway that will be announced to the nym-api and subsequently to the clients [env: - NYMNODE_HOSTNAME=] - --location Optional **physical** location of this node's server. Either full country name (e.g. 'Poland'), two-letter alpha2 (e.g. - 'PL'), three-letter alpha3 (e.g. 'POL') or three-digit numeric-3 (e.g. '616') can be provided [env: NYMNODE_LOCATION=] - --http-bind-address Socket address this node will use for binding its http API. default: `[::]:8080` [env: NYMNODE_HTTP_BIND_ADDRESS=] - --landing-page-assets-path Path to assets directory of custom landing page of this node [env: NYMNODE_HTTP_LANDING_ASSETS=] - --http-access-token An optional bearer token for accessing certain http endpoints. Currently only used for prometheus metrics [env: - NYMNODE_HTTP_ACCESS_TOKEN=] - --expose-system-info Specify whether basic system information should be exposed. default: true [env: NYMNODE_HTTP_EXPOSE_SYSTEM_INFO=] [possible - values: true, false] - --expose-system-hardware Specify whether basic system hardware information should be exposed. default: true [env: - NYMNODE_HTTP_EXPOSE_SYSTEM_HARDWARE=] [possible values: true, false] - --expose-crypto-hardware Specify whether detailed system crypto hardware information should be exposed. default: true [env: - NYMNODE_HTTP_EXPOSE_CRYPTO_HARDWARE=] [possible values: true, false] - --mixnet-bind-address Address this node will bind to for listening for mixnet packets default: `[::]:1789` [env: NYMNODE_MIXNET_BIND_ADDRESS=] - --mixnet-announce-port If applicable, custom port announced in the self-described API that other clients and nodes will use. Useful when the node - is behind a proxy [env: NYMNODE_MIXNET_ANNOUNCE_PORT=] - --nym-api-urls Addresses to nym APIs from which the node gets the view of the network [env: NYMNODE_NYM_APIS=] - --nyxd-urls Addresses to nyxd chain endpoint which the node will use for chain interactions [env: NYMNODE_NYXD=] - --enable-console-logging Specify whether running statistics of this node should be logged to the console [env: NYMNODE_ENABLE_CONSOLE_LOGGING=] - [possible values: true, false] - --wireguard-enabled Specifies whether the wireguard service is enabled on this node [env: NYMNODE_WG_ENABLED=] [possible values: true, false] - --wireguard-bind-address Socket address this node will use for binding its wireguard interface. default: `[::]:51822` [env: - NYMNODE_WG_BIND_ADDRESS=] - --wireguard-tunnel-announced-port Tunnel port announced to external clients wishing to connect to the wireguard interface. Useful in the instances where the - node is behind a proxy [env: NYMNODE_WG_ANNOUNCED_PORT=] - --wireguard-private-network-prefix The prefix denoting the maximum number of the clients that can be connected via Wireguard. The maximum value for IPv4 is 32 - and for IPv6 is 128 [env: NYMNODE_WG_PRIVATE_NETWORK_PREFIX=] - --wireguard-userspace Use userspace implementation of WireGuard (wireguard-go) instead of kernel module. Useful in containerized environments - without kernel WireGuard support [env: NYMNODE_WG_USERSPACE=] [possible values: true, false] - --verloc-bind-address Socket address this node will use for binding its verloc API. default: `[::]:1790` [env: NYMNODE_VERLOC_BIND_ADDRESS=] - --verloc-announce-port If applicable, custom port announced in the self-described API that other clients and nodes will use. Useful when the node - is behind a proxy [env: NYMNODE_VERLOC_ANNOUNCE_PORT=] - --entry-bind-address Socket address this node will use for binding its client websocket API. default: `[::]:9000` [env: - NYMNODE_ENTRY_BIND_ADDRESS=] - --announce-ws-port Custom announced port for listening for websocket client traffic. If unspecified, the value from the `bind_address` will be - used instead [env: NYMNODE_ENTRY_ANNOUNCE_WS_PORT=] - --announce-wss-port If applicable, announced port for listening for secure websocket client traffic [env: NYMNODE_ENTRY_ANNOUNCE_WSS_PORT=] - --enforce-zk-nyms Indicates whether this gateway is accepting only coconut credentials for accessing the mixnet or if it also accepts - non-paying clients [env: NYMNODE_ENFORCE_ZK_NYMS=] [possible values: true, false] - --mnemonic Custom cosmos wallet mnemonic used for zk-nym redemption. If no value is provided, a fresh mnemonic is going to be - generated [env: NYMNODE_MNEMONIC=] - --upgrade-mode-attestation-url Endpoint to query to retrieve current upgrade mode attestation. This argument should never be set outside testnets and - local networks [env: NYMNODE_UPGRADE_MODE_ATTESTATION_URL=] - --upgrade-mode-attester-public-key Expected public key of the entity signing the published attestation. This argument should never be set outside testnets and - local networks [env: NYMNODE_UPGRADE_MODE_ATTESTER_PUBKEY=] - --upstream-exit-policy-url Specifies the url for an upstream source of the exit policy used by this node [env: NYMNODE_UPSTREAM_EXIT_POLICY=] - --open-proxy Specifies whether this exit node should run in 'open-proxy' mode and thus would attempt to resolve **ANY** request it - receives [env: NYMNODE_OPEN_PROXY=] [possible values: true, false] - --lp-control-bind-address Bind address for the TCP LP control traffic. default: `[::]:41264` [env: NYMNODE_LP_CONTROL_BIND_ADDRESS=] - --lp-control-announce-port Custom announced port for listening for the TCP LP control traffic. If unspecified, the value from the - `lp_control_bind_address` will be used instead [env: NYMNODE_LP_CONTROL_ANNOUNCE_PORT=] - --lp-data-bind-address Bind address for the UDP LP data traffic. default: `[::]:51264` [env: NYMNODE_LP_DATA_BIND_ADDRESS=] - --lp-data-announce-port Custom announced port for listening for the UDP LP data traffic. If unspecified, the value from the `lp_data_bind_address` - will be used instead [env: NYMNODE_LP_DATA_ANNOUNCE_PORT=] - --lp-use-mock-ecash Use mock ecash manager for LP testing. WARNING: Only use this for local testing! Never enable in production. When enabled, - the LP listener will accept any credential without blockchain verification [env: NYMNODE_LP_USE_MOCK_ECASH=] [possible - values: true, false] - -h, --help Print help + --id + Id of the nym-node to use [env: NYMNODE_ID=] [default: default-nym-node] + --config-file + Path to a configuration file of this node [env: NYMNODE_CONFIG=] + --accept-operator-terms-and-conditions + Explicitly specify whether you agree with the terms and conditions of a nym node operator as defined at [env: NYMNODE_ACCEPT_OPERATOR_TERMS=] + --deny-init + Forbid a new node from being initialised if configuration file for the provided specification doesn't already exist [env: NYMNODE_DENY_INIT=] + --init-only + If this is a brand new nym-node, specify whether it should only be initialised without actually running the subprocesses [env: NYMNODE_INIT_ONLY=] + --local + Flag specifying this node will be running in a local setting [env: NYMNODE_LOCAL=] + --mode [...] + Specifies the current mode(s) of this nym-node [env: NYMNODE_MODE=] [possible values: mixnode, entry-gateway, exit-gateway, exit-providers-only] + --modes + Specifies the current mode(s) of this nym-node as a single flag [env: NYMNODE_MODES=] [possible values: mixnode, entry-gateway, exit-gateway, exit-providers-only] + -w, --write-changes + If this node has been initialised before, specify whether to write any new changes to the config file [env: NYMNODE_WRITE_CONFIG_CHANGES=] + --bonding-information-output + Specify output file for bonding information of this nym-node, i.e. its encoded keys. NOTE: the required bonding information is still a subject to change and this argument should be treated only as a preview of future features [env: NYMNODE_BONDING_INFORMATION_OUTPUT=] + -o, --output + Specify the output format of the bonding information (`text` or `json`) [env: NYMNODE_OUTPUT=] [default: text] [possible values: text, json] + --public-ips + Comma separated list of public ip addresses that will be announced to the nym-api and subsequently to the clients. In nearly all circumstances, it's going to be identical to the address you're going to use for bonding [env: NYMNODE_PUBLIC_IPS=] + --hostname + Optional hostname associated with this gateway that will be announced to the nym-api and subsequently to the clients [env: NYMNODE_HOSTNAME=] + --location + Optional **physical** location of this node's server. Either full country name (e.g. 'Poland'), two-letter alpha2 (e.g. 'PL'), three-letter alpha3 (e.g. 'POL') or three-digit numeric-3 (e.g. '616') can be provided [env: NYMNODE_LOCATION=] + --http-bind-address + Socket address this node will use for binding its http API. default: `[::]:8080` [env: NYMNODE_HTTP_BIND_ADDRESS=] + --landing-page-assets-path + Path to assets directory of custom landing page of this node [env: NYMNODE_HTTP_LANDING_ASSETS=] + --http-access-token + An optional bearer token for accessing certain http endpoints. Currently only used for prometheus metrics [env: NYMNODE_HTTP_ACCESS_TOKEN=] + --expose-system-info + Specify whether basic system information should be exposed. default: true [env: NYMNODE_HTTP_EXPOSE_SYSTEM_INFO=] [possible values: true, false] + --expose-system-hardware + Specify whether basic system hardware information should be exposed. default: true [env: NYMNODE_HTTP_EXPOSE_SYSTEM_HARDWARE=] [possible values: true, false] + --expose-crypto-hardware + Specify whether detailed system crypto hardware information should be exposed. default: true [env: NYMNODE_HTTP_EXPOSE_CRYPTO_HARDWARE=] [possible values: true, false] + --mixnet-bind-address + Address this node will bind to for listening for mixnet packets default: `[::]:1789` [env: NYMNODE_MIXNET_BIND_ADDRESS=] + --mixnet-announce-port + If applicable, custom port announced in the self-described API that other clients and nodes will use. Useful when the node is behind a proxy [env: NYMNODE_MIXNET_ANNOUNCE_PORT=] + --nym-api-urls + Addresses to nym APIs from which the node gets the view of the network [env: NYMNODE_NYM_APIS=] + --nyxd-urls + Addresses to nyxd chain endpoint which the node will use for chain interactions [env: NYMNODE_NYXD=] + --enable-console-logging + Specify whether running statistics of this node should be logged to the console [env: NYMNODE_ENABLE_CONSOLE_LOGGING=] [possible values: true, false] + --wireguard-enabled + Specifies whether the wireguard service is enabled on this node [env: NYMNODE_WG_ENABLED=] [possible values: true, false] + --wireguard-bind-address + Socket address this node will use for binding its wireguard interface. default: `[::]:51822` [env: NYMNODE_WG_BIND_ADDRESS=] + --wireguard-tunnel-announced-port + Tunnel port announced to external clients wishing to connect to the wireguard interface. Useful in the instances where the node is behind a proxy [env: NYMNODE_WG_ANNOUNCED_PORT=] + --wireguard-private-network-prefix + The prefix denoting the maximum number of the clients that can be connected via Wireguard. The maximum value for IPv4 is 32 and for IPv6 is 128 [env: NYMNODE_WG_PRIVATE_NETWORK_PREFIX=] + --wireguard-userspace + Use userspace implementation of WireGuard (wireguard-go) instead of kernel module. Useful in containerized environments without kernel WireGuard support [env: NYMNODE_WG_USERSPACE=] [possible values: true, false] + --verloc-bind-address + Socket address this node will use for binding its verloc API. default: `[::]:1790` [env: NYMNODE_VERLOC_BIND_ADDRESS=] + --verloc-announce-port + If applicable, custom port announced in the self-described API that other clients and nodes will use. Useful when the node is behind a proxy [env: NYMNODE_VERLOC_ANNOUNCE_PORT=] + --entry-bind-address + Socket address this node will use for binding its client websocket API. default: `[::]:9000` [env: NYMNODE_ENTRY_BIND_ADDRESS=] + --announce-ws-port + Custom announced port for listening for websocket client traffic. If unspecified, the value from the `bind_address` will be used instead [env: NYMNODE_ENTRY_ANNOUNCE_WS_PORT=] + --announce-wss-port + If applicable, announced port for listening for secure websocket client traffic [env: NYMNODE_ENTRY_ANNOUNCE_WSS_PORT=] + --enforce-zk-nyms + Indicates whether this gateway is accepting only coconut credentials for accessing the mixnet or if it also accepts non-paying clients [env: NYMNODE_ENFORCE_ZK_NYMS=] [possible values: true, false] + --mnemonic + Custom cosmos wallet mnemonic used for zk-nym redemption. If no value is provided, a fresh mnemonic is going to be generated [env: NYMNODE_MNEMONIC=] + --upgrade-mode-attestation-url + Endpoint to query to retrieve current upgrade mode attestation. This argument should never be set outside testnets and local networks [env: NYMNODE_UPGRADE_MODE_ATTESTATION_URL=] + --upgrade-mode-attester-public-key + Expected public key of the entity signing the published attestation. This argument should never be set outside testnets and local networks [env: NYMNODE_UPGRADE_MODE_ATTESTER_PUBKEY=] + --upstream-exit-policy-url + Specifies the url for an upstream source of the exit policy used by this node [env: NYMNODE_UPSTREAM_EXIT_POLICY=] + --open-proxy + Specifies whether this exit node should run in 'open-proxy' mode and thus would attempt to resolve **ANY** request it receives [env: NYMNODE_OPEN_PROXY=] [possible values: true, false] + --lp-control-bind-address + Bind address for the TCP LP control traffic. default: `[::]:41264` [env: NYMNODE_LP_CONTROL_BIND_ADDRESS=] + --lp-control-announce-port + Custom announced port for listening for the TCP LP control traffic. If unspecified, the value from the `lp_control_bind_address` will be used instead [env: NYMNODE_LP_CONTROL_ANNOUNCE_PORT=] + --lp-data-bind-address + Bind address for the UDP LP data traffic. default: `[::]:51264` [env: NYMNODE_LP_DATA_BIND_ADDRESS=] + --lp-data-announce-port + Custom announced port for listening for the UDP LP data traffic. If unspecified, the value from the `lp_data_bind_address` will be used instead [env: NYMNODE_LP_DATA_ANNOUNCE_PORT=] + --lp-use-mock-ecash + Use mock ecash manager for LP testing. WARNING: Only use this for local testing! Never enable in production. When enabled, the LP listener will accept any credential without blockchain verification [env: NYMNODE_LP_USE_MOCK_ECASH=] [possible values: true, false] + -h, --help + Print help ``` diff --git a/documentation/docs/components/outputs/command-outputs/nymvisor-help.md b/documentation/docs/components/outputs/command-outputs/nymvisor-help.md index e3c23ff77d..e4124a3ea9 100644 --- a/documentation/docs/components/outputs/command-outputs/nymvisor-help.md +++ b/documentation/docs/components/outputs/command-outputs/nymvisor-help.md @@ -11,7 +11,10 @@ Commands: help Print this message or the help of the given subcommand(s) Options: - -c, --config-env-file Path pointing to an env file that configures the nymvisor and overrides any preconfigured values - -h, --help Print help - -V, --version Print version + -c, --config-env-file + Path pointing to an env file that configures the nymvisor and overrides any preconfigured values + -h, --help + Print help + -V, --version + Print version ``` diff --git a/documentation/docs/pages/operators/nodes/preliminary-steps/vps-setup.mdx b/documentation/docs/pages/operators/nodes/preliminary-steps/vps-setup.mdx index da46321ee3..42a88f9cf4 100644 --- a/documentation/docs/pages/operators/nodes/preliminary-steps/vps-setup.mdx +++ b/documentation/docs/pages/operators/nodes/preliminary-steps/vps-setup.mdx @@ -2,7 +2,7 @@ import { Callout } from 'nextra/components'; import { VarInfo } from 'components/variable-info.tsx'; import { Steps } from 'nextra/components';import { Tabs } from 'nextra/components'; import { MyTab } from 'components/generic-tabs.tsx'; -import PortsNymNode from 'components/operators/snippets/ports-nym-node.mdx'; +import TabsPortsNymNode from 'components/operators/snippets/tabs-ports-nym-node.mdx'; import PortsValidator from 'components/operators/snippets/ports-validator.mdx'; import NymNodeSpecs from 'components/operators/snippets/nym-node-specs.mdx'; import NTPSync from 'components/operators/snippets/ntp-time-sync.mdx' @@ -54,44 +54,20 @@ apt install ufw --fix-missing -###### 3. Configure your firewall using Uncomplicated Firewall (UFW) +###### 3. Configure your firewall -For a `nym-node` or Nyx validator to recieve traffic, you need to open ports on the server. The following commands will allow you to set up a firewall using `ufw`. - -- Check if you have `ufw` installed: -```sh -ufw version -``` -- If it's not installed, install with: -```sh -apt install ufw -y -``` -- Enable ufw -```sh -ufw enable -``` -- Check the status of the firewall -```sh -ufw status -``` - -###### 4. Open all needed ports to have your firewall for `nym-node` working correctly +For a `nym-node` or Nyx validator to recieve traffic, you need to open ports on the server.
nym-node, validator, ]} defaultIndex="0"> - +
-- Re-check the status of the firewall: -```sh -ufw status -``` - For more information about your node's port configuration, check the [port reference table](#ports-reference-table) below. diff --git a/scripts/nym-node-setup/network-tunnel-manager.sh b/scripts/nym-node-setup/network-tunnel-manager.sh index 8faa623047..4bfe031589 100755 --- a/scripts/nym-node-setup/network-tunnel-manager.sh +++ b/scripts/nym-node-setup/network-tunnel-manager.sh @@ -513,7 +513,138 @@ apply_smtps_465_rate_limit() { ############################################################################### -# part 2: wireguard exit policy manager +# part 2: host network firewall for nym services +############################################################################### + +NETWORK_FIREWALL_COMMENT="NYM-NETWORK-FW" + +delete_managed_input_rules() { + local cmd="$1" + + while read -r rule; do + [[ -z "$rule" ]] && continue + local spec + spec="${rule#-A INPUT }" + $cmd -D INPUT $spec 2>/dev/null || true + done < <($cmd -S INPUT | grep -F -- "$NETWORK_FIREWALL_COMMENT" || true) +} + +add_input_port_rule() { + local cmd="$1" + local port="$2" + local protocol="$3" + local iface="${4:-}" + + if [[ -n "$iface" ]]; then + if ! $cmd -C INPUT -i "$iface" -p "$protocol" --dport "$port" -m conntrack --ctstate NEW -m comment --comment "$NETWORK_FIREWALL_COMMENT" -j ACCEPT 2>/dev/null; then + $cmd -A INPUT -i "$iface" -p "$protocol" --dport "$port" -m conntrack --ctstate NEW -m comment --comment "$NETWORK_FIREWALL_COMMENT" -j ACCEPT + ok "added $cmd INPUT $protocol port $port on $iface" + fi + else + if ! $cmd -C INPUT -p "$protocol" --dport "$port" -m conntrack --ctstate NEW -m comment --comment "$NETWORK_FIREWALL_COMMENT" -j ACCEPT 2>/dev/null; then + $cmd -A INPUT -p "$protocol" --dport "$port" -m conntrack --ctstate NEW -m comment --comment "$NETWORK_FIREWALL_COMMENT" -j ACCEPT + ok "added $cmd INPUT $protocol port $port" + fi + fi +} + +configure_network_firewall() { + info "configuring host network firewall for nym node services" + + delete_managed_input_rules iptables + delete_managed_input_rules ip6tables + + local tcp_ports=(22 80 443 1789 1790 8080 9000 9001 41264) + local udp_ports=(4443 51822 51264) + + local port + for port in "${tcp_ports[@]}"; do + add_input_port_rule iptables "$port" tcp + add_input_port_rule ip6tables "$port" tcp + done + + for port in "${udp_ports[@]}"; do + add_input_port_rule iptables "$port" udp + add_input_port_rule ip6tables "$port" udp + done + + add_input_port_rule iptables 51830 tcp "$WG_INTERFACE" + add_input_port_rule ip6tables 51830 tcp "$WG_INTERFACE" + + save_iptables_rules + ok "host network firewall configuration completed" +} + +show_network_firewall_status() { + info "managed host network firewall rules" + echo + info "ipv4 input rules:" + iptables -L INPUT -n -v --line-numbers | grep -E "($NETWORK_FIREWALL_COMMENT|dpt:22|dpt:80|dpt:443|dpt:1789|dpt:1790|dpt:8080|dpt:9000|dpt:9001|dpt:51822|dpt:51830|dpt:41264|dpt:51264)" || true + echo + info "ipv6 input rules:" + ip6tables -L INPUT -n -v --line-numbers | grep -E "($NETWORK_FIREWALL_COMMENT|dpt:22|dpt:80|dpt:443|dpt:1789|dpt:1790|dpt:8080|dpt:9000|dpt:9001|dpt:51822|dpt:51830|dpt:41264|dpt:51264)" || true +} + +test_network_firewall_rules() { + info "testing host network firewall rules" + + local failures=0 + local tcp_ports=(22 80 443 1789 1790 8080 9000 9001 41264) + local udp_ports=(51822 51264) + local port + + for port in "${tcp_ports[@]}"; do + if iptables -C INPUT -p tcp --dport "$port" -m conntrack --ctstate NEW -m comment --comment "$NETWORK_FIREWALL_COMMENT" -j ACCEPT 2>/dev/null; then + ok "ipv4 tcp port $port allowed" + else + error "ipv4 tcp port $port missing" + ((failures++)) + fi + + if ip6tables -C INPUT -p tcp --dport "$port" -m conntrack --ctstate NEW -m comment --comment "$NETWORK_FIREWALL_COMMENT" -j ACCEPT 2>/dev/null; then + ok "ipv6 tcp port $port allowed" + else + error "ipv6 tcp port $port missing" + ((failures++)) + fi + done + + for port in "${udp_ports[@]}"; do + if iptables -C INPUT -p udp --dport "$port" -m conntrack --ctstate NEW -m comment --comment "$NETWORK_FIREWALL_COMMENT" -j ACCEPT 2>/dev/null; then + ok "ipv4 udp port $port allowed" + else + error "ipv4 udp port $port missing" + ((failures++)) + fi + + if ip6tables -C INPUT -p udp --dport "$port" -m conntrack --ctstate NEW -m comment --comment "$NETWORK_FIREWALL_COMMENT" -j ACCEPT 2>/dev/null; then + ok "ipv6 udp port $port allowed" + else + error "ipv6 udp port $port missing" + ((failures++)) + fi + done + + if iptables -C INPUT -i "$WG_INTERFACE" -p tcp --dport 51830 -m conntrack --ctstate NEW -m comment --comment "$NETWORK_FIREWALL_COMMENT" -j ACCEPT 2>/dev/null; then + ok "ipv4 tcp port 51830 allowed on $WG_INTERFACE" + else + error "ipv4 tcp port 51830 missing on $WG_INTERFACE" + ((failures++)) + fi + + if ip6tables -C INPUT -i "$WG_INTERFACE" -p tcp --dport 51830 -m conntrack --ctstate NEW -m comment --comment "$NETWORK_FIREWALL_COMMENT" -j ACCEPT 2>/dev/null; then + ok "ipv6 tcp port 51830 allowed on $WG_INTERFACE" + else + error "ipv6 tcp port 51830 missing on $WG_INTERFACE" + ((failures++)) + fi + + return "$failures" +} + + +############################################################################### +# part 3: wireguard exit policy manager ############################################################################### add_port_rules() { @@ -892,6 +1023,8 @@ show_exit_policy_status() { echo ip6tables -L "$NYM_CHAIN" -n -v 2>/dev/null || echo "ipv6 chain not found" echo + show_network_firewall_status + echo info "ip forwarding:" echo "ipv4: $(cat /proc/sys/net/ipv4/ip_forward 2>/dev/null || echo 0)" echo "ipv6: $(cat /proc/sys/net/ipv6/conf/all/forwarding 2>/dev/null || echo 0)" @@ -942,7 +1075,7 @@ test_exit_policy_connectivity() { ############################################################################### -# part 3: check the firewall setup +# part 4: check the firewall setup ############################################################################### firewall_rule_line() { @@ -1013,21 +1146,15 @@ check_iptables_default_policies() { if [[ -z "${input_policy:-}" ]]; then error "unable to read INPUT default policy (iptables -S INPUT failed?)" issues=1 - elif [[ "${input_policy^^}" != "DROP" ]]; then - error "INPUT default policy is ${input_policy^^}; expected DROP so traffic is only allowed by explicit rules." - issues=1 else - ok "INPUT default policy is DROP" + info "INPUT default policy is ${input_policy^^}" fi if [[ -z "${forward_policy:-}" ]]; then error "unable to read FORWARD default policy (iptables -S FORWARD failed?)" issues=1 - elif [[ "${forward_policy^^}" != "DROP" ]]; then - error "FORWARD default policy is ${forward_policy^^}; expected DROP to ensure traffic only flows via NYM-EXIT rules." - issues=1 else - ok "FORWARD default policy is DROP" + info "FORWARD default policy is ${forward_policy^^}" fi if [[ -z "${output_policy:-}" ]]; then @@ -1050,6 +1177,7 @@ check_firewall_setup() { check_iptables_default_policies || errors=1 check_forward_chain || errors=1 check_nym_exit_chain || errors=1 + test_network_firewall_rules || errors=1 if command -v ip6tables >/dev/null 2>&1; then info "checking ipv6 firewall ordering…" @@ -1062,7 +1190,7 @@ check_firewall_setup() { fi if [[ $errors -ne 0 ]]; then - error "There may be some ordering issues, it is recommended to re-run network-tunnel-manager.sh exit_policy_install after configuring UFW." + error "There may be some ordering issues, it is recommended to re-run network-tunnel-manager.sh exit_policy_install and review the firewall output above." return 1 fi @@ -1072,7 +1200,7 @@ check_firewall_setup() { ############################################################################### -# part 4: full exit policy verification tests +# part 5: full exit policy verification tests ############################################################################### test_port_range_rules() { @@ -1227,6 +1355,9 @@ exit_policy_run_tests() { test_critical_services || ((failed += 1)) ((total += 1)) + test_network_firewall_rules || ((failed += 1)) + ((total += 1)) + if [[ $skip_default -eq 0 ]]; then test_default_reject_rule || ((failed += 1)) ((total += 1)) @@ -1243,7 +1374,7 @@ exit_policy_run_tests() { } ############################################################################### -# part 5: high level workflows +# part 6: high level workflows ############################################################################### nym_tunnel_setup() { @@ -1286,9 +1417,10 @@ exit_policy_install() { } complete_networking_configuration() { - info "starting complete networking configuration: tunnels + exit policy" + info "starting complete networking configuration: tunnels + host firewall + exit policy" nym_tunnel_setup + configure_network_firewall exit_policy_install check_firewall_setup || error "firewall order checks reported problems, please review output" exit_policy_run_tests || error "exit policy tests reported problems, please review output" @@ -1316,6 +1448,12 @@ case "$cmd" in complete_networking_configuration status=$? ;; + + # nym firewall setup + configure_network_firewall) + configure_network_firewall + status=$? + ;; # tunnel manager cmds fetch_ipv6_address_nym_tun) @@ -1403,9 +1541,11 @@ case "$cmd" in usage: $0 [args] high level workflows: - complete_networking_configuration Install tunnel interfaces, setup networking, iptables, wg exit policy & tests - nym_tunnel_setup Install tunnel interfaces & setup networking + complete_networking_configuration Install tunnel interfaces, setup networking, host firewall, iptables, wg exit policy & tests + nym_tunnel_setup Install tunnel interfaces & setup networking + configure_network_firewall Install host firewall rules for nym services exit_policy_install Install and configure wireguard exit policy + tunnel and nat helpers: adjust_ip_forwarding Enable ipv4/ipv6 forwarding via sysctl.d apply_iptables_rules Apply nat/forward rules for ${TUNNEL_INTERFACE} From 68caecff35e81b5462eb83912b0a569b8fca827e Mon Sep 17 00:00:00 2001 From: import this <97586125+serinko@users.noreply.github.com> Date: Thu, 26 Mar 2026 11:02:10 +0100 Subject: [PATCH 13/23] [DOCs]: Release notes v2026.6 stilton (#6606) * operators updates * add headers * Update changelog.mdx * bump up node version * udpate time * edit typos --------- Co-authored-by: Merve <111695676+merve64@users.noreply.github.com> --- .../outputs/api-scraping-outputs/time-now.md | 2 +- .../docs/pages/operators/changelog.mdx | 83 +++++++++++++++++++ .../pages/operators/nodes/nym-node/setup.mdx | 9 +- 3 files changed, 89 insertions(+), 5 deletions(-) diff --git a/documentation/docs/components/outputs/api-scraping-outputs/time-now.md b/documentation/docs/components/outputs/api-scraping-outputs/time-now.md index f713759556..4c4a912a0b 100644 --- a/documentation/docs/components/outputs/api-scraping-outputs/time-now.md +++ b/documentation/docs/components/outputs/api-scraping-outputs/time-now.md @@ -1 +1 @@ -Wednesday, March 25th 2026, 13:56:09 UTC +Thursday, March 26th 2026, 09:28:08 UTC \ No newline at end of file diff --git a/documentation/docs/pages/operators/changelog.mdx b/documentation/docs/pages/operators/changelog.mdx index c4b8e9d37d..23505d5b73 100644 --- a/documentation/docs/pages/operators/changelog.mdx +++ b/documentation/docs/pages/operators/changelog.mdx @@ -57,6 +57,89 @@ This page displays a full list of all the changes during our release cycle from +## `v2026.6-stilton` + +- [Release Binaries](https://github.com/nymtech/nym/releases/tag/nym-binaries-v2026.6-stilton) +- [`nym-node`](nodes/nym-node.mdx) version `1.28.0` + +```sh +nym-node +Binary Name: nym-node +Build Timestamp: 2026-03-25T13:41:43.299729408Z +Build Version: 1.28.0 +Commit SHA: 034346917901bd373dee0ca170b26a9359dbd26d +Commit Date: 2026-03-25T07:47:04.000000000+01:00 +Commit Branch: HEAD +rustc Version: 1.91.1 +rustc Channel: stable +cargo Profile: release +``` + +### Operators Updates & Tools + +New release is here and with it a completely new theme of the docs! + + +**This release introduces implementation of Lewes Protocol (LP). Only nodes upgraded to this version will be able to complete handshake using LP.** + +**Please upgrade soon and follow the section below to ensure smooth functionality of your `nym-node`.** + + +1. **[Network Tunnel Manager (NTM)](#network-tunnel-manager-ntm-updates) is now the single port manager for WireGuard routing and Gateway Nym nodes.** + +2. Lewes protocol ports (`41264/tcp` and `51264/udp`) as well as all the essential ports for mixnet operation (before `ufw`) are now included in NTM +- Follow [these steps](#network-tunnel-manager-ntm-updates) to implement changes if you run any Gateway type of Nym node +- For operators running mixnode mode, make sure to open all ports via `ufw` as [documented here](/operators/nodes/preliminary-steps/vps-setup#3-configure-your-firewall), do *not* use NTM as you nodes do *not* need all exress ports (exit policy) opened + + +#### Network Tunnel Manager (NTM) Updates + + +Nym team is testing an unreleased version of [Gateway Probe](/operators/performance-and-testing/gateway-probe). This new version checks whether the ports opened align with the governance decision about exit policy. If they don't, the nodes will be taken out of Service grant program and [Delegation program](https://nym.com/network/DP). + + +NTM is now changed to be a standalone port manager for servers hosting Nym nodes running in a Gateway mode (with or without WireGuard). Operators of these nodes no longer need to manage mixnet fundamental ports by `ufw` separately, as the NTM will take care of it as well as adjusting the ports according to Nym exit policy. + +**Follow these steps to update the ports setting of your server using NTM.** + + +###### 1. Download the new NTM +- Download the latest NTM and make it executable: +```sh +curl -L https://raw.githubusercontent.com/nymtech/nym/refs/heads/develop/scripts/nym-node-setup/network-tunnel-manager.sh -o ./network-tunnel-manager.sh && \ +chmod +x network-tunnel-manager.sh +``` + +###### 2. Update the exit policy +- To ensure your routing is clean, run: +```sh +./network-tunnel-manager.sh complete_networking_configuration +``` + +Congratulation, your port configuration is up to date, including LP ports. + + + +### Features + +- [Add LP to NS UI](https://github.com/nymtech/nym/pull/6562): Operators can now monitor LP registration status directly from the UI. The gateway list shows post-quantum registration status via emojis (success/failure), and the dashboard view adds LP count columns for success, failure, and untested, making monitoring faster and easier. + +- [Introduce /v3/unstable/nym-nodes/semi-skimmed](https://github.com/nymtech/nym/pull/6499): New API endpoint aggregates LP information for nodes, allowing them to discover each other's LP capabilities and establish shared post-quantum connections efficiently. + +- [Additional ticket for agent](https://github.com/nymtech/nym/pull/6551): Adds an extra ticket so the agent/probe can perform LP tests automatically without additional configuration. + +### Bugfix + +- [LP fixes](https://github.com/nymtech/nym/pull/6601): Improvements for LP registration results and preshared key handling to ensure accurate LP status reporting. + +- [Allow deserialisation of LP data from either snake_case or lowercase](https://github.com/nymtech/nym/pull/6586): Fixes LP data deserialization issues, making LP information robust across different formats. + + +### Refactors & Maintenance + +- [LP improvements](https://github.com/nymtech/nym/pull/6526): Refactors LP handling code and scaffolds internode handshake processing to simplify future maintenance and reduce potential bugs. + + ## `v2026.5-raclette` - [Release Binaries](https://github.com/nymtech/nym/releases/tag/nym-binaries-v2026.5-raclette) diff --git a/documentation/docs/pages/operators/nodes/nym-node/setup.mdx b/documentation/docs/pages/operators/nodes/nym-node/setup.mdx index 8e9e30eff7..6394620273 100644 --- a/documentation/docs/pages/operators/nodes/nym-node/setup.mdx +++ b/documentation/docs/pages/operators/nodes/nym-node/setup.mdx @@ -19,11 +19,12 @@ This documentation page provides a guide on how to set up and run a [NYM NODE](. ## Current version ```sh +nym-node Binary Name: nym-node -Build Timestamp: 2026-03-10T10:14:03.446553605Z -Build Version: 1.27.0 -Commit SHA: 7cccf3cfff448f1b38324f7655930a2b621588c6 -Commit Date: 2026-03-10T10:46:12.000000000+01:00 +Build Timestamp: 2026-03-25T13:41:43.299729408Z +Build Version: 1.28.0 +Commit SHA: 034346917901bd373dee0ca170b26a9359dbd26d +Commit Date: 2026-03-25T07:47:04.000000000+01:00 Commit Branch: HEAD rustc Version: 1.91.1 rustc Channel: stable From e3622075838eefb7c193b4d18e10d65320ad84b0 Mon Sep 17 00:00:00 2001 From: import this <97586125+serinko@users.noreply.github.com> Date: Thu, 26 Mar 2026 12:27:57 +0100 Subject: [PATCH 14/23] [DOCs/operators]: Fix - disable ufw to clean machine conf state (#6620) --- .../outputs/api-scraping-outputs/time-now.md | 2 +- .../docs/pages/operators/changelog.mdx | 21 +++++++++++++++++-- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/documentation/docs/components/outputs/api-scraping-outputs/time-now.md b/documentation/docs/components/outputs/api-scraping-outputs/time-now.md index 4c4a912a0b..3d232e6dc0 100644 --- a/documentation/docs/components/outputs/api-scraping-outputs/time-now.md +++ b/documentation/docs/components/outputs/api-scraping-outputs/time-now.md @@ -1 +1 @@ -Thursday, March 26th 2026, 09:28:08 UTC \ No newline at end of file +Thursday, March 26th 2026, 11:17:59 UTC diff --git a/documentation/docs/pages/operators/changelog.mdx b/documentation/docs/pages/operators/changelog.mdx index 23505d5b73..835784ca34 100644 --- a/documentation/docs/pages/operators/changelog.mdx +++ b/documentation/docs/pages/operators/changelog.mdx @@ -111,14 +111,31 @@ chmod +x network-tunnel-manager.sh ``` ###### 2. Update the exit policy -- To ensure your routing is clean, run: + +- Run NTM to become main port manager (including Nym services), run: ```sh ./network-tunnel-manager.sh complete_networking_configuration ``` -Congratulation, your port configuration is up to date, including LP ports. +###### 3. Disable `ufw` + +Right now your NTM is handling the port management. You can disable `ufw`. + +- We suggest to not uninstall it, just make it innactive for the time being: + +```sh +ufw disable +``` + +###### 4. Re-run NTM + +- NTM is fully omnipotent, re-rerun it again now with `ufw` disabled to ensure clean state: +```sh +./network-tunnel-manager.sh complete_networking_configuration +``` +Congratulation, your port configuration is up to date, including LP ports. ### Features From 6581ebf235cdd6418e38689f550e23e4a3b4bbc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C4=99drzej=20Stuczy=C5=84ski?= Date: Thu, 26 Mar 2026 16:02:19 +0000 Subject: [PATCH 15/23] feat: multiple deposit prices (#6608) * added reduced pricing handling logic * admin methods for setting the whitelist of reduced price accounts * updated client traits * query to get all whitelisted accounts * query for getting detailed deposit statistics * fixes * set initial whitelisted accounts in the migration * stop transferring tokens to the holding account after redemption * stop gateways from creating redemption multisig proposals * make sure credential-proxy uses reduced deposits when available * cargo fmt * update deposit handler to allow EITHER default price or reduced price this will allow non-breaking upgrades of NS and credential proxy * removed use of unstable rust features * rebuilt contract schema * correct license timestamp --- .../bandwidth-controller/src/acquire/mod.rs | 2 +- .../contract_traits/ecash_query_client.rs | 34 +- .../contract_traits/ecash_signing_client.rs | 44 +- .../ecash-contract/src/counters.rs | 7 + .../ecash-contract/src/deposit_statistics.rs | 38 ++ .../ecash-contract/src/error.rs | 22 + .../ecash-contract/src/lib.rs | 2 + .../ecash-contract/src/msg.rs | 48 +- .../ecash-contract/src/reduced_deposit.rs | 16 + .../src/shared_state/nyxd_client.rs | 6 +- .../shared_state/required_deposit_cache.rs | 25 +- .../src/ecash/credential_sender.rs | 560 +----------------- .../src/ecash/helpers.rs | 4 +- .../credential-verification/src/ecash/mod.rs | 2 +- .../src/ecash/state.rs | 72 +-- contracts/Cargo.lock | 27 +- contracts/Cargo.toml | 3 + contracts/ecash/Cargo.toml | 6 +- contracts/ecash/schema/nym-ecash.json | 370 +++++++++++- contracts/ecash/schema/raw/execute.json | 52 +- contracts/ecash/schema/raw/migrate.json | 53 +- contracts/ecash/schema/raw/query.json | 51 +- ...ponse_to_get_all_whitelisted_accounts.json | 59 ++ ...esponse_to_get_default_deposit_amount.json | 24 + .../response_to_get_deposits_statistics.json | 93 +++ ...esponse_to_get_reduced_deposit_amount.json | 34 ++ contracts/ecash/src/contract/helpers.rs | 197 +----- contracts/ecash/src/contract/mod.rs | 252 +++++++- .../ecash/src/contract/queued_migrations.rs | 325 +++++++++- contracts/ecash/src/contract/test.rs | 420 ++++++++++++- contracts/ecash/src/deposit.rs | 27 +- contracts/ecash/src/deposit_stats.rs | 374 ++++++++++++ contracts/ecash/src/helpers.rs | 3 + contracts/ecash/src/lib.rs | 7 +- contracts/ecash/src/multitest.rs | 198 +++++++ contracts/ecash/src/support/mod.rs | 5 - contracts/ecash/src/support/tests.rs | 104 ---- nym-wallet/Cargo.lock | 23 +- 38 files changed, 2529 insertions(+), 1060 deletions(-) create mode 100644 common/cosmwasm-smart-contracts/ecash-contract/src/deposit_statistics.rs create mode 100644 common/cosmwasm-smart-contracts/ecash-contract/src/reduced_deposit.rs create mode 100644 contracts/ecash/schema/raw/response_to_get_all_whitelisted_accounts.json create mode 100644 contracts/ecash/schema/raw/response_to_get_default_deposit_amount.json create mode 100644 contracts/ecash/schema/raw/response_to_get_deposits_statistics.json create mode 100644 contracts/ecash/schema/raw/response_to_get_reduced_deposit_amount.json create mode 100644 contracts/ecash/src/deposit_stats.rs delete mode 100644 contracts/ecash/src/support/mod.rs delete mode 100644 contracts/ecash/src/support/tests.rs diff --git a/common/bandwidth-controller/src/acquire/mod.rs b/common/bandwidth-controller/src/acquire/mod.rs index b6c3aae8cb..51031f814e 100644 --- a/common/bandwidth-controller/src/acquire/mod.rs +++ b/common/bandwidth-controller/src/acquire/mod.rs @@ -34,7 +34,7 @@ where let signing_key = ed25519::PrivateKey::new(&mut rng); let expiration = expiration.unwrap_or_else(ecash_default_expiration_date); - let deposit_amount = client.get_required_deposit_amount().await?; + let deposit_amount = client.get_default_deposit_amount().await?; info!("we'll need to deposit {deposit_amount} to obtain the ticketbook"); let result = client .make_ticketbook_deposit( diff --git a/common/client-libs/validator-client/src/nyxd/contract_traits/ecash_query_client.rs b/common/client-libs/validator-client/src/nyxd/contract_traits/ecash_query_client.rs index 2a799b5da4..2abff233da 100644 --- a/common/client-libs/validator-client/src/nyxd/contract_traits/ecash_query_client.rs +++ b/common/client-libs/validator-client/src/nyxd/contract_traits/ecash_query_client.rs @@ -8,6 +8,7 @@ use crate::nyxd::CosmWasmClient; use async_trait::async_trait; use cosmwasm_std::Coin; use nym_ecash_contract_common::deposit::LatestDepositResponse; +use nym_ecash_contract_common::deposit_statistics::DepositsStatistics; use nym_ecash_contract_common::msg::QueryMsg as EcashQueryMsg; use serde::Deserialize; @@ -17,6 +18,9 @@ pub use nym_ecash_contract_common::blacklist::{ pub use nym_ecash_contract_common::deposit::{ Deposit, DepositData, DepositId, DepositResponse, PagedDepositsResponse, }; +pub use nym_ecash_contract_common::reduced_deposit::{ + WhitelistedAccount, WhitelistedAccountsResponse, +}; #[cfg_attr(target_arch = "wasm32", async_trait(?Send))] #[cfg_attr(not(target_arch = "wasm32"), async_trait)] @@ -42,8 +46,18 @@ pub trait EcashQueryClient { .await } - async fn get_required_deposit_amount(&self) -> Result { - self.query_ecash_contract(EcashQueryMsg::GetRequiredDepositAmount {}) + async fn get_default_deposit_amount(&self) -> Result { + self.query_ecash_contract(EcashQueryMsg::GetDefaultDepositAmount {}) + .await + } + + async fn get_reduced_deposit_amount(&self, address: String) -> Result, NyxdError> { + self.query_ecash_contract(EcashQueryMsg::GetReducedDepositAmount { address }) + .await + } + + async fn get_all_whitelisted_accounts(&self) -> Result { + self.query_ecash_contract(EcashQueryMsg::GetAllWhitelistedAccounts {}) .await } @@ -65,6 +79,11 @@ pub trait EcashQueryClient { self.query_ecash_contract(EcashQueryMsg::GetDepositsPaged { start_after, limit }) .await } + + async fn get_deposits_statistics(&self) -> Result { + self.query_ecash_contract(EcashQueryMsg::GetDepositsStatistics {}) + .await + } } #[cfg_attr(target_arch = "wasm32", async_trait(?Send))] @@ -122,10 +141,17 @@ mod tests { EcashQueryMsg::GetDepositsPaged { limit, start_after } => { client.get_deposits_paged(start_after, limit).ignore() } - EcashQueryMsg::GetRequiredDepositAmount {} => { - client.get_required_deposit_amount().ignore() + EcashQueryMsg::GetDefaultDepositAmount {} => { + client.get_default_deposit_amount().ignore() + } + EcashQueryMsg::GetReducedDepositAmount { address } => { + client.get_reduced_deposit_amount(address).ignore() + } + EcashQueryMsg::GetAllWhitelistedAccounts {} => { + client.get_all_whitelisted_accounts().ignore() } EcashQueryMsg::GetLatestDeposit {} => client.get_latest_deposit().ignore(), + EcashQueryMsg::GetDepositsStatistics {} => client.get_deposits_statistics().ignore(), }; } } diff --git a/common/client-libs/validator-client/src/nyxd/contract_traits/ecash_signing_client.rs b/common/client-libs/validator-client/src/nyxd/contract_traits/ecash_signing_client.rs index 72ba43053b..613ee64d8e 100644 --- a/common/client-libs/validator-client/src/nyxd/contract_traits/ecash_signing_client.rs +++ b/common/client-libs/validator-client/src/nyxd/contract_traits/ecash_signing_client.rs @@ -62,13 +62,47 @@ pub trait EcashSigningClient { new_deposit: Coin, fee: Option, ) -> Result { - let req = EcashExecuteMsg::UpdateDepositValue { + let req = EcashExecuteMsg::UpdateDefaultDepositValue { new_deposit: new_deposit.into(), }; self.execute_ecash_contract(fee, req, "Ecash::UpdateDepositValue".to_string(), vec![]) .await } + async fn set_reduced_deposit_price( + &self, + address: String, + deposit: Coin, + fee: Option, + ) -> Result { + let req = EcashExecuteMsg::SetReducedDepositPrice { + address, + deposit: deposit.into(), + }; + self.execute_ecash_contract( + fee, + req, + "Ecash::SetReducedDepositPrice".to_string(), + vec![], + ) + .await + } + + async fn remove_reduced_deposit_price( + &self, + address: String, + fee: Option, + ) -> Result { + let req = EcashExecuteMsg::RemoveReducedDepositPrice { address }; + self.execute_ecash_contract( + fee, + req, + "Ecash::RemoveReducedDepositPrice".to_string(), + vec![], + ) + .await + } + async fn propose_for_blacklist( &self, public_key: String, @@ -141,9 +175,15 @@ mod tests { .ignore(), ExecuteMsg::RedeemTickets { .. } => unimplemented!(), // no redeem tickets method for the client ExecuteMsg::UpdateAdmin { admin } => client.update_admin(admin, None).ignore(), - ExecuteMsg::UpdateDepositValue { new_deposit } => client + ExecuteMsg::UpdateDefaultDepositValue { new_deposit } => client .update_deposit_value(new_deposit.into(), None) .ignore(), + ExecuteMsg::SetReducedDepositPrice { address, deposit } => client + .set_reduced_deposit_price(address, deposit.into(), None) + .ignore(), + ExecuteMsg::RemoveReducedDepositPrice { address } => { + client.remove_reduced_deposit_price(address, None).ignore() + } }; } } diff --git a/common/cosmwasm-smart-contracts/ecash-contract/src/counters.rs b/common/cosmwasm-smart-contracts/ecash-contract/src/counters.rs index e3b9d01752..f4cd70720b 100644 --- a/common/cosmwasm-smart-contracts/ecash-contract/src/counters.rs +++ b/common/cosmwasm-smart-contracts/ecash-contract/src/counters.rs @@ -6,6 +6,13 @@ use cosmwasm_std::Coin; #[cw_serde] pub struct PoolCounters { + /// Represents the total amount of funds deposited into the contract. pub total_deposited: Coin, + + /// Represents the total amount of funds redeemed from the contract that got transferred into the holding account. pub total_redeemed: Coin, + + /// Represents the total amount of tickets requested to be redeemed from the contract and get moved into the holding account, + /// after that functionality got disabled. + pub tickets_requested_and_not_redeemed: u64, } diff --git a/common/cosmwasm-smart-contracts/ecash-contract/src/deposit_statistics.rs b/common/cosmwasm-smart-contracts/ecash-contract/src/deposit_statistics.rs new file mode 100644 index 0000000000..dc4af4326d --- /dev/null +++ b/common/cosmwasm-smart-contracts/ecash-contract/src/deposit_statistics.rs @@ -0,0 +1,38 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use cosmwasm_schema::cw_serde; +use cosmwasm_std::Coin; +use std::collections::HashMap; + +/// Aggregate statistics about all deposits made through the ecash contract. +#[cw_serde] +pub struct DepositsStatistics { + /// Total number of deposits ever made (at any price tier), + /// derived from the deposit id counter. + pub total_deposits_made: u32, + + /// Total value of all deposits ever made (at any price tier), + /// sourced from `PoolCounters::total_deposited`. + pub total_deposited: Coin, + + /// Number of deposits made at the default (non-reduced) price. + pub total_deposits_made_with_default_price: u32, + + /// Total value deposited at the default price. + pub total_deposited_with_default_price: Coin, + + /// Number of deposits made at any custom (reduced) price, summed across all whitelisted accounts. + pub total_deposits_made_with_custom_price: u32, + + /// Total value deposited at custom prices, summed across all whitelisted accounts. + pub total_deposited_with_custom_price: Coin, + + /// Per-account breakdown of deposit counts for whitelisted addresses. + // note: we use String for addressing due to serialisation incompatibility + pub deposits_made_with_custom_price: HashMap, + + /// Per-account breakdown of deposited amounts for whitelisted addresses. + // note: we use String for addressing due to serialisation incompatibility + pub deposited_with_custom_price: HashMap, +} diff --git a/common/cosmwasm-smart-contracts/ecash-contract/src/error.rs b/common/cosmwasm-smart-contracts/ecash-contract/src/error.rs index 3aec9dd6e7..9fd94ab81a 100644 --- a/common/cosmwasm-smart-contracts/ecash-contract/src/error.rs +++ b/common/cosmwasm-smart-contracts/ecash-contract/src/error.rs @@ -65,4 +65,26 @@ pub enum EcashContractError { #[error("the account blacklisting hasn't been fully implemented yet")] UnimplementedBlacklisting, + + #[error("reduced deposit must use the same denom as the default deposit (expected '{expected}', got '{got}')")] + InvalidReducedDepositDenom { expected: String, got: String }, + + #[error( + "reduced deposit amount ({reduced}) must be strictly less than the default ({default})" + )] + ReducedDepositNotReduced { + reduced: cosmwasm_std::Uint128, + default: cosmwasm_std::Uint128, + }, + + #[error("address '{address}' does not have a custom reduced deposit price set")] + NoReducedDepositPrice { address: String }, + + #[error( + "deposit amount ({amount}) must be at least the ticket book size ({ticket_book_size})" + )] + DepositBelowTicketBookSize { + amount: cosmwasm_std::Uint128, + ticket_book_size: u64, + }, } diff --git a/common/cosmwasm-smart-contracts/ecash-contract/src/lib.rs b/common/cosmwasm-smart-contracts/ecash-contract/src/lib.rs index 3c05433349..5495094d70 100644 --- a/common/cosmwasm-smart-contracts/ecash-contract/src/lib.rs +++ b/common/cosmwasm-smart-contracts/ecash-contract/src/lib.rs @@ -4,10 +4,12 @@ pub mod blacklist; pub mod counters; pub mod deposit; +pub mod deposit_statistics; pub mod error; pub mod event_attributes; pub mod events; pub mod msg; pub mod redeem_credential; +pub mod reduced_deposit; pub use error::EcashContractError; diff --git a/common/cosmwasm-smart-contracts/ecash-contract/src/msg.rs b/common/cosmwasm-smart-contracts/ecash-contract/src/msg.rs index 3341ae0afd..7ba734c294 100644 --- a/common/cosmwasm-smart-contracts/ecash-contract/src/msg.rs +++ b/common/cosmwasm-smart-contracts/ecash-contract/src/msg.rs @@ -9,6 +9,10 @@ use crate::blacklist::{BlacklistedAccountResponse, PagedBlacklistedAccountRespon #[cfg(feature = "schema")] use crate::deposit::{DepositResponse, LatestDepositResponse, PagedDepositsResponse}; #[cfg(feature = "schema")] +use crate::deposit_statistics::DepositsStatistics; +#[cfg(feature = "schema")] +use crate::reduced_deposit::WhitelistedAccountsResponse; +#[cfg(feature = "schema")] use cosmwasm_schema::QueryResponses; #[cw_serde] @@ -42,10 +46,25 @@ pub enum ExecuteMsg { admin: String, }, - UpdateDepositValue { + #[serde(alias = "update_deposit_value")] + UpdateDefaultDepositValue { new_deposit: Coin, }, + /// Set (or overwrite) a reduced deposit price for a specific address. + /// Only callable by the contract admin. + SetReducedDepositPrice { + address: String, + deposit: Coin, + }, + + /// Remove the reduced deposit price for a specific address, reverting them to + /// the default price. Returns an error if the address has no custom price set. + /// Only callable by the contract admin. + RemoveReducedDepositPrice { + address: String, + }, + // TODO: properly implement ProposeToBlacklist { public_key: String, @@ -68,7 +87,15 @@ pub enum QueryMsg { }, #[cfg_attr(feature = "schema", returns(Coin))] - GetRequiredDepositAmount {}, + #[serde(alias = "get_required_deposit_amount")] + #[serde(alias = "GetRequiredDepositAmount")] + GetDefaultDepositAmount {}, + + #[cfg_attr(feature = "schema", returns(Option))] + GetReducedDepositAmount { address: String }, + + #[cfg_attr(feature = "schema", returns(WhitelistedAccountsResponse))] + GetAllWhitelistedAccounts {}, #[cfg_attr(feature = "schema", returns(DepositResponse))] GetDeposit { deposit_id: u32 }, @@ -81,7 +108,22 @@ pub enum QueryMsg { limit: Option, start_after: Option, }, + + #[cfg_attr(feature = "schema", returns(DepositsStatistics))] + GetDepositsStatistics {}, } #[cw_serde] -pub struct MigrateMsg {} +pub struct MigrateMsg { + /// Initial set of whitelisted accounts with their reduced deposit prices. + /// Each entry is validated and stored during migration. + pub initial_whitelist: Vec, +} + +/// An address and its reduced deposit price, used when seeding the whitelist +/// via migration. +#[cw_serde] +pub struct WhitelistedDeposit { + pub address: String, + pub deposit: Coin, +} diff --git a/common/cosmwasm-smart-contracts/ecash-contract/src/reduced_deposit.rs b/common/cosmwasm-smart-contracts/ecash-contract/src/reduced_deposit.rs new file mode 100644 index 0000000000..95f7355e24 --- /dev/null +++ b/common/cosmwasm-smart-contracts/ecash-contract/src/reduced_deposit.rs @@ -0,0 +1,16 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{Addr, Coin}; + +#[cw_serde] +pub struct WhitelistedAccount { + pub address: Addr, + pub deposit: Coin, +} + +#[cw_serde] +pub struct WhitelistedAccountsResponse { + pub whitelisted_accounts: Vec, +} diff --git a/common/credential-proxy/src/shared_state/nyxd_client.rs b/common/credential-proxy/src/shared_state/nyxd_client.rs index 6adaf3d600..0007e24a7b 100644 --- a/common/credential-proxy/src/shared_state/nyxd_client.rs +++ b/common/credential-proxy/src/shared_state/nyxd_client.rs @@ -6,7 +6,7 @@ use crate::helpers::LockTimer; use nym_ecash_contract_common::msg::ExecuteMsg; use nym_validator_client::nyxd::contract_traits::NymContractsProvider; use nym_validator_client::nyxd::cosmwasm_client::types::ExecuteResult; -use nym_validator_client::nyxd::{Coin, Config, CosmWasmClient, NyxdClient}; +use nym_validator_client::nyxd::{AccountId, Coin, Config, CosmWasmClient, NyxdClient}; use nym_validator_client::{DirectSigningHttpRpcNyxdClient, nyxd}; use std::ops::Deref; use std::sync::Arc; @@ -50,6 +50,10 @@ impl ChainClient { Ok(ChainClient(Arc::new(RwLock::new(client)))) } + pub async fn address(&self) -> AccountId { + self.0.read().await.address() + } + pub async fn query_chain(&self) -> ChainReadPermit<'_> { let _acquire_timer = LockTimer::new("acquire chain query permit"); self.0.read().await diff --git a/common/credential-proxy/src/shared_state/required_deposit_cache.rs b/common/credential-proxy/src/shared_state/required_deposit_cache.rs index fa8094cb53..1aa151e73e 100644 --- a/common/credential-proxy/src/shared_state/required_deposit_cache.rs +++ b/common/credential-proxy/src/shared_state/required_deposit_cache.rs @@ -8,6 +8,7 @@ use nym_validator_client::nyxd::contract_traits::EcashQueryClient; use std::sync::Arc; use time::OffsetDateTime; use tokio::sync::RwLock; +use tracing::{info, warn}; pub struct CachedDeposit { valid_until: OffsetDateTime, @@ -56,13 +57,29 @@ impl RequiredDepositCache { // update cache drop(read_guard); + + let address = chain_client.address().await; + info!("checking deposit required by {address}"); let mut write_guard = self.inner.write().await; - let deposit_amount = chain_client - .query_chain() - .await - .get_required_deposit_amount() + + let read_permit = chain_client.query_chain().await; + let reduced = read_permit + .get_reduced_deposit_amount(address.to_string()) .await?; + let deposit_amount = match reduced { + Some(reduced) => { + info!("we're permitted to use reduced price"); + reduced + } + None => { + warn!( + "using default deposit value {address} is not whitelisted for price reduction" + ); + read_permit.get_default_deposit_amount().await? + } + }; + let nym_coin: Coin = deposit_amount.into(); write_guard.update(nym_coin.clone()); diff --git a/common/credential-verification/src/ecash/credential_sender.rs b/common/credential-verification/src/ecash/credential_sender.rs index 85c54b01ad..866e523a29 100644 --- a/common/credential-verification/src/ecash/credential_sender.rs +++ b/common/credential-verification/src/ecash/credential_sender.rs @@ -3,25 +3,19 @@ use crate::Error; use crate::ecash::error::EcashTicketError; -use crate::ecash::helpers::for_each_api_concurrent; use crate::ecash::state::SharedState; use cosmwasm_std::Fraction; use cw_utils::ThresholdResponse; use futures::channel::mpsc::UnboundedReceiver; use futures::{Stream, StreamExt}; -use nym_api_requests::constants::MIN_BATCH_REDEMPTION_DELAY; -use nym_api_requests::ecash::models::{BatchRedeemTicketsBody, VerifyEcashTicketBody}; +use nym_api_requests::ecash::models::VerifyEcashTicketBody; use nym_credentials_interface::Bandwidth; use nym_credentials_interface::{ClientTicket, TicketType}; use nym_validator_client::EcashApiClient; use nym_validator_client::coconut::EcashApiError; -use nym_validator_client::nym_api::{EpochId, NymApiClientExt}; +use nym_validator_client::nym_api::NymApiClientExt; use nym_validator_client::nyxd::AccountId; -use nym_validator_client::nyxd::contract_traits::{ - EcashSigningClient, MultisigQueryClient, MultisigSigningClient, PagedMultisigQueryClient, -}; -use nym_validator_client::nyxd::cosmwasm_client::ContractResponseData; -use nym_validator_client::nyxd::cw3::Status; +use nym_validator_client::nyxd::contract_traits::MultisigQueryClient; use si_scale::helpers::bibytes2; use std::collections::{HashMap, HashSet}; use std::ops::Deref; @@ -31,22 +25,6 @@ use tokio::sync::{Mutex, RwLockReadGuard}; use tokio::time::{Duration, Instant, interval_at}; use tracing::{debug, error, info, instrument, trace, warn}; -enum ProposalResult { - Executed, - Rejected, - Pending, -} - -impl ProposalResult { - fn is_pending(&self) -> bool { - matches!(self, ProposalResult::Pending) - } - - fn is_rejected(&self) -> bool { - matches!(self, ProposalResult::Rejected) - } -} - struct PendingVerification { ticket: ClientTicket, @@ -68,43 +46,6 @@ impl PendingVerification { } } -struct PendingRedemptionVote { - proposal_id: u64, - digest: Vec, - included_serial_numbers: Vec>, - epoch_id: EpochId, - - // vec of node ids of apis that haven't sent a valid response - pending: Vec, -} - -impl PendingRedemptionVote { - fn new( - proposal_id: u64, - digest: Vec, - included_serial_numbers: Vec>, - epoch_id: EpochId, - pending: Vec, - ) -> Self { - PendingRedemptionVote { - proposal_id, - digest, - included_serial_numbers, - epoch_id, - pending, - } - } - - fn to_request_body(&self, gateway_cosmos_addr: AccountId) -> BatchRedeemTicketsBody { - BatchRedeemTicketsBody::new( - self.digest.clone(), - self.proposal_id, - self.included_serial_numbers.clone(), - gateway_cosmos_addr, - ) - } -} - pub struct CredentialHandlerConfig { /// Specifies the multiplier for revoking a malformed/double-spent ticket /// (if it has to go all the way to the nym-api for verification) @@ -132,7 +73,6 @@ pub struct CredentialHandler { ticket_receiver: UnboundedReceiver, shared_state: SharedState, pending_tickets: Vec, - pending_redemptions: Vec, } impl CredentialHandler { @@ -184,75 +124,6 @@ impl CredentialHandler { Ok(pending) } - async fn rebuild_pending_votes( - shared_state: &SharedState, - ) -> Result, EcashTicketError> { - // 1. get all tickets that were not fully verified - let unverified = shared_state.storage.get_all_unresolved_proposals().await?; - let mut pending = Vec::with_capacity(unverified.len()); - - let epoch_id = shared_state.current_epoch_id().await?; - let apis = shared_state - .api_clients(epoch_id) - .await? - .iter() - .map(|s| (s.cosmos_address.to_string(), s.node_id)) - .collect::>(); - - for proposal_id in unverified { - // get all of the votes - let votes = shared_state - .start_query() - .await - .get_all_votes(proposal_id as u64) - .await - .map_err(EcashTicketError::chain_query_failure)? - .into_iter() - .map(|v| v.voter) - .collect::>(); - - let mut missing_votes = Vec::new(); - - // see who hasn't voted - for (api_address, api_id) in &apis { - // for each signer, check if they have actually voted; if not, that's the missing guy - if !votes.contains(api_address) { - missing_votes.push(*api_id) - } - } - - // attempt to rebuild SN and digest from the proposal info + storage data - let proposal_info = shared_state - .start_query() - .await - .query_proposal(proposal_id as u64) - .await - .map_err(EcashTicketError::chain_query_failure)?; - - let tickets = shared_state - .storage - .get_all_proposed_tickets_with_sn(proposal_id as u32) - .await?; - let digest = - BatchRedeemTicketsBody::make_digest(tickets.iter().map(|t| &t.serial_number)); - let encoded_digest = bs58::encode(&digest).into_string(); - if encoded_digest != proposal_info.description { - error!("the lost proposal {proposal_id} does not have a matching digest!"); - continue; - } - - pending.push(PendingRedemptionVote { - proposal_id: proposal_id as u64, - digest, - included_serial_numbers: tickets.into_iter().map(|t| t.serial_number).collect(), - epoch_id, - pending: missing_votes, - }) - } - - Ok(pending) - } - pub(crate) async fn new( config: CredentialHandlerConfig, ticket_receiver: UnboundedReceiver, @@ -276,51 +147,15 @@ impl CredentialHandler { // on startup read pending credentials and api responses from the storage let pending_tickets = Self::rebuild_pending_tickets(&shared_state).await?; - // on startup read pending proposals from the storage - // then reconstruct the votes by querying the multisig contract for votes on those proposals - // digest from the description and count from the message - let pending_redemptions = Self::rebuild_pending_votes(&shared_state).await?; - Ok(CredentialHandler { config, multisig_threshold, ticket_receiver, shared_state, pending_tickets, - pending_redemptions, }) } - // the argument is temporary as we'll be reading from the storage - async fn create_redemption_proposal( - &self, - commitment: &[u8], - number_of_tickets: u16, - ) -> Result { - let res = self - .shared_state - .start_tx() - .await - .request_ticket_redemption( - bs58::encode(commitment).into_string(), - number_of_tickets, - None, - ) - .await - .map_err(|source| EcashTicketError::RedemptionProposalCreationFailure { source })?; - - // that one is quite tricky because proposal exists on chain, but we didn't get the id... - // but it should be quite impossible to ever reach this unless we make breaking changes - let proposal_id = res - .parse_singleton_u64_contract_data() - .inspect_err(|err| error!("reached seemingly impossible error! could not recover the redemption proposal id: {err}")) - .map_err(|source| EcashTicketError::ProposalIdParsingFailure { source })?; - - info!("created redemption proposal {proposal_id} to redeem {number_of_tickets} tickets"); - - Ok(proposal_id) - } - /// Attempt to send ticket verification request to the provided ecash verifier. async fn verify_ticket( &self, @@ -522,42 +357,7 @@ impl CredentialHandler { async fn resolve_pending(&mut self) -> Result<(), EcashTicketError> { let mut still_failing = Vec::new(); - // 1. attempt to resolve all pending proposals - while let Some(mut pending) = self.pending_redemptions.pop() { - match self.try_resolve_pending_proposal(&mut pending, None).await { - Ok(resolution) => { - if resolution.is_pending() { - warn!( - "still failed to reach quorum for proposal {}. apis: {:?} haven't responded. we'll retry later", - pending.proposal_id, pending.pending - ); - still_failing.push(pending); - } else { - self.shared_state - .storage - .clear_post_proposal_data( - pending.proposal_id as u32, - OffsetDateTime::now_utc(), - resolution.is_rejected(), - ) - .await?; - } - } - Err(err) => { - error!( - "experienced internal error when attempting to resolve pending proposal: {err}" - ); - // make sure to update internal state to not lose any data - self.pending_redemptions.push(pending); - self.pending_redemptions.append(&mut still_failing); - return Err(err); - } - } - } - - let mut still_failing = Vec::new(); - - // 2. attempt to verify the remaining tickets + // 1. attempt to verify the remaining tickets while let Some(mut pending) = self.pending_tickets.pop() { // possible optimisation: if there's a lot of pending tickets, pre-emptively grab locks for api_clients match self @@ -595,362 +395,14 @@ impl CredentialHandler { Ok(()) } - /// Attempt to send batch redemption request to the provided ecash verifier. - async fn redeem_tickets( - &self, - proposal_id: u64, - request: &BatchRedeemTicketsBody, - client: &EcashApiClient, - ) -> Result { - match client.api_client.batch_redeem_ecash_tickets(request).await { - Ok(res) => { - let accepted = if res.proposal_accepted { - trace!("{client} has accepted proposal {proposal_id}"); - true - } else { - warn!("{client} has rejected proposal {proposal_id}"); - false - }; - - Ok(accepted) - } - Err(err) => { - error!( - "failed to send proposal {proposal_id} for redemption vote to ecash signer '{client}': {err}. if we don't reach quorum, we'll retry later" - ); - Ok(false) - } - } - } - - async fn try_execute_proposal(&self, proposal_id: u64) -> Result<(), EcashTicketError> { - self.shared_state - .start_tx() - .await - .execute_proposal(proposal_id, None) - .await - .map_err( - |source| EcashTicketError::RedemptionProposalExecutionFailure { - proposal_id, - source, - }, - )?; - Ok(()) - } - - async fn get_proposal_status(&self, proposal_id: u64) -> Result { - Ok(self - .shared_state - .start_query() - .await - .query_proposal(proposal_id) - .await - .map_err(EcashTicketError::chain_query_failure)? - .status) - } - - async fn try_finalize_proposal( - &self, - proposal_id: u64, - ) -> Result { - match self.get_proposal_status(proposal_id).await? { - Status::Pending => { - // the voting hasn't even begun! - error!("impossible case! the proposal {proposal_id} is still pending"); - Ok(ProposalResult::Pending) - } - Status::Open => { - debug!("proposal {proposal_id} is still open and needs more votes"); - Ok(ProposalResult::Pending) - } - Status::Rejected => { - warn!("proposal {proposal_id} has been rejected"); - Ok(ProposalResult::Rejected) - } - Status::Passed => { - info!( - "proposal {proposal_id} has already been passed - we just need to execute it" - ); - self.try_execute_proposal(proposal_id).await?; - info!("executed proposal {proposal_id}"); - Ok(ProposalResult::Executed) - } - Status::Executed => { - info!("proposal {proposal_id} has already been executed - nothing to do!"); - Ok(ProposalResult::Executed) - } - } - } - - async fn try_resolve_pending_proposal( - &self, - pending: &mut PendingRedemptionVote, - api_clients: Option>>, - ) -> Result { - let proposal_id = pending.proposal_id; - - info!( - "attempting to resolve pending redemption proposal {proposal_id} to redeem {} tickets", - pending.included_serial_numbers.len() - ); - - // check if the proposal still needs more votes from the apis - let result = self.try_finalize_proposal(proposal_id).await?; - if !result.is_pending() { - return Ok(result); - } - - let api_clients = match api_clients { - Some(clients) => clients, - None => self.shared_state.api_clients(pending.epoch_id).await?, - }; - - let redemption_request = pending.to_request_body(self.shared_state.address.clone()); - - // TODO: optimisation: tell other apis they can purge our tickets even if they haven't voted - - let total = api_clients.len(); - let api_failures = Mutex::new(Vec::new()); - let rejected = AtomicUsize::new(0); - - for_each_api_concurrent(&api_clients, &pending.pending, |ecash_client| async { - // errors are only returned on hard, storage, failures - match self - .redeem_tickets(pending.proposal_id, &redemption_request, ecash_client) - .await - { - Err(err) => { - error!("internal failure. could not proceed with ticket redemption: {err}"); - api_failures.lock().await.push(ecash_client.node_id); - } - Ok(false) => { - rejected.fetch_add(1, Ordering::SeqCst); - } - _ => {} - } - }) - .await; - - let api_failures = api_failures.into_inner(); - let num_failures = api_failures.len(); - pending.pending = api_failures; - - let rejected = rejected.into_inner(); - let rejected_ratio = rejected as f32 / total as f32; - let rejected_perc = rejected_ratio * 100.; - if rejected_ratio >= (1. - self.multisig_threshold) { - error!( - "{rejected_perc:.2}% of signers rejected proposal {proposal_id}. we won't be able to execute it" - ); - // no need to query the chain as with so many rejections it's impossible it has passed. - return Ok(ProposalResult::Rejected); - } - - let accepted_ratio = (total - rejected - num_failures) as f32 / total as f32; - let accepted_perc = accepted_ratio * 100.; - match accepted_ratio { - n if n < self.multisig_threshold => { - error!( - "less than 2/3 of signers ({accepted_perc:.2}%) accepted proposal {proposal_id}. we're not yet be able to execute it to get funds out" - ); - return Ok(ProposalResult::Pending); - } - n if n < self.config.minimum_api_quorum => { - warn!( - "the system seems to be a bit unstable: less than 80%, but more than 67% of signers ({accepted_perc:.2}%) accepted proposal {proposal_id}" - ); - } - _ => { - trace!("{accepted_perc:.2}% of signers accepted proposal {proposal_id}"); - } - } - - // attempt to execute the proposal if it reached the required threshold - self.try_finalize_proposal(proposal_id).await - } - - async fn maybe_redeem_tickets(&mut self) -> Result<(), EcashTicketError> { - if !self.pending_tickets.is_empty() { - return Err(EcashTicketError::PendingTickets); - } - - let latest_stored = self.shared_state.storage.latest_proposal().await?; - - // check if we have already created the proposal but crashed before persisting it in the db - // - // if we have some persisted proposals in storage, try to see if there's anything more recent on chain - // (i.e. the missing proposal) - // if not (i.e. this would have been our first) check the latest page of proposals. - // while this is not ideal, realistically speaking we probably crashed few minutes ago - // and worst case scenario we'll just recreate the proposal instead - // - // LIMITATION: if MULTIPLE proposals got created in between, well. though luck. - let latest_on_chain = if let Some(latest_stored) = &latest_stored { - // those are sorted in ASCENDING way - self.shared_state - .proposals_since(latest_stored.proposal_id as u64) - .await? - .pop() - } else { - // but those are DESCENDING - self.shared_state - .last_proposal_page() - .await? - .first() - .cloned() - }; - - let now = OffsetDateTime::now_utc(); - - let prior_proposal = match (&latest_stored, latest_on_chain) { - (None, None) => { - // we haven't created any proposals before - trace!("this could be our first redemption proposal"); - None - } - (Some(stored), None) => { - if stored.created_at + MIN_BATCH_REDEMPTION_DELAY > now { - trace!("too soon to create new redemption proposal"); - return Ok(()); - } - None - } - (_, Some(on_chain)) => { - warn!( - "we seem to have crashed after creating proposal, but before persisting it onto disk!" - ); - - Some(on_chain) - } - }; - - // technically we could have been just caching all of those serial numbers as we verify tickets, - // but given how infrequently we call this, there's no point in wasting this memory - let verified_tickets = self - .shared_state - .storage - .get_all_verified_tickets_with_sn() - .await?; - - // TODO: somehow simplify that nasty nested if - if verified_tickets.len() < self.config.minimum_redemption_tickets { - // bypass the number of tickets check if we're about to lose our rewards due to expiration - if let Some(latest_stored) = latest_stored { - if latest_stored.created_at + self.config.maximum_time_between_redemption < now { - {} - } else { - debug!( - "we only have {} verified tickets. there's no point in creating a redemption request yet. (we need at least {} (configurable))", - verified_tickets.len(), - self.config.minimum_redemption_tickets - ); - return Ok(()); - } - } else { - // first proposal - debug!( - "we only have {} verified tickets. there's no point in creating a redemption request yet. (we need at least {} (configurable))", - verified_tickets.len(), - self.config.minimum_redemption_tickets - ); - return Ok(()); - } - } - - // this should have been ensured when querying - assert!(verified_tickets.len() <= u16::MAX as usize); - - let digest = - BatchRedeemTicketsBody::make_digest(verified_tickets.iter().map(|t| &t.serial_number)); - let encoded_digest = bs58::encode(&digest).into_string(); - - let prior_proposal_id = if let Some(prior_proposal) = prior_proposal { - if prior_proposal.description == encoded_digest { - info!("we have already created proposal for those tickets"); - Some(prior_proposal.id) - } else { - warn!( - "our missed proposal seem to have been for different tickets - abandoning it" - ); - None - } - } else { - None - }; - - // if the proposal has already existed on chain, do use it. otherwise create a new one - let proposal_id = if let Some(prior) = prior_proposal_id { - prior - } else { - self.create_redemption_proposal(&digest, verified_tickets.len() as u16) - .await? - }; - - if proposal_id > u32::MAX as u64 { - // realistically will we ever reach it? no. - panic!( - "we have created more than {} proposals. we can't handle that.", - u32::MAX - ) - } - - self.shared_state - .storage - .insert_redemption_proposal( - &verified_tickets, - proposal_id as u32, - OffsetDateTime::now_utc(), - ) - .await?; - - let current_epoch = self.shared_state.current_epoch_id().await?; - let api_clients = self.shared_state.api_clients(current_epoch).await?; - let ids = api_clients.iter().map(|c| c.node_id).collect(); - let mut pending = PendingRedemptionVote::new( - proposal_id, - digest, - verified_tickets - .into_iter() - .map(|t| t.serial_number) - .collect(), - current_epoch, - ids, - ); - - let resolution = self - .try_resolve_pending_proposal(&mut pending, Some(api_clients)) - .await?; - if resolution.is_pending() { - warn!( - "failed to reach quorum for proposal {proposal_id}. apis: {:?} haven't responded. we'll retry later", - pending.pending - ); - self.pending_redemptions.push(pending); - } else { - self.shared_state - .storage - .clear_post_proposal_data( - proposal_id as u32, - OffsetDateTime::now_utc(), - resolution.is_rejected(), - ) - .await?; - } - - Ok(()) - } - async fn periodic_operations(&mut self) -> Result<(), EcashTicketError> { trace!( - "attempting to resolve all pending operations -> tickets that are waiting for verification and possibly redemption" + "attempting to resolve all pending operations -> tickets that are waiting for verification" ); - // 1. retry all operations that have failed in the past: verification requests and pending redemption + // retry the pending verification requests that have failed before self.resolve_pending().await?; - // 2. if applicable, attempt to redeem all newly verified tickets - self.maybe_redeem_tickets().await?; - Ok(()) } diff --git a/common/credential-verification/src/ecash/helpers.rs b/common/credential-verification/src/ecash/helpers.rs index 9d767fba6c..ca57b95b65 100644 --- a/common/credential-verification/src/ecash/helpers.rs +++ b/common/credential-verification/src/ecash/helpers.rs @@ -7,7 +7,7 @@ use std::future::Future; use std::ops::Deref; use tokio::sync::RwLockReadGuard; -pub(crate) fn apis_stream<'a>( +pub fn apis_stream<'a>( // if needed we could make this argument more generic to accept either locks or iterators, etc. all_clients: &'a RwLockReadGuard<'a, Vec>, filter_by_id: &'a [u64], @@ -22,7 +22,7 @@ pub(crate) fn apis_stream<'a>( ) } -pub(crate) async fn for_each_api_concurrent<'a, F, Fut>( +pub async fn for_each_api_concurrent<'a, F, Fut>( all_clients: &'a RwLockReadGuard<'a, Vec>, filter_by_id: &'a [u64], f: F, diff --git a/common/credential-verification/src/ecash/mod.rs b/common/credential-verification/src/ecash/mod.rs index c87b86030d..255215b27f 100644 --- a/common/credential-verification/src/ecash/mod.rs +++ b/common/credential-verification/src/ecash/mod.rs @@ -20,7 +20,7 @@ use tracing::error; pub mod credential_sender; pub mod error; -mod helpers; +pub mod helpers; mod state; pub mod traits; diff --git a/common/credential-verification/src/ecash/state.rs b/common/credential-verification/src/ecash/state.rs index 78cabf7a95..888439b628 100644 --- a/common/credential-verification/src/ecash/state.rs +++ b/common/credential-verification/src/ecash/state.rs @@ -3,17 +3,12 @@ use crate::Error; use crate::ecash::error::EcashTicketError; -use cosmwasm_std::{CosmosMsg, WasmMsg, from_json}; use nym_credentials_interface::VerificationKeyAuth; -use nym_ecash_contract_common::msg::ExecuteMsg; use nym_gateway_storage::traits::BandwidthGatewayStorage; use nym_validator_client::coconut::all_ecash_api_clients; use nym_validator_client::nym_api::EpochId; use nym_validator_client::nyxd::AccountId; -use nym_validator_client::nyxd::contract_traits::{ - DkgQueryClient, MultisigQueryClient, NymContractsProvider, -}; -use nym_validator_client::nyxd::cw3::ProposalResponse; +use nym_validator_client::nyxd::contract_traits::{DkgQueryClient, NymContractsProvider}; use nym_validator_client::{DirectSigningHttpRpcNyxdClient, EcashApiClient}; use std::collections::BTreeMap; use std::ops::Deref; @@ -77,53 +72,6 @@ impl SharedState { Ok(this) } - fn created_redemption_proposal(&self, proposal: &ProposalResponse) -> bool { - let Some(msg) = proposal.msgs.first() else { - return false; - }; - let CosmosMsg::Wasm(WasmMsg::Execute { msg, .. }) = msg else { - return false; - }; - let Ok(ExecuteMsg::RedeemTickets { gw, .. }) = from_json(msg) else { - return false; - }; - - gw == self.address.as_ref() - } - - /// retrieve all redemption proposals made by this gateway since, but excluding, the provided id - pub(crate) async fn proposals_since( - &self, - proposal_id: u64, - ) -> Result, EcashTicketError> { - Ok(self - .start_query() - .await - .list_proposals(Some(proposal_id), None) - .await - .map_err(EcashTicketError::chain_query_failure)? - .proposals - .into_iter() - .filter(|p| self.created_redemption_proposal(p)) - .collect()) - } - - /// retrieve all redemption proposals made by this gateway that are available on the last page of the query - pub(crate) async fn last_proposal_page( - &self, - ) -> Result, EcashTicketError> { - Ok(self - .start_query() - .await - .reverse_proposals(None, None) - .await - .map_err(EcashTicketError::chain_query_failure)? - .proposals - .into_iter() - .filter(|p| self.created_redemption_proposal(p)) - .collect()) - } - async fn set_epoch_data( &self, epoch_id: EpochId, @@ -240,24 +188,6 @@ impl SharedState { data.get(&epoch_id).map(|d| &d.master_key).unwrap() })) } - - pub(crate) async fn start_tx(&self) -> RwLockWriteGuard<'_, DirectSigningHttpRpcNyxdClient> { - self.nyxd_client.write().await - } - - pub(crate) async fn start_query(&self) -> RwLockReadGuard<'_, DirectSigningHttpRpcNyxdClient> { - self.nyxd_client.read().await - } - - pub(crate) async fn current_epoch_id(&self) -> Result { - Ok(self - .start_query() - .await - .get_current_epoch() - .await - .map_err(EcashTicketError::chain_query_failure)? - .epoch_id) - } } pub(crate) struct EpochState { diff --git a/contracts/Cargo.lock b/contracts/Cargo.lock index 84a73def30..1fb526afa4 100644 --- a/contracts/Cargo.lock +++ b/contracts/Cargo.lock @@ -709,7 +709,7 @@ dependencies = [ "nym-contracts-common", "nym-contracts-common-testing", "nym-group-contract-common", - "nym-multisig-contract-common", + "nym-multisig-contract-common 1.20.4 (registry+https://github.com/rust-lang/crates.io-index)", ] [[package]] @@ -1258,7 +1258,7 @@ dependencies = [ "cw2", "cw4", "nym-contracts-common", - "nym-multisig-contract-common", + "nym-multisig-contract-common 1.20.4 (registry+https://github.com/rust-lang/crates.io-index)", ] [[package]] @@ -1329,11 +1329,11 @@ dependencies = [ "cw3", "cw4", "nym-contracts-common", + "nym-contracts-common-testing", "nym-crypto", "nym-ecash-contract-common", - "nym-multisig-contract-common", + "nym-multisig-contract-common 1.20.4 (registry+https://github.com/rust-lang/crates.io-index)", "nym-network-defaults", - "rand_chacha", "schemars", "semver", "serde", @@ -1344,8 +1344,6 @@ dependencies = [ [[package]] name = "nym-ecash-contract-common" version = "1.20.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d022e85291bf51877fbbf4688bc3762c605fdaee3b98c6407414f7c358bc5610" dependencies = [ "bs58", "cosmwasm-schema", @@ -1353,7 +1351,7 @@ dependencies = [ "cw-controllers", "cw-utils", "cw2", - "nym-multisig-contract-common", + "nym-multisig-contract-common 1.20.4", "thiserror 2.0.12", ] @@ -1416,6 +1414,21 @@ dependencies = [ "time", ] +[[package]] +name = "nym-multisig-contract-common" +version = "1.20.4" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-storage-plus", + "cw-utils", + "cw3", + "cw4", + "schemars", + "serde", + "thiserror 2.0.12", +] + [[package]] name = "nym-multisig-contract-common" version = "1.20.4" diff --git a/contracts/Cargo.toml b/contracts/Cargo.toml index 6659cb8804..0b23099771 100644 --- a/contracts/Cargo.toml +++ b/contracts/Cargo.toml @@ -96,3 +96,6 @@ unreachable = "deny" # For local development, import via path instead of crates.io, e.g. # [patch.crates-io] # nym-coconut-dkg-common = { path = "../common/cosmwasm-smart-contracts/coconut-dkg" } + +[patch.crates-io] +nym-ecash-contract-common = { path = "../common/cosmwasm-smart-contracts/ecash-contract" } diff --git a/contracts/ecash/Cargo.toml b/contracts/ecash/Cargo.toml index 4badeb4460..6391ebd269 100644 --- a/contracts/ecash/Cargo.toml +++ b/contracts/ecash/Cargo.toml @@ -39,8 +39,12 @@ nym-network-defaults = { workspace = true, default-features = false } anyhow = { workspace = true } sylvia = { workspace = true, features = ["mt"] } nym-crypto = { workspace = true, features = ["rand", "asymmetric"] } -rand_chacha = "0.3" cw-multi-test = { workspace = true } +nym-contracts-common-testing = { workspace = true } + [features] schema-gen = ["nym-ecash-contract-common/schema"] + +[lints] +workspace = true diff --git a/contracts/ecash/schema/nym-ecash.json b/contracts/ecash/schema/nym-ecash.json index ce67c4b049..7bf0c27804 100644 --- a/contracts/ecash/schema/nym-ecash.json +++ b/contracts/ecash/schema/nym-ecash.json @@ -156,10 +156,10 @@ { "type": "object", "required": [ - "update_deposit_value" + "update_default_deposit_value" ], "properties": { - "update_deposit_value": { + "update_default_deposit_value": { "type": "object", "required": [ "new_deposit" @@ -174,6 +174,54 @@ }, "additionalProperties": false }, + { + "description": "Set (or overwrite) a reduced deposit price for a specific address. Only callable by the contract admin.", + "type": "object", + "required": [ + "set_reduced_deposit_price" + ], + "properties": { + "set_reduced_deposit_price": { + "type": "object", + "required": [ + "address", + "deposit" + ], + "properties": { + "address": { + "type": "string" + }, + "deposit": { + "$ref": "#/definitions/Coin" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Remove the reduced deposit price for a specific address, reverting them to the default price. Returns an error if the address has no custom price set. Only callable by the contract admin.", + "type": "object", + "required": [ + "remove_reduced_deposit_price" + ], + "properties": { + "remove_reduced_deposit_price": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, { "type": "object", "required": [ @@ -297,10 +345,44 @@ { "type": "object", "required": [ - "get_required_deposit_amount" + "get_default_deposit_amount" ], "properties": { - "get_required_deposit_amount": { + "get_default_deposit_amount": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "get_reduced_deposit_amount" + ], + "properties": { + "get_reduced_deposit_amount": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "get_all_whitelisted_accounts" + ], + "properties": { + "get_all_whitelisted_accounts": { "type": "object", "additionalProperties": false } @@ -373,6 +455,19 @@ } }, "additionalProperties": false + }, + { + "type": "object", + "required": [ + "get_deposits_statistics" + ], + "properties": { + "get_deposits_statistics": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false } ] }, @@ -380,10 +475,120 @@ "$schema": "http://json-schema.org/draft-07/schema#", "title": "MigrateMsg", "type": "object", - "additionalProperties": false + "required": [ + "initial_whitelist" + ], + "properties": { + "initial_whitelist": { + "description": "Initial set of whitelisted accounts with their reduced deposit prices. Each entry is validated and stored during migration.", + "type": "array", + "items": { + "$ref": "#/definitions/WhitelistedDeposit" + } + } + }, + "additionalProperties": false, + "definitions": { + "Coin": { + "type": "object", + "required": [ + "amount", + "denom" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "denom": { + "type": "string" + } + }, + "additionalProperties": false + }, + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + }, + "WhitelistedDeposit": { + "description": "An address and its reduced deposit price, used when seeding the whitelist via migration.", + "type": "object", + "required": [ + "address", + "deposit" + ], + "properties": { + "address": { + "type": "string" + }, + "deposit": { + "$ref": "#/definitions/Coin" + } + }, + "additionalProperties": false + } + } }, "sudo": null, "responses": { + "get_all_whitelisted_accounts": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "WhitelistedAccountsResponse", + "type": "object", + "required": [ + "whitelisted_accounts" + ], + "properties": { + "whitelisted_accounts": { + "type": "array", + "items": { + "$ref": "#/definitions/WhitelistedAccount" + } + } + }, + "additionalProperties": false, + "definitions": { + "Addr": { + "description": "A human readable address.\n\nIn Cosmos, this is typically bech32 encoded. But for multi-chain smart contracts no assumptions should be made other than being UTF-8 encoded and of reasonable length.\n\nThis type represents a validated address. It can be created in the following ways 1. Use `Addr::unchecked(input)` 2. Use `let checked: Addr = deps.api.addr_validate(input)?` 3. Use `let checked: Addr = deps.api.addr_humanize(canonical_addr)?` 4. Deserialize from JSON. This must only be done from JSON that was validated before such as a contract's state. `Addr` must not be used in messages sent by the user because this would result in unvalidated instances.\n\nThis type is immutable. If you really need to mutate it (Really? Are you sure?), create a mutable copy using `let mut mutable = Addr::to_string()` and operate on that `String` instance.", + "type": "string" + }, + "Coin": { + "type": "object", + "required": [ + "amount", + "denom" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "denom": { + "type": "string" + } + }, + "additionalProperties": false + }, + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + }, + "WhitelistedAccount": { + "type": "object", + "required": [ + "address", + "deposit" + ], + "properties": { + "address": { + "$ref": "#/definitions/Addr" + }, + "deposit": { + "$ref": "#/definitions/Coin" + } + }, + "additionalProperties": false + } + } + }, "get_blacklist_paged": { "$schema": "http://json-schema.org/draft-07/schema#", "title": "PagedBlacklistedAccountResponse", @@ -496,6 +701,30 @@ } } }, + "get_default_deposit_amount": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Coin", + "type": "object", + "required": [ + "amount", + "denom" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "denom": { + "type": "string" + } + }, + "additionalProperties": false, + "definitions": { + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + } + } + }, "get_deposit": { "$schema": "http://json-schema.org/draft-07/schema#", "title": "DepositResponse", @@ -594,6 +823,99 @@ } } }, + "get_deposits_statistics": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "DepositsStatistics", + "description": "Aggregate statistics about all deposits made through the ecash contract.", + "type": "object", + "required": [ + "deposited_with_custom_price", + "deposits_made_with_custom_price", + "total_deposited", + "total_deposited_with_custom_price", + "total_deposited_with_default_price", + "total_deposits_made", + "total_deposits_made_with_custom_price", + "total_deposits_made_with_default_price" + ], + "properties": { + "deposited_with_custom_price": { + "description": "Per-account breakdown of deposited amounts for whitelisted addresses.", + "type": "object", + "additionalProperties": false + }, + "deposits_made_with_custom_price": { + "description": "Per-account breakdown of deposit counts for whitelisted addresses.", + "type": "object", + "additionalProperties": false + }, + "total_deposited": { + "description": "Total value of all deposits ever made (at any price tier), sourced from `PoolCounters::total_deposited`.", + "allOf": [ + { + "$ref": "#/definitions/Coin" + } + ] + }, + "total_deposited_with_custom_price": { + "description": "Total value deposited at custom prices, summed across all whitelisted accounts.", + "allOf": [ + { + "$ref": "#/definitions/Coin" + } + ] + }, + "total_deposited_with_default_price": { + "description": "Total value deposited at the default price.", + "allOf": [ + { + "$ref": "#/definitions/Coin" + } + ] + }, + "total_deposits_made": { + "description": "Total number of deposits ever made (at any price tier), derived from the deposit id counter.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "total_deposits_made_with_custom_price": { + "description": "Number of deposits made at any custom (reduced) price, summed across all whitelisted accounts.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "total_deposits_made_with_default_price": { + "description": "Number of deposits made at the default (non-reduced) price.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false, + "definitions": { + "Coin": { + "type": "object", + "required": [ + "amount", + "denom" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "denom": { + "type": "string" + } + }, + "additionalProperties": false + }, + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + } + } + }, "get_latest_deposit": { "$schema": "http://json-schema.org/draft-07/schema#", "title": "LatestDepositResponse", @@ -644,24 +966,34 @@ } } }, - "get_required_deposit_amount": { + "get_reduced_deposit_amount": { "$schema": "http://json-schema.org/draft-07/schema#", - "title": "Coin", - "type": "object", - "required": [ - "amount", - "denom" - ], - "properties": { - "amount": { - "$ref": "#/definitions/Uint128" + "title": "Nullable_Coin", + "anyOf": [ + { + "$ref": "#/definitions/Coin" }, - "denom": { - "type": "string" + { + "type": "null" } - }, - "additionalProperties": false, + ], "definitions": { + "Coin": { + "type": "object", + "required": [ + "amount", + "denom" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "denom": { + "type": "string" + } + }, + "additionalProperties": false + }, "Uint128": { "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", "type": "string" diff --git a/contracts/ecash/schema/raw/execute.json b/contracts/ecash/schema/raw/execute.json index e16868cf1a..3f64fb7f29 100644 --- a/contracts/ecash/schema/raw/execute.json +++ b/contracts/ecash/schema/raw/execute.json @@ -104,10 +104,10 @@ { "type": "object", "required": [ - "update_deposit_value" + "update_default_deposit_value" ], "properties": { - "update_deposit_value": { + "update_default_deposit_value": { "type": "object", "required": [ "new_deposit" @@ -122,6 +122,54 @@ }, "additionalProperties": false }, + { + "description": "Set (or overwrite) a reduced deposit price for a specific address. Only callable by the contract admin.", + "type": "object", + "required": [ + "set_reduced_deposit_price" + ], + "properties": { + "set_reduced_deposit_price": { + "type": "object", + "required": [ + "address", + "deposit" + ], + "properties": { + "address": { + "type": "string" + }, + "deposit": { + "$ref": "#/definitions/Coin" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Remove the reduced deposit price for a specific address, reverting them to the default price. Returns an error if the address has no custom price set. Only callable by the contract admin.", + "type": "object", + "required": [ + "remove_reduced_deposit_price" + ], + "properties": { + "remove_reduced_deposit_price": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, { "type": "object", "required": [ diff --git a/contracts/ecash/schema/raw/migrate.json b/contracts/ecash/schema/raw/migrate.json index 7fbe8c5708..6049157ca5 100644 --- a/contracts/ecash/schema/raw/migrate.json +++ b/contracts/ecash/schema/raw/migrate.json @@ -2,5 +2,56 @@ "$schema": "http://json-schema.org/draft-07/schema#", "title": "MigrateMsg", "type": "object", - "additionalProperties": false + "required": [ + "initial_whitelist" + ], + "properties": { + "initial_whitelist": { + "description": "Initial set of whitelisted accounts with their reduced deposit prices. Each entry is validated and stored during migration.", + "type": "array", + "items": { + "$ref": "#/definitions/WhitelistedDeposit" + } + } + }, + "additionalProperties": false, + "definitions": { + "Coin": { + "type": "object", + "required": [ + "amount", + "denom" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "denom": { + "type": "string" + } + }, + "additionalProperties": false + }, + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + }, + "WhitelistedDeposit": { + "description": "An address and its reduced deposit price, used when seeding the whitelist via migration.", + "type": "object", + "required": [ + "address", + "deposit" + ], + "properties": { + "address": { + "type": "string" + }, + "deposit": { + "$ref": "#/definitions/Coin" + } + }, + "additionalProperties": false + } + } } diff --git a/contracts/ecash/schema/raw/query.json b/contracts/ecash/schema/raw/query.json index f0a9ef71b1..00b2db97bb 100644 --- a/contracts/ecash/schema/raw/query.json +++ b/contracts/ecash/schema/raw/query.json @@ -55,10 +55,44 @@ { "type": "object", "required": [ - "get_required_deposit_amount" + "get_default_deposit_amount" ], "properties": { - "get_required_deposit_amount": { + "get_default_deposit_amount": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "get_reduced_deposit_amount" + ], + "properties": { + "get_reduced_deposit_amount": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "get_all_whitelisted_accounts" + ], + "properties": { + "get_all_whitelisted_accounts": { "type": "object", "additionalProperties": false } @@ -131,6 +165,19 @@ } }, "additionalProperties": false + }, + { + "type": "object", + "required": [ + "get_deposits_statistics" + ], + "properties": { + "get_deposits_statistics": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false } ] } diff --git a/contracts/ecash/schema/raw/response_to_get_all_whitelisted_accounts.json b/contracts/ecash/schema/raw/response_to_get_all_whitelisted_accounts.json new file mode 100644 index 0000000000..256867acb3 --- /dev/null +++ b/contracts/ecash/schema/raw/response_to_get_all_whitelisted_accounts.json @@ -0,0 +1,59 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "WhitelistedAccountsResponse", + "type": "object", + "required": [ + "whitelisted_accounts" + ], + "properties": { + "whitelisted_accounts": { + "type": "array", + "items": { + "$ref": "#/definitions/WhitelistedAccount" + } + } + }, + "additionalProperties": false, + "definitions": { + "Addr": { + "description": "A human readable address.\n\nIn Cosmos, this is typically bech32 encoded. But for multi-chain smart contracts no assumptions should be made other than being UTF-8 encoded and of reasonable length.\n\nThis type represents a validated address. It can be created in the following ways 1. Use `Addr::unchecked(input)` 2. Use `let checked: Addr = deps.api.addr_validate(input)?` 3. Use `let checked: Addr = deps.api.addr_humanize(canonical_addr)?` 4. Deserialize from JSON. This must only be done from JSON that was validated before such as a contract's state. `Addr` must not be used in messages sent by the user because this would result in unvalidated instances.\n\nThis type is immutable. If you really need to mutate it (Really? Are you sure?), create a mutable copy using `let mut mutable = Addr::to_string()` and operate on that `String` instance.", + "type": "string" + }, + "Coin": { + "type": "object", + "required": [ + "amount", + "denom" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "denom": { + "type": "string" + } + }, + "additionalProperties": false + }, + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + }, + "WhitelistedAccount": { + "type": "object", + "required": [ + "address", + "deposit" + ], + "properties": { + "address": { + "$ref": "#/definitions/Addr" + }, + "deposit": { + "$ref": "#/definitions/Coin" + } + }, + "additionalProperties": false + } + } +} diff --git a/contracts/ecash/schema/raw/response_to_get_default_deposit_amount.json b/contracts/ecash/schema/raw/response_to_get_default_deposit_amount.json new file mode 100644 index 0000000000..b1c4c6c716 --- /dev/null +++ b/contracts/ecash/schema/raw/response_to_get_default_deposit_amount.json @@ -0,0 +1,24 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Coin", + "type": "object", + "required": [ + "amount", + "denom" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "denom": { + "type": "string" + } + }, + "additionalProperties": false, + "definitions": { + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + } + } +} diff --git a/contracts/ecash/schema/raw/response_to_get_deposits_statistics.json b/contracts/ecash/schema/raw/response_to_get_deposits_statistics.json new file mode 100644 index 0000000000..404799e43b --- /dev/null +++ b/contracts/ecash/schema/raw/response_to_get_deposits_statistics.json @@ -0,0 +1,93 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "DepositsStatistics", + "description": "Aggregate statistics about all deposits made through the ecash contract.", + "type": "object", + "required": [ + "deposited_with_custom_price", + "deposits_made_with_custom_price", + "total_deposited", + "total_deposited_with_custom_price", + "total_deposited_with_default_price", + "total_deposits_made", + "total_deposits_made_with_custom_price", + "total_deposits_made_with_default_price" + ], + "properties": { + "deposited_with_custom_price": { + "description": "Per-account breakdown of deposited amounts for whitelisted addresses.", + "type": "object", + "additionalProperties": false + }, + "deposits_made_with_custom_price": { + "description": "Per-account breakdown of deposit counts for whitelisted addresses.", + "type": "object", + "additionalProperties": false + }, + "total_deposited": { + "description": "Total value of all deposits ever made (at any price tier), sourced from `PoolCounters::total_deposited`.", + "allOf": [ + { + "$ref": "#/definitions/Coin" + } + ] + }, + "total_deposited_with_custom_price": { + "description": "Total value deposited at custom prices, summed across all whitelisted accounts.", + "allOf": [ + { + "$ref": "#/definitions/Coin" + } + ] + }, + "total_deposited_with_default_price": { + "description": "Total value deposited at the default price.", + "allOf": [ + { + "$ref": "#/definitions/Coin" + } + ] + }, + "total_deposits_made": { + "description": "Total number of deposits ever made (at any price tier), derived from the deposit id counter.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "total_deposits_made_with_custom_price": { + "description": "Number of deposits made at any custom (reduced) price, summed across all whitelisted accounts.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "total_deposits_made_with_default_price": { + "description": "Number of deposits made at the default (non-reduced) price.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false, + "definitions": { + "Coin": { + "type": "object", + "required": [ + "amount", + "denom" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "denom": { + "type": "string" + } + }, + "additionalProperties": false + }, + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + } + } +} diff --git a/contracts/ecash/schema/raw/response_to_get_reduced_deposit_amount.json b/contracts/ecash/schema/raw/response_to_get_reduced_deposit_amount.json new file mode 100644 index 0000000000..e0533432a2 --- /dev/null +++ b/contracts/ecash/schema/raw/response_to_get_reduced_deposit_amount.json @@ -0,0 +1,34 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Nullable_Coin", + "anyOf": [ + { + "$ref": "#/definitions/Coin" + }, + { + "type": "null" + } + ], + "definitions": { + "Coin": { + "type": "object", + "required": [ + "amount", + "denom" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "denom": { + "type": "string" + } + }, + "additionalProperties": false + }, + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + } + } +} diff --git a/contracts/ecash/src/contract/helpers.rs b/contracts/ecash/src/contract/helpers.rs index 578e967da8..cb7acde076 100644 --- a/contracts/ecash/src/contract/helpers.rs +++ b/contracts/ecash/src/contract/helpers.rs @@ -2,11 +2,9 @@ // SPDX-License-Identifier: Apache-2.0 use crate::contract::NymEcashContract; -use crate::helpers::{ - create_batch_redemption_proposal, create_blacklist_proposal, Config, ProposalId, -}; +use crate::helpers::{create_batch_redemption_proposal, create_blacklist_proposal, ProposalId}; use cosmwasm_schema::cw_serde; -use cosmwasm_std::{to_json_binary, Addr, Coin, Decimal, Deps, Storage, SubMsg, Uint128}; +use cosmwasm_std::{to_json_binary, Addr, Deps, Storage, SubMsg}; use cw3::ProposalResponse; use nym_ecash_contract_common::EcashContractError; use nym_multisig_contract_common::msg::QueryMsg as MultisigQueryMsg; @@ -33,28 +31,6 @@ impl NymEcashContract { Ok(TICKETBOOK_SIZE) } - pub(crate) fn tickets_redemption_amount( - &self, - storage: &dyn Storage, - config: &Config, - number_of_tickets: u16, - ) -> Result { - let deposit_amount = config.deposit_amount.amount; - let ticketbook_size = Uint128::new(self.get_ticketbook_size(storage)? as u128); - let tickets = Uint128::new(number_of_tickets as u128); - - // how many tickets from a ticketbook you redeemed - let book_ratio = Decimal::from_ratio(tickets, ticketbook_size); - - // return = ticketbook_price * (tickets / ticketbook_size) - let return_amount = deposit_amount.mul_floor(book_ratio); - - Ok(Coin { - denom: config.deposit_amount.denom.clone(), - amount: return_amount, - }) - } - fn must_get_multisig_addr(&self, deps: Deps) -> Result { // SAFETY: multisig admin MUST always be set on initialisation, // if the call fails, we're in some weird UB land @@ -117,172 +93,3 @@ impl NymEcashContract { Ok(proposal_response) } } - -#[cfg(test)] -mod tests { - use super::*; - use crate::support::tests::TestSetupSimple; - - #[test] - fn ticket_redemption_amount() -> anyhow::Result<()> { - // make sure the ticketbook size hasn't changed so that our tests are still valid - assert_eq!(TICKETBOOK_SIZE, 50); - - // ticketbook price of 100nym - let test = TestSetupSimple::new().with_deposit_amount(100_000_000); - let res = - test.contract() - .tickets_redemption_amount(test.deps().storage, &test.config(), 1)?; - assert_eq!(res.amount.u128(), 2_000_000); - let res = - test.contract() - .tickets_redemption_amount(test.deps().storage, &test.config(), 2)?; - assert_eq!(res.amount.u128(), 4_000_000); - let res = - test.contract() - .tickets_redemption_amount(test.deps().storage, &test.config(), 5)?; - assert_eq!(res.amount.u128(), 10_000_000); - let res = - test.contract() - .tickets_redemption_amount(test.deps().storage, &test.config(), 10)?; - assert_eq!(res.amount.u128(), 20_000_000); - let res = - test.contract() - .tickets_redemption_amount(test.deps().storage, &test.config(), 30)?; - assert_eq!(res.amount.u128(), 60_000_000); - let res = - test.contract() - .tickets_redemption_amount(test.deps().storage, &test.config(), 50)?; - assert_eq!(res.amount.u128(), 100_000_000); - let res = - test.contract() - .tickets_redemption_amount(test.deps().storage, &test.config(), 123)?; - assert_eq!(res.amount.u128(), 246_000_000); - - // ticketbook price of 1.5unym per ticket - let test = TestSetupSimple::new().with_deposit_amount(75); - let res = - test.contract() - .tickets_redemption_amount(test.deps().storage, &test.config(), 1)?; - assert_eq!(res.amount.u128(), 1); - let res = - test.contract() - .tickets_redemption_amount(test.deps().storage, &test.config(), 2)?; - assert_eq!(res.amount.u128(), 3); - let res = - test.contract() - .tickets_redemption_amount(test.deps().storage, &test.config(), 5)?; - assert_eq!(res.amount.u128(), 7); - let res = - test.contract() - .tickets_redemption_amount(test.deps().storage, &test.config(), 10)?; - assert_eq!(res.amount.u128(), 15); - let res = - test.contract() - .tickets_redemption_amount(test.deps().storage, &test.config(), 30)?; - assert_eq!(res.amount.u128(), 45); - let res = - test.contract() - .tickets_redemption_amount(test.deps().storage, &test.config(), 50)?; - assert_eq!(res.amount.u128(), 75); - let res = - test.contract() - .tickets_redemption_amount(test.deps().storage, &test.config(), 123)?; - assert_eq!(res.amount.u128(), 184); - - // ticketbook price of 1unym per ticket - let test = TestSetupSimple::new().with_deposit_amount(50); - let res = - test.contract() - .tickets_redemption_amount(test.deps().storage, &test.config(), 1)?; - assert_eq!(res.amount.u128(), 1); - let res = - test.contract() - .tickets_redemption_amount(test.deps().storage, &test.config(), 2)?; - assert_eq!(res.amount.u128(), 2); - let res = - test.contract() - .tickets_redemption_amount(test.deps().storage, &test.config(), 5)?; - assert_eq!(res.amount.u128(), 5); - let res = - test.contract() - .tickets_redemption_amount(test.deps().storage, &test.config(), 10)?; - assert_eq!(res.amount.u128(), 10); - let res = - test.contract() - .tickets_redemption_amount(test.deps().storage, &test.config(), 30)?; - assert_eq!(res.amount.u128(), 30); - let res = - test.contract() - .tickets_redemption_amount(test.deps().storage, &test.config(), 50)?; - assert_eq!(res.amount.u128(), 50); - let res = - test.contract() - .tickets_redemption_amount(test.deps().storage, &test.config(), 123)?; - assert_eq!(res.amount.u128(), 123); - - // ticketbook price of 1unym in total - let test = TestSetupSimple::new().with_deposit_amount(1); - let res = - test.contract() - .tickets_redemption_amount(test.deps().storage, &test.config(), 1)?; - assert_eq!(res.amount.u128(), 0); - let res = - test.contract() - .tickets_redemption_amount(test.deps().storage, &test.config(), 2)?; - assert_eq!(res.amount.u128(), 0); - let res = - test.contract() - .tickets_redemption_amount(test.deps().storage, &test.config(), 5)?; - assert_eq!(res.amount.u128(), 0); - let res = - test.contract() - .tickets_redemption_amount(test.deps().storage, &test.config(), 10)?; - assert_eq!(res.amount.u128(), 0); - let res = - test.contract() - .tickets_redemption_amount(test.deps().storage, &test.config(), 30)?; - assert_eq!(res.amount.u128(), 0); - let res = - test.contract() - .tickets_redemption_amount(test.deps().storage, &test.config(), 50)?; - assert_eq!(res.amount.u128(), 1); - let res = - test.contract() - .tickets_redemption_amount(test.deps().storage, &test.config(), 123)?; - assert_eq!(res.amount.u128(), 2); - - // ticketbook price of 0unym - let test = TestSetupSimple::new().with_deposit_amount(0); - let res = - test.contract() - .tickets_redemption_amount(test.deps().storage, &test.config(), 1)?; - assert_eq!(res.amount.u128(), 0); - let res = - test.contract() - .tickets_redemption_amount(test.deps().storage, &test.config(), 2)?; - assert_eq!(res.amount.u128(), 0); - let res = - test.contract() - .tickets_redemption_amount(test.deps().storage, &test.config(), 5)?; - assert_eq!(res.amount.u128(), 0); - let res = - test.contract() - .tickets_redemption_amount(test.deps().storage, &test.config(), 10)?; - assert_eq!(res.amount.u128(), 0); - let res = - test.contract() - .tickets_redemption_amount(test.deps().storage, &test.config(), 30)?; - assert_eq!(res.amount.u128(), 0); - let res = - test.contract() - .tickets_redemption_amount(test.deps().storage, &test.config(), 50)?; - assert_eq!(res.amount.u128(), 0); - let res = - test.contract() - .tickets_redemption_amount(test.deps().storage, &test.config(), 123)?; - assert_eq!(res.amount.u128(), 0); - - Ok(()) - } -} diff --git a/contracts/ecash/src/contract/mod.rs b/contracts/ecash/src/contract/mod.rs index 934b2bdc07..a832ce0c5d 100644 --- a/contracts/ecash/src/contract/mod.rs +++ b/contracts/ecash/src/contract/mod.rs @@ -4,11 +4,12 @@ use crate::constants::{BLACKLIST_PROPOSAL_REPLY_ID, REDEMPTION_PROPOSAL_REPLY_ID}; use crate::contract::helpers::Invariants; use crate::deposit::DepositStorage; +use crate::deposit_stats::DepositStatsStorage; use crate::helpers::{ BlacklistKey, Config, MultisigReply, BLACKLIST_PAGE_DEFAULT_LIMIT, BLACKLIST_PAGE_MAX_LIMIT, CONTRACT_NAME, CONTRACT_VERSION, DEPOSITS_PAGE_DEFAULT_LIMIT, DEPOSITS_PAGE_MAX_LIMIT, }; -use cosmwasm_std::{coin, BankMsg, Coin, Event, Order, Reply, Response, StdResult}; +use cosmwasm_std::{coin, Addr, Coin, DepsMut, Event, Order, Reply, Response, StdResult}; use cw4::Cw4Contract; use cw_controllers::Admin; use cw_storage_plus::{Bound, Item, Map}; @@ -20,9 +21,12 @@ use nym_ecash_contract_common::counters::PoolCounters; use nym_ecash_contract_common::deposit::{ DepositData, DepositResponse, LatestDepositResponse, PagedDepositsResponse, }; +use nym_ecash_contract_common::deposit_statistics::DepositsStatistics; use nym_ecash_contract_common::events::{ DEPOSITED_FUNDS_EVENT_TYPE, DEPOSIT_ID, PROPOSAL_ID_ATTRIBUTE_NAME, }; +use nym_ecash_contract_common::msg::WhitelistedDeposit; +use nym_ecash_contract_common::reduced_deposit::{WhitelistedAccount, WhitelistedAccountsResponse}; use nym_ecash_contract_common::EcashContractError; use nym_network_defaults::TICKETBOOK_SIZE; use sylvia::ctx::{ExecCtx, InstantiateCtx, MigrateCtx, QueryCtx}; @@ -41,6 +45,13 @@ pub struct NymEcashContract { pub(crate) pool_counters: Item, pub(crate) expected_invariants: Item, + /// Information about the performed deposits + pub(crate) deposit_stats: DepositStatsStorage, + + /// Map of approved addresses that are allowed to perform deposits using a reduced amount + /// as specified by the saved value. + pub(crate) reduced_deposits: Map, + pub(crate) blacklist: Map, pub(crate) deposits: DepositStorage, @@ -58,6 +69,8 @@ impl NymEcashContract { config: Item::new("config"), pool_counters: Item::new("pool_counters"), expected_invariants: Item::new("expected_invariants"), + deposit_stats: DepositStatsStorage::new(), + reduced_deposits: Map::new("reduced_deposits"), blacklist: Map::new("blacklist"), deposits: DepositStorage::new(), } @@ -72,6 +85,9 @@ impl NymEcashContract { group_addr: String, deposit_amount: Coin, ) -> Result { + // all counters, deposits, etc. always use and always will use the same denom + let zero_coin = coin(0, &deposit_amount.denom); + let multisig_addr = ctx.deps.api.addr_validate(&multisig_addr)?; let holding_account = ctx.deps.api.addr_validate(&holding_account)?; let group_addr = Cw4Contract(ctx.deps.api.addr_validate(&group_addr).map_err(|_| { @@ -96,8 +112,9 @@ impl NymEcashContract { self.pool_counters.save( ctx.deps.storage, &PoolCounters { - total_deposited: coin(0, &deposit_amount.denom), - total_redeemed: coin(0, &deposit_amount.denom), + total_deposited: zero_coin.clone(), + total_redeemed: zero_coin.clone(), + tickets_requested_and_not_redeemed: 0, }, )?; @@ -110,6 +127,14 @@ impl NymEcashContract { }, )?; + self.deposit_stats + .deposits_with_default_price + .save(ctx.deps.storage, &0)?; + + self.deposit_stats + .deposits_with_default_price_amounts + .save(ctx.deps.storage, &zero_coin)?; + cw2::set_contract_version(ctx.deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; set_build_information!(ctx.deps.storage)?; @@ -161,12 +186,43 @@ impl NymEcashContract { } #[sv::msg(query)] - pub fn get_required_deposit_amount(&self, ctx: QueryCtx) -> StdResult { + pub fn get_default_deposit_amount(&self, ctx: QueryCtx) -> StdResult { let deposit_amount = self.config.load(ctx.deps.storage)?.deposit_amount; Ok(deposit_amount) } + #[sv::msg(query)] + pub fn get_reduced_deposit_amount( + &self, + ctx: QueryCtx, + address: String, + ) -> StdResult> { + let address = ctx.deps.api.addr_validate(&address)?; + let deposit_amount = self.reduced_deposits.may_load(ctx.deps.storage, address)?; + + Ok(deposit_amount) + } + + #[sv::msg(query)] + pub fn get_all_whitelisted_accounts( + &self, + ctx: QueryCtx, + ) -> StdResult { + let whitelisted_accounts = self + .reduced_deposits + .range(ctx.deps.storage, None, None, Order::Ascending) + .map(|item| { + let (address, deposit) = item?; + Ok(WhitelistedAccount { address, deposit }) + }) + .collect::>>()?; + + Ok(WhitelistedAccountsResponse { + whitelisted_accounts, + }) + } + #[sv::msg(query)] pub fn get_deposit( &self, @@ -226,6 +282,40 @@ impl NymEcashContract { }) } + #[sv::msg(query)] + pub fn get_deposits_statistics( + &self, + ctx: QueryCtx, + ) -> Result { + let storage = ctx.deps.storage; + let denom = &self.config.load(storage)?.deposit_amount.denom; + + let total_deposits_made = self.deposits.total_deposits_made(storage)?; + let total_deposited = self.pool_counters.load(storage)?.total_deposited; + + let total_deposits_made_with_default_price = self + .deposit_stats + .get_total_deposits_made_with_default_price(storage)?; + let total_deposited_with_default_price = self + .deposit_stats + .get_total_deposited_with_default_price(storage, denom)?; + + let custom = self + .deposit_stats + .get_custom_price_deposits(storage, denom)?; + + Ok(DepositsStatistics { + total_deposits_made, + total_deposited, + total_deposits_made_with_default_price, + total_deposited_with_default_price, + total_deposits_made_with_custom_price: custom.total_count, + total_deposited_with_custom_price: custom.total_amount, + deposits_made_with_custom_price: custom.per_account_count, + deposited_with_custom_price: custom.per_account_amount, + }) + } + /*===================== ======EXECUTIONS======= =====================*/ @@ -236,20 +326,48 @@ impl NymEcashContract { ctx: ExecCtx, identity_key: String, ) -> Result { - let required_deposit = self.config.load(ctx.deps.storage)?.deposit_amount; + let default_deposit = self.config.load(ctx.deps.storage)?.deposit_amount; + let reduced_deposit = self + .reduced_deposits + .may_load(ctx.deps.storage, ctx.info.sender.clone())?; - let submitted = cw_utils::must_pay(&ctx.info, &required_deposit.denom)?; + let submitted = cw_utils::must_pay(&ctx.info, &default_deposit.denom)?; + let mut funds = ctx.info.funds; - if submitted != required_deposit.amount { - let mut funds = ctx.info.funds; + // Whitelisted accounts may deposit at either their reduced price or the + // default price. If the default price is sent, the deposit is treated as + // a regular (non-reduced) deposit for statistics purposes. + if submitted == default_deposit.amount { + self.deposit_stats + .new_default_deposit(ctx.deps.storage, &default_deposit)?; + } else if let Some(reduced_deposit) = reduced_deposit.as_ref() { + // can't do if let chaining due to outdated rustc used for building contracts + if reduced_deposit.amount == submitted { + self.deposit_stats.new_reduced_deposit( + ctx.deps.storage, + &ctx.info.sender, + reduced_deposit, + )?; + } else { + // we are allowed to send reduced amounts, but we sent the wrong amount + return Err(EcashContractError::WrongAmount { + // SAFETY: the call to `must_pay` ensured a single coin has been sent + #[allow(clippy::unwrap_used)] + received: funds.pop().unwrap(), + amount: reduced_deposit.clone(), + }); + } + } else { + // we sent wrong amount of tokens return Err(EcashContractError::WrongAmount { // SAFETY: the call to `must_pay` ensured a single coin has been sent #[allow(clippy::unwrap_used)] received: funds.pop().unwrap(), - amount: required_deposit, + amount: default_deposit, }); - } + }; + // global total needed when migrating to the nym pool contract self.pool_counters .update(ctx.deps.storage, |mut counters| -> StdResult<_> { counters.total_deposited.amount += submitted; @@ -285,6 +403,8 @@ impl NymEcashContract { Ok(Response::new().add_submessage(msg)) } + /// Old legacy method for requesting ticket redemption by moving them into the holding accounts + /// currently only executed by legacy gateways #[sv::msg(exec)] pub fn redeem_tickets( &self, @@ -301,22 +421,15 @@ impl NymEcashContract { self.multisig .assert_admin(ctx.deps.as_ref(), &ctx.info.sender)?; - let config = self.config.load(ctx.deps.storage)?; - let to_return = self.tickets_redemption_amount(ctx.deps.storage, &config, n)?; - if to_return.amount.is_zero() { - return Ok(Response::new()); - } - self.pool_counters .update(ctx.deps.storage, |mut counters| -> StdResult<_> { - counters.total_redeemed.amount += to_return.amount; + counters.tickets_requested_and_not_redeemed += n as u64; Ok(counters) })?; - Ok(Response::new().add_message(BankMsg::Send { - to_address: config.holding_account.to_string(), - amount: vec![to_return], - })) + Ok(Response::new().add_event( + Event::new("ticket_redemption").add_attribute("moved_to_holding_account", "false"), + )) } #[sv::msg(exec)] @@ -334,7 +447,7 @@ impl NymEcashContract { } #[sv::msg(exec)] - pub fn update_deposit_value( + pub fn update_default_deposit_value( &self, ctx: ExecCtx, new_deposit: Coin, @@ -343,6 +456,14 @@ impl NymEcashContract { self.contract_admin .assert_admin(ctx.deps.as_ref(), &ctx.info.sender)?; + let ticket_book_size = self.get_ticketbook_size(ctx.deps.storage)?; + if new_deposit.amount < cosmwasm_std::Uint128::from(ticket_book_size) { + return Err(EcashContractError::DepositBelowTicketBookSize { + amount: new_deposit.amount, + ticket_book_size, + }); + } + let deposit_str = new_deposit.to_string(); self.config .update(ctx.deps.storage, |mut cfg| -> StdResult<_> { @@ -352,6 +473,85 @@ impl NymEcashContract { Ok(Response::new().add_attribute("updated_deposit", deposit_str)) } + pub(crate) fn add_reduced_deposit_address( + &self, + deps: DepsMut, + address: Addr, + deposit: &Coin, + ) -> Result<(), EcashContractError> { + // the reduced price must be strictly less than the default to avoid + // accidentally misconfiguring an address to pay more than everyone else + let default = self.config.load(deps.storage)?.deposit_amount; + if deposit.denom != default.denom { + return Err(EcashContractError::InvalidReducedDepositDenom { + expected: default.denom, + got: deposit.denom.clone(), + }); + } + if deposit.amount >= default.amount { + return Err(EcashContractError::ReducedDepositNotReduced { + reduced: deposit.amount, + default: default.amount, + }); + } + + let ticket_book_size = self.get_ticketbook_size(deps.storage)?; + if deposit.amount < cosmwasm_std::Uint128::from(ticket_book_size) { + return Err(EcashContractError::DepositBelowTicketBookSize { + amount: deposit.amount, + ticket_book_size, + }); + } + + self.reduced_deposits.save(deps.storage, address, deposit)?; + Ok(()) + } + + #[sv::msg(exec)] + pub fn set_reduced_deposit_price( + &self, + ctx: ExecCtx, + address: String, + deposit: Coin, + ) -> Result { + self.contract_admin + .assert_admin(ctx.deps.as_ref(), &ctx.info.sender)?; + + let addr = ctx.deps.api.addr_validate(&address)?; + self.add_reduced_deposit_address(ctx.deps, addr.clone(), &deposit)?; + + Ok(Response::new() + .add_attribute("action", "set_reduced_deposit_price") + .add_attribute("address", address) + .add_attribute("deposit", deposit.to_string())) + } + + /// Removes the reduced deposit price for a given address, reverting them to + /// the default deposit amount. This is safe to call even if the address has + /// already deposited at the reduced price — their next deposit will simply + /// use the default price. Historical deposit statistics are not affected. + #[sv::msg(exec)] + pub fn remove_reduced_deposit_price( + &self, + ctx: ExecCtx, + address: String, + ) -> Result { + self.contract_admin + .assert_admin(ctx.deps.as_ref(), &ctx.info.sender)?; + + let addr = ctx.deps.api.addr_validate(&address)?; + + if !self.reduced_deposits.has(ctx.deps.storage, addr.clone()) { + return Err(EcashContractError::NoReducedDepositPrice { address }); + } + + self.reduced_deposits.remove(ctx.deps.storage, addr); + + Ok(Response::new() + .add_attribute("action", "remove_reduced_deposit_price") + .add_attribute("address", address)) + } + #[sv::msg(exec)] pub fn propose_to_blacklist( &self, @@ -459,11 +659,15 @@ impl NymEcashContract { =======MIGRATION======= =====================*/ #[sv::msg(migrate)] - pub fn migrate(&self, ctx: MigrateCtx) -> Result { + pub fn migrate( + &self, + ctx: MigrateCtx, + initial_whitelist: Vec, + ) -> Result { set_build_information!(ctx.deps.storage)?; cw2::ensure_from_older_version(ctx.deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; - queued_migrations::remove_redemption_gateway_share(ctx.deps)?; + queued_migrations::add_tiered_pricing(ctx.deps, initial_whitelist)?; Ok(Response::new()) } diff --git a/contracts/ecash/src/contract/queued_migrations.rs b/contracts/ecash/src/contract/queued_migrations.rs index a479330aca..284b8bf81c 100644 --- a/contracts/ecash/src/contract/queued_migrations.rs +++ b/contracts/ecash/src/contract/queued_migrations.rs @@ -1,42 +1,303 @@ -// Copyright 2024 - Nym Technologies SA +// Copyright 2026 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 use crate::contract::NymEcashContract; -use crate::helpers::Config; -use cosmwasm_std::{Addr, Coin, Decimal, DepsMut}; -use cw4::Cw4Contract; -use cw_storage_plus::Item; +use cosmwasm_std::DepsMut; +use nym_ecash_contract_common::msg::WhitelistedDeposit; use nym_ecash_contract_common::EcashContractError; -use serde::{Deserialize, Serialize}; -pub fn remove_redemption_gateway_share(deps: DepsMut) -> Result<(), EcashContractError> { - #[derive(Serialize, Deserialize)] - struct OldConfig { - group_addr: Cw4Contract, - holding_account: Addr, +pub fn add_tiered_pricing( + mut deps: DepsMut, + initial_whitelist: Vec, +) -> Result<(), EcashContractError> { + let contract = NymEcashContract::new(); - redemption_gateway_share: Decimal, - deposit_amount: Coin, + // All the deposits made so far were performed with the default price. + let deposits_performed = contract.deposits.total_deposits_made(deps.storage)?; + let deposits_amounts = contract.pool_counters.load(deps.storage)?.total_deposited; + + contract + .deposit_stats + .deposits_with_default_price + .save(deps.storage, &deposits_performed)?; + + contract + .deposit_stats + .deposits_with_default_price_amounts + .save(deps.storage, &deposits_amounts)?; + + // Seed the whitelist with the initial set of reduced deposit prices. + for whitelisted in initial_whitelist { + let addr = deps.api.addr_validate(&whitelisted.address)?; + + contract.add_reduced_deposit_address(deps.branch(), addr, &whitelisted.deposit)?; } - impl From for Config { - fn from(config: OldConfig) -> Self { - Config { - group_addr: config.group_addr, - holding_account: config.holding_account, - deposit_amount: config.deposit_amount, - } - } - } - - const OLD_CONFIG: Item = Item::new("config"); - - let old_config = OLD_CONFIG.load(deps.storage)?; - let new_config = old_config.into(); - - NymEcashContract::new() - .config - .save(deps.storage, &new_config)?; - Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::contract::helpers::Invariants; + use crate::deposit::DepositStorage; + use crate::deposit_stats::DepositStatsStorage; + use crate::helpers::Config; + use cosmwasm_std::testing::{mock_dependencies, MockApi, MockQuerier}; + use cosmwasm_std::{coin, Empty, MemoryStorage, OwnedDeps, Uint128}; + use cw4::Cw4Contract; + use cw_storage_plus::Item; + use nym_ecash_contract_common::counters::PoolCounters; + + const DENOM: &str = "unym"; + const DEFAULT_DEPOSIT: u128 = 75_000_000; + const TICKET_BOOK_SIZE: u64 = 50; + + /// Initialise the contract config and invariants so that whitelist + /// validation during migration has the values it needs. + fn save_config_and_invariants( + deps: &mut OwnedDeps>, + ) { + let contract = NymEcashContract::new(); + let group_addr = deps.api.addr_make("group"); + let holding_account = deps.api.addr_make("holding"); + contract + .config + .save( + deps.as_mut().storage, + &Config { + group_addr: Cw4Contract(group_addr), + holding_account, + deposit_amount: coin(DEFAULT_DEPOSIT, DENOM), + }, + ) + .unwrap(); + contract + .expected_invariants + .save( + deps.as_mut().storage, + &Invariants { + ticket_book_size: TICKET_BOOK_SIZE, + }, + ) + .unwrap(); + } + + fn save_pool_counters(storage: &mut dyn cosmwasm_std::Storage, total_deposited: u128) { + let pool_counters: Item = Item::new("pool_counters"); + pool_counters + .save( + storage, + &PoolCounters { + total_deposited: coin(total_deposited, DENOM), + total_redeemed: coin(0, DENOM), + tickets_requested_and_not_redeemed: 0, + }, + ) + .unwrap(); + } + + #[test] + fn migration_with_no_prior_deposits_initialises_stats_to_zero() { + let mut deps = mock_dependencies(); + + // No deposit_id_counter saved — contract never had a deposit. + save_pool_counters(deps.as_mut().storage, 0); + + add_tiered_pricing(deps.as_mut(), vec![]).unwrap(); + + let stats = DepositStatsStorage::new(); + assert_eq!( + stats + .deposits_with_default_price + .load(deps.as_ref().storage) + .unwrap(), + 0 + ); + assert_eq!( + stats + .deposits_with_default_price_amounts + .load(deps.as_ref().storage) + .unwrap(), + coin(0, DENOM) + ); + } + + #[test] + fn migration_with_prior_deposits_backfills_correct_count() { + let mut deps = mock_dependencies(); + let n_deposits: u32 = 3; + let total: u128 = n_deposits as u128 * 75_000_000; + + // Simulate n_deposits having been made: counter stores the next available id, + // which equals the number of deposits already performed. + let deposits = DepositStorage::new(); + deposits + .deposit_id_counter + .save(deps.as_mut().storage, &n_deposits) + .unwrap(); + + save_pool_counters(deps.as_mut().storage, total); + + add_tiered_pricing(deps.as_mut(), vec![]).unwrap(); + + let stats = DepositStatsStorage::new(); + assert_eq!( + stats + .deposits_with_default_price + .load(deps.as_ref().storage) + .unwrap(), + n_deposits + ); + assert_eq!( + stats + .deposits_with_default_price_amounts + .load(deps.as_ref().storage) + .unwrap(), + coin(total, DENOM) + ); + } + + #[test] + fn migration_with_single_deposit_backfills_count_of_one() { + let mut deps = mock_dependencies(); + + // After one deposit, next_id returns 0 and saves counter=1. + let deposits = DepositStorage::new(); + deposits + .deposit_id_counter + .save(deps.as_mut().storage, &1u32) + .unwrap(); + + save_pool_counters(deps.as_mut().storage, 75_000_000); + + add_tiered_pricing(deps.as_mut(), vec![]).unwrap(); + + let stats = DepositStatsStorage::new(); + assert_eq!( + stats + .deposits_with_default_price + .load(deps.as_ref().storage) + .unwrap(), + 1 + ); + } + + #[test] + fn migration_stores_valid_whitelist_entries() { + let mut deps = mock_dependencies(); + save_pool_counters(deps.as_mut().storage, 0); + save_config_and_invariants(&mut deps); + + let addr1 = deps.api.addr_make("alice"); + let addr2 = deps.api.addr_make("bob"); + + let whitelist = vec![ + WhitelistedDeposit { + address: addr1.to_string(), + deposit: coin(10_000_000, DENOM), + }, + WhitelistedDeposit { + address: addr2.to_string(), + deposit: coin(50_000_000, DENOM), + }, + ]; + + add_tiered_pricing(deps.as_mut(), whitelist).unwrap(); + + let contract = NymEcashContract::new(); + assert_eq!( + contract + .reduced_deposits + .load(deps.as_ref().storage, addr1) + .unwrap(), + coin(10_000_000, DENOM) + ); + assert_eq!( + contract + .reduced_deposits + .load(deps.as_ref().storage, addr2) + .unwrap(), + coin(50_000_000, DENOM) + ); + } + + #[test] + fn migration_rejects_wrong_denom() { + let mut deps = mock_dependencies(); + save_pool_counters(deps.as_mut().storage, 0); + save_config_and_invariants(&mut deps); + + let whitelist = vec![WhitelistedDeposit { + address: deps.api.addr_make("alice").to_string(), + deposit: coin(10_000_000, "uatom"), + }]; + + let err = add_tiered_pricing(deps.as_mut(), whitelist).unwrap_err(); + assert_eq!( + err, + EcashContractError::InvalidReducedDepositDenom { + expected: DENOM.to_string(), + got: "uatom".to_string(), + } + ); + } + + #[test] + fn migration_rejects_amount_not_less_than_default() { + let mut deps = mock_dependencies(); + save_pool_counters(deps.as_mut().storage, 0); + save_config_and_invariants(&mut deps); + + // Equal to default — should fail + let whitelist = vec![WhitelistedDeposit { + address: deps.api.addr_make("alice").to_string(), + deposit: coin(DEFAULT_DEPOSIT, DENOM), + }]; + + let err = add_tiered_pricing(deps.as_mut(), whitelist).unwrap_err(); + assert_eq!( + err, + EcashContractError::ReducedDepositNotReduced { + reduced: Uint128::new(DEFAULT_DEPOSIT), + default: Uint128::new(DEFAULT_DEPOSIT), + } + ); + + // Greater than default — should also fail + let whitelist = vec![WhitelistedDeposit { + address: deps.api.addr_make("alice").to_string(), + deposit: coin(DEFAULT_DEPOSIT + 1, DENOM), + }]; + + let err = add_tiered_pricing(deps.as_mut(), whitelist).unwrap_err(); + assert_eq!( + err, + EcashContractError::ReducedDepositNotReduced { + reduced: Uint128::new(DEFAULT_DEPOSIT + 1), + default: Uint128::new(DEFAULT_DEPOSIT), + } + ); + } + + #[test] + fn migration_rejects_amount_below_ticket_book_size() { + let mut deps = mock_dependencies(); + save_pool_counters(deps.as_mut().storage, 0); + save_config_and_invariants(&mut deps); + + let whitelist = vec![WhitelistedDeposit { + address: deps.api.addr_make("alice").to_string(), + deposit: coin(TICKET_BOOK_SIZE as u128 - 1, DENOM), + }]; + + let err = add_tiered_pricing(deps.as_mut(), whitelist).unwrap_err(); + assert_eq!( + err, + EcashContractError::DepositBelowTicketBookSize { + amount: Uint128::new(TICKET_BOOK_SIZE as u128 - 1), + ticket_book_size: TICKET_BOOK_SIZE, + } + ); + } +} diff --git a/contracts/ecash/src/contract/test.rs b/contracts/ecash/src/contract/test.rs index 77f4dade4c..cdf79f6195 100644 --- a/contracts/ecash/src/contract/test.rs +++ b/contracts/ecash/src/contract/test.rs @@ -3,8 +3,8 @@ use crate::contract::NymEcashContract; use cosmwasm_std::testing::{message_info, mock_dependencies, mock_env, MockApi, MockQuerier}; -use cosmwasm_std::{coin, Addr, Empty, Env, MemoryStorage, OwnedDeps}; -use sylvia::ctx::{InstantiateCtx, QueryCtx}; +use cosmwasm_std::{coin, Addr, Empty, Env, MemoryStorage, MessageInfo, OwnedDeps}; +use sylvia::ctx::{ExecCtx, InstantiateCtx, QueryCtx}; pub const TEST_DENOM: &str = "unym"; @@ -55,30 +55,48 @@ impl TestSetup { pub fn query_ctx(&self) -> QueryCtx<'_> { QueryCtx::from((self.deps.as_ref(), self.env.clone())) } + + pub fn exec_ctx(&mut self, sender: MessageInfo) -> ExecCtx<'_> { + ExecCtx::from((self.deps.as_mut(), self.env.clone(), sender)) + } + + pub fn admin_info(&self) -> MessageInfo { + let admin = self + .contract + .contract_admin + .get(self.deps.as_ref()) + .unwrap() + .unwrap(); + message_info(&admin, &[]) + } } #[cfg(test)] mod tests { use super::*; + use cosmwasm_std::coin; use nym_ecash_contract_common::deposit::Deposit; + use nym_ecash_contract_common::reduced_deposit::WhitelistedAccount; + use nym_ecash_contract_common::EcashContractError; use sylvia::anyhow; + const CONTRACT: NymEcashContract = NymEcashContract::new(); + #[test] fn deposit_queries() -> anyhow::Result<()> { let mut test = TestSetup::init(); // no deposit - let res = test.contract.get_deposit(test.query_ctx(), 42)?; + let res = CONTRACT.get_deposit(test.query_ctx(), 42)?; assert!(res.deposit.is_none()); - let deps = test.deps.as_mut(); - let deposit_id = test.contract.deposits.save_deposit( - deps.storage, + let deposit_id = CONTRACT.deposits.save_deposit( + test.deps.as_mut().storage, "GLdR2NRVZBiCoCbv4fNqt9wUJZAnNjGXHkx3TjVAUzrK".to_string(), )?; // deposit exists - let res = test.contract.get_deposit(test.query_ctx(), deposit_id)?; + let res = CONTRACT.get_deposit(test.query_ctx(), deposit_id)?; let expected = Deposit { bs58_encoded_ed25519_pubkey: "GLdR2NRVZBiCoCbv4fNqt9wUJZAnNjGXHkx3TjVAUzrK".to_string(), }; @@ -87,4 +105,392 @@ mod tests { Ok(()) } + + #[test] + fn get_default_deposit_amount_returns_configured_value() -> anyhow::Result<()> { + let test = TestSetup::init(); + + let amount = CONTRACT.get_default_deposit_amount(test.query_ctx())?; + + assert_eq!(amount, coin(75_000_000, TEST_DENOM)); + + Ok(()) + } + + #[test] + fn get_reduced_deposit_amount_returns_none_for_unlisted_address() -> anyhow::Result<()> { + let test = TestSetup::init(); + let unknown = test.deps.api.addr_make("unknown"); + + let amount = CONTRACT.get_reduced_deposit_amount(test.query_ctx(), unknown.to_string())?; + + assert!(amount.is_none()); + + Ok(()) + } + + #[test] + fn get_reduced_deposit_amount_returns_amount_for_whitelisted_address() -> anyhow::Result<()> { + let mut test = TestSetup::init(); + let addr = test.deps.api.addr_make("whitelisted"); + let reduced = coin(10_000_000, TEST_DENOM); + + CONTRACT + .reduced_deposits + .save(test.deps.as_mut().storage, addr.clone(), &reduced)?; + + let amount = CONTRACT.get_reduced_deposit_amount(test.query_ctx(), addr.to_string())?; + + assert_eq!(amount, Some(reduced)); + + Ok(()) + } + + // --- get_all_whitelisted_accounts --- + + #[test] + fn get_all_whitelisted_accounts_returns_empty_by_default() -> anyhow::Result<()> { + let test = TestSetup::init(); + + let res = CONTRACT.get_all_whitelisted_accounts(test.query_ctx())?; + assert!(res.whitelisted_accounts.is_empty()); + + Ok(()) + } + + #[test] + fn get_all_whitelisted_accounts_returns_all_entries() -> anyhow::Result<()> { + let mut test = TestSetup::init(); + let alice = test.deps.api.addr_make("alice"); + let bob = test.deps.api.addr_make("bob"); + + let admin = test.admin_info(); + CONTRACT.set_reduced_deposit_price( + test.exec_ctx(admin), + alice.to_string(), + coin(10_000_000, TEST_DENOM), + )?; + + let admin = test.admin_info(); + CONTRACT.set_reduced_deposit_price( + test.exec_ctx(admin), + bob.to_string(), + coin(5_000_000, TEST_DENOM), + )?; + + let res = CONTRACT.get_all_whitelisted_accounts(test.query_ctx())?; + assert_eq!(res.whitelisted_accounts.len(), 2); + + assert!(res.whitelisted_accounts.contains(&WhitelistedAccount { + address: alice, + deposit: coin(10_000_000, TEST_DENOM), + })); + assert!(res.whitelisted_accounts.contains(&WhitelistedAccount { + address: bob, + deposit: coin(5_000_000, TEST_DENOM), + })); + + Ok(()) + } + + // --- set_reduced_deposit_price --- + + #[test] + fn set_reduced_deposit_price_requires_admin() -> anyhow::Result<()> { + let mut test = TestSetup::init(); + let non_admin = test.deps.api.addr_make("non_admin"); + let addr = test.deps.api.addr_make("alice"); + + let err = CONTRACT + .set_reduced_deposit_price( + test.exec_ctx(message_info(&non_admin, &[])), + addr.to_string(), + coin(10_000_000, TEST_DENOM), + ) + .unwrap_err(); + + assert!(matches!(err, EcashContractError::Admin(_))); + + Ok(()) + } + + #[test] + fn set_reduced_deposit_price_rejects_wrong_denom() -> anyhow::Result<()> { + let mut test = TestSetup::init(); + let addr = test.deps.api.addr_make("alice"); + let admin = test.admin_info(); + + let err = CONTRACT + .set_reduced_deposit_price( + test.exec_ctx(admin), + addr.to_string(), + coin(10_000_000, "uatom"), + ) + .unwrap_err(); + + assert_eq!( + err, + EcashContractError::InvalidReducedDepositDenom { + expected: TEST_DENOM.to_string(), + got: "uatom".to_string(), + } + ); + + Ok(()) + } + + #[test] + fn set_reduced_deposit_price_rejects_amount_equal_to_default() -> anyhow::Result<()> { + let mut test = TestSetup::init(); + let addr = test.deps.api.addr_make("alice"); + let admin = test.admin_info(); + + let err = CONTRACT + .set_reduced_deposit_price( + test.exec_ctx(admin), + addr.to_string(), + coin(75_000_000, TEST_DENOM), // same as default + ) + .unwrap_err(); + + assert!(matches!( + err, + EcashContractError::ReducedDepositNotReduced { .. } + )); + + Ok(()) + } + + #[test] + fn set_reduced_deposit_price_rejects_amount_above_default() -> anyhow::Result<()> { + let mut test = TestSetup::init(); + let addr = test.deps.api.addr_make("alice"); + let admin = test.admin_info(); + + let err = CONTRACT + .set_reduced_deposit_price( + test.exec_ctx(admin), + addr.to_string(), + coin(100_000_000, TEST_DENOM), + ) + .unwrap_err(); + + assert!(matches!( + err, + EcashContractError::ReducedDepositNotReduced { .. } + )); + + Ok(()) + } + + #[test] + fn set_reduced_deposit_price_rejects_amount_below_ticket_book_size() -> anyhow::Result<()> { + let mut test = TestSetup::init(); + let addr = test.deps.api.addr_make("alice"); + let admin = test.admin_info(); + + let err = CONTRACT + .set_reduced_deposit_price( + test.exec_ctx(admin), + addr.to_string(), + coin(10, TEST_DENOM), // below ticket_book_size (50) + ) + .unwrap_err(); + + assert!(matches!( + err, + EcashContractError::DepositBelowTicketBookSize { .. } + )); + + Ok(()) + } + + #[test] + fn set_reduced_deposit_price_stores_price() -> anyhow::Result<()> { + let mut test = TestSetup::init(); + let addr = test.deps.api.addr_make("alice"); + let reduced = coin(10_000_000, TEST_DENOM); + let admin = test.admin_info(); + + CONTRACT.set_reduced_deposit_price( + test.exec_ctx(admin), + addr.to_string(), + reduced.clone(), + )?; + + let stored = CONTRACT.get_reduced_deposit_amount(test.query_ctx(), addr.to_string())?; + + assert_eq!(stored, Some(reduced)); + + Ok(()) + } + + #[test] + fn set_reduced_deposit_price_overwrites_existing_price() -> anyhow::Result<()> { + let mut test = TestSetup::init(); + let addr = test.deps.api.addr_make("alice"); + + let admin = test.admin_info(); + CONTRACT.set_reduced_deposit_price( + test.exec_ctx(admin), + addr.to_string(), + coin(10_000_000, TEST_DENOM), + )?; + + let admin = test.admin_info(); + CONTRACT.set_reduced_deposit_price( + test.exec_ctx(admin), + addr.to_string(), + coin(5_000_000, TEST_DENOM), + )?; + + let stored = CONTRACT.get_reduced_deposit_amount(test.query_ctx(), addr.to_string())?; + + assert_eq!(stored, Some(coin(5_000_000, TEST_DENOM))); + + Ok(()) + } + + // --- remove_reduced_deposit_price --- + + #[test] + fn remove_reduced_deposit_price_requires_admin() -> anyhow::Result<()> { + let mut test = TestSetup::init(); + let non_admin = test.deps.api.addr_make("non_admin"); + let addr = test.deps.api.addr_make("alice"); + + let err = CONTRACT + .remove_reduced_deposit_price( + test.exec_ctx(message_info(&non_admin, &[])), + addr.to_string(), + ) + .unwrap_err(); + + assert!(matches!(err, EcashContractError::Admin(_))); + + Ok(()) + } + + #[test] + fn remove_reduced_deposit_price_clears_stored_price() -> anyhow::Result<()> { + let mut test = TestSetup::init(); + let addr = test.deps.api.addr_make("alice"); + + let admin = test.admin_info(); + CONTRACT.set_reduced_deposit_price( + test.exec_ctx(admin), + addr.to_string(), + coin(10_000_000, TEST_DENOM), + )?; + + let admin = test.admin_info(); + CONTRACT.remove_reduced_deposit_price(test.exec_ctx(admin), addr.to_string())?; + + let stored = CONTRACT.get_reduced_deposit_amount(test.query_ctx(), addr.to_string())?; + + assert!(stored.is_none()); + + Ok(()) + } + + #[test] + fn remove_reduced_deposit_price_errors_for_unlisted_address() -> anyhow::Result<()> { + let mut test = TestSetup::init(); + let addr = test.deps.api.addr_make("alice"); + + let admin = test.admin_info(); + let err = CONTRACT + .remove_reduced_deposit_price(test.exec_ctx(admin), addr.to_string()) + .unwrap_err(); + + assert_eq!( + err, + EcashContractError::NoReducedDepositPrice { + address: addr.to_string() + } + ); + + Ok(()) + } + + // --- get_deposits_statistics --- + + #[test] + fn get_deposits_statistics_returns_zeroes_after_init() -> anyhow::Result<()> { + let test = TestSetup::init(); + + let stats = CONTRACT.get_deposits_statistics(test.query_ctx())?; + + assert_eq!(stats.total_deposits_made, 0); + assert_eq!(stats.total_deposited, coin(0, TEST_DENOM)); + assert_eq!(stats.total_deposits_made_with_default_price, 0); + assert_eq!( + stats.total_deposited_with_default_price, + coin(0, TEST_DENOM) + ); + assert_eq!(stats.total_deposits_made_with_custom_price, 0); + assert_eq!(stats.total_deposited_with_custom_price, coin(0, TEST_DENOM)); + assert!(stats.deposits_made_with_custom_price.is_empty()); + assert!(stats.deposited_with_custom_price.is_empty()); + + CONTRACT + .deposit_stats + .assert_counts_consistent(test.deps.as_ref().storage, stats.total_deposits_made); + + Ok(()) + } + + #[test] + fn deposit_stats_invariant_holds_after_mixed_deposits() -> anyhow::Result<()> { + let mut test = TestSetup::init(); + let alice = test.deps.api.addr_make("alice"); + let bob = test.deps.api.addr_make("bob"); + + // whitelist alice + let admin = test.admin_info(); + CONTRACT.set_reduced_deposit_price( + test.exec_ctx(admin), + alice.to_string(), + coin(10_000_000, TEST_DENOM), + )?; + + // alice deposits at reduced price + let alice_info = message_info(&alice, &[coin(10_000_000, TEST_DENOM)]); + CONTRACT.deposit_ticket_book_funds( + test.exec_ctx(alice_info), + "GLdR2NRVZBiCoCbv4fNqt9wUJZAnNjGXHkx3TjVAUzrK".to_string(), + )?; + + // bob deposits at default price + let bob_info = message_info(&bob, &[coin(75_000_000, TEST_DENOM)]); + CONTRACT.deposit_ticket_book_funds( + test.exec_ctx(bob_info), + "GLdR2NRVZBiCoCbv4fNqt9wUJZAnNjGXHkx3TjVAUzrK".to_string(), + )?; + + // alice deposits again at reduced price + let alice_info = message_info(&alice, &[coin(10_000_000, TEST_DENOM)]); + CONTRACT.deposit_ticket_book_funds( + test.exec_ctx(alice_info), + "GLdR2NRVZBiCoCbv4fNqt9wUJZAnNjGXHkx3TjVAUzrK".to_string(), + )?; + + // alice deposits at the default price — should be treated as a normal deposit + let alice_info = message_info(&alice, &[coin(75_000_000, TEST_DENOM)]); + CONTRACT.deposit_ticket_book_funds( + test.exec_ctx(alice_info), + "GLdR2NRVZBiCoCbv4fNqt9wUJZAnNjGXHkx3TjVAUzrK".to_string(), + )?; + + let total = CONTRACT + .deposits + .total_deposits_made(test.deps.as_ref().storage)?; + assert_eq!(total, 4); + + CONTRACT + .deposit_stats + .assert_counts_consistent(test.deps.as_ref().storage, total); + + Ok(()) + } } diff --git a/contracts/ecash/src/deposit.rs b/contracts/ecash/src/deposit.rs index 11d13831b8..f71b23a55d 100644 --- a/contracts/ecash/src/deposit.rs +++ b/contracts/ecash/src/deposit.rs @@ -29,6 +29,14 @@ impl DepositStorage { .map_err(Into::into) } + /// Returns the total number of deposits ever made. + /// + /// The deposit id counter stores the next available id, which equals the + /// total count (first deposit gets id 0, counter becomes 1, and so on). + pub fn total_deposits_made(&self, storage: &dyn Storage) -> Result { + Ok(self.deposit_id_counter.may_load(storage)?.unwrap_or(0)) + } + fn next_id(&self, store: &mut dyn Storage) -> Result { let id: DepositId = self.deposit_id_counter.may_load(store)?.unwrap_or_default(); let next_id = id + 1; @@ -115,8 +123,8 @@ impl StoredDeposits { #[cfg(test)] mod tests { use super::*; - use crate::support::tests::test_rng; use cosmwasm_std::testing::mock_dependencies; + use nym_contracts_common_testing::test_rng; use nym_crypto::asymmetric::ed25519; #[test] @@ -142,6 +150,23 @@ mod tests { Ok(()) } + #[test] + fn total_deposits_made_tracks_count() -> anyhow::Result<()> { + let mut deps = mock_dependencies(); + let storage = DepositStorage::new(); + + assert_eq!(storage.total_deposits_made(deps.as_ref().storage)?, 0); + + let _ = storage.next_id(deps.as_mut().storage)?; + assert_eq!(storage.total_deposits_made(deps.as_ref().storage)?, 1); + + let _ = storage.next_id(deps.as_mut().storage)?; + let _ = storage.next_id(deps.as_mut().storage)?; + assert_eq!(storage.total_deposits_made(deps.as_ref().storage)?, 3); + + Ok(()) + } + #[test] fn iterating_over_deposits() { let mut deps = mock_dependencies(); diff --git a/contracts/ecash/src/deposit_stats.rs b/contracts/ecash/src/deposit_stats.rs new file mode 100644 index 0000000000..3dcc935c28 --- /dev/null +++ b/contracts/ecash/src/deposit_stats.rs @@ -0,0 +1,374 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use cosmwasm_std::{coin, Addr, Coin, Order, StdResult, Storage}; +use cw_storage_plus::{Item, Map}; +use nym_ecash_contract_common::EcashContractError; +use std::collections::HashMap; + +pub(crate) struct DepositStatsStorage { + /// Total deposits performed with the default price + pub(crate) deposits_with_default_price: Item, + + /// Total amounts deposited with the default price + pub(crate) deposits_with_default_price_amounts: Item, + + /// Total deposits performed with a custom price by account + pub(crate) deposits_with_custom_price: Map, + + /// Total amounts deposited with a custom price by account + pub(crate) deposits_with_custom_price_amounts: Map, +} + +impl DepositStatsStorage { + pub(crate) const fn new() -> Self { + Self { + deposits_with_default_price: Item::new("deposits_with_default_price"), + deposits_with_default_price_amounts: Item::new("deposits_with_default_price_amounts"), + deposits_with_custom_price: Map::new("deposits_with_custom_price"), + deposits_with_custom_price_amounts: Map::new("deposits_with_custom_price_amounts"), + } + } + + pub(crate) fn new_default_deposit( + &self, + store: &mut dyn Storage, + deposited: &Coin, + ) -> Result<(), EcashContractError> { + self.deposits_with_default_price + .update(store, |count| StdResult::Ok(count + 1))?; + self.deposits_with_default_price_amounts + .update(store, |amount| { + let mut updated = amount; + updated.amount += deposited.amount; + StdResult::Ok(updated) + })?; + + Ok(()) + } + + pub(crate) fn new_reduced_deposit( + &self, + store: &mut dyn Storage, + sender: &Addr, + deposited: &Coin, + ) -> Result<(), EcashContractError> { + self.deposits_with_custom_price + .update(store, sender.clone(), |count| { + StdResult::Ok(count.unwrap_or_default() + 1) + })?; + + self.deposits_with_custom_price_amounts + .update(store, sender.clone(), |amount| { + let updated = match amount { + None => deposited.clone(), + Some(mut existing) => { + existing.amount += deposited.amount; + existing + } + }; + StdResult::Ok(updated) + })?; + + Ok(()) + } + + pub(crate) fn get_total_deposits_made_with_default_price( + &self, + store: &dyn Storage, + ) -> StdResult { + Ok(self + .deposits_with_default_price + .may_load(store)? + .unwrap_or(0)) + } + + pub(crate) fn get_total_deposited_with_default_price( + &self, + store: &dyn Storage, + denom: &str, + ) -> StdResult { + Ok(self + .deposits_with_default_price_amounts + .may_load(store)? + .unwrap_or_else(|| coin(0, denom))) + } + + pub(crate) fn get_custom_price_deposits( + &self, + store: &dyn Storage, + denom: &str, + ) -> StdResult { + let mut total_count = 0; + let mut total_amount = coin(0, denom); + let mut per_account_count = HashMap::new(); + let mut per_account_amount = HashMap::new(); + + for item in self + .deposits_with_custom_price + .range(store, None, None, Order::Ascending) + { + let (addr, count) = item?; + total_count += count; + per_account_count.insert(addr.into_string(), count); + } + + for item in + self.deposits_with_custom_price_amounts + .range(store, None, None, Order::Ascending) + { + let (addr, amount) = item?; + total_amount.amount += amount.amount; + per_account_amount.insert(addr.into_string(), amount); + } + + Ok(CustomPriceDepositStats { + total_count, + total_amount, + per_account_count, + per_account_amount, + }) + } +} + +impl DepositStatsStorage { + /// Asserts that the per-tier deposit counts sum to the given total. + /// Only meaningful when all deposits go through the contract entry point + /// (not after raw storage writes that bypass bookkeeping). + #[cfg(test)] + pub(crate) fn assert_counts_consistent(&self, store: &dyn Storage, total_deposits_made: u32) { + let default_count = self + .get_total_deposits_made_with_default_price(store) + .unwrap(); + let custom = self.get_custom_price_deposits(store, "unused").unwrap(); + assert_eq!( + default_count + custom.total_count, + total_deposits_made, + "deposit stats invariant violated: default ({default_count}) + custom ({}) != total ({total_deposits_made})", + custom.total_count, + ); + } +} + +pub(crate) struct CustomPriceDepositStats { + pub(crate) total_count: u32, + pub(crate) total_amount: Coin, + pub(crate) per_account_count: HashMap, + pub(crate) per_account_amount: HashMap, +} + +#[cfg(test)] +mod tests { + use super::*; + use cosmwasm_std::coin; + use cosmwasm_std::testing::mock_dependencies; + + const DENOM: &str = "unym"; + const DEFAULT_AMOUNT: u128 = 75_000_000; + const REDUCED_AMOUNT: u128 = 10_000_000; + + /// Mirror what `instantiate` does: zero-initialise the default-price counters. + /// The custom-price Maps need no initialisation (they start empty). + fn init_stats(storage: &mut dyn Storage) -> DepositStatsStorage { + let stats = DepositStatsStorage::new(); + stats.deposits_with_default_price.save(storage, &0).unwrap(); + stats + .deposits_with_default_price_amounts + .save(storage, &coin(0, DENOM)) + .unwrap(); + stats + } + + #[test] + fn single_default_deposit_increments_count_and_amount() { + let mut deps = mock_dependencies(); + let stats = init_stats(deps.as_mut().storage); + + stats + .new_default_deposit(deps.as_mut().storage, &coin(DEFAULT_AMOUNT, DENOM)) + .unwrap(); + + assert_eq!( + stats + .deposits_with_default_price + .load(deps.as_ref().storage) + .unwrap(), + 1 + ); + assert_eq!( + stats + .deposits_with_default_price_amounts + .load(deps.as_ref().storage) + .unwrap(), + coin(DEFAULT_AMOUNT, DENOM) + ); + } + + #[test] + fn multiple_default_deposits_accumulate() { + let mut deps = mock_dependencies(); + let stats = init_stats(deps.as_mut().storage); + + for _ in 0..3 { + stats + .new_default_deposit(deps.as_mut().storage, &coin(DEFAULT_AMOUNT, DENOM)) + .unwrap(); + } + + assert_eq!( + stats + .deposits_with_default_price + .load(deps.as_ref().storage) + .unwrap(), + 3 + ); + assert_eq!( + stats + .deposits_with_default_price_amounts + .load(deps.as_ref().storage) + .unwrap(), + coin(DEFAULT_AMOUNT * 3, DENOM) + ); + } + + #[test] + fn single_reduced_deposit_is_tracked_per_address() { + let mut deps = mock_dependencies(); + let stats = init_stats(deps.as_mut().storage); + let alice = deps.api.addr_make("alice"); + + stats + .new_reduced_deposit(deps.as_mut().storage, &alice, &coin(REDUCED_AMOUNT, DENOM)) + .unwrap(); + + assert_eq!( + stats + .deposits_with_custom_price + .load(deps.as_ref().storage, alice.clone()) + .unwrap(), + 1 + ); + assert_eq!( + stats + .deposits_with_custom_price_amounts + .load(deps.as_ref().storage, alice.clone()) + .unwrap(), + coin(REDUCED_AMOUNT, DENOM) + ); + // default-price stats must be untouched + assert_eq!( + stats + .deposits_with_default_price + .load(deps.as_ref().storage) + .unwrap(), + 0 + ); + } + + #[test] + fn multiple_reduced_deposits_same_address_accumulate() { + let mut deps = mock_dependencies(); + let stats = init_stats(deps.as_mut().storage); + let alice = deps.api.addr_make("alice"); + + for _ in 0..4 { + stats + .new_reduced_deposit(deps.as_mut().storage, &alice, &coin(REDUCED_AMOUNT, DENOM)) + .unwrap(); + } + + assert_eq!( + stats + .deposits_with_custom_price + .load(deps.as_ref().storage, alice.clone()) + .unwrap(), + 4 + ); + assert_eq!( + stats + .deposits_with_custom_price_amounts + .load(deps.as_ref().storage, alice.clone()) + .unwrap(), + coin(REDUCED_AMOUNT * 4, DENOM) + ); + } + + #[test] + fn reduced_deposits_for_different_addresses_tracked_independently() { + let mut deps = mock_dependencies(); + let stats = init_stats(deps.as_mut().storage); + let alice = deps.api.addr_make("alice"); + let bob = deps.api.addr_make("bob"); + + stats + .new_reduced_deposit(deps.as_mut().storage, &alice, &coin(REDUCED_AMOUNT, DENOM)) + .unwrap(); + stats + .new_reduced_deposit(deps.as_mut().storage, &alice, &coin(REDUCED_AMOUNT, DENOM)) + .unwrap(); + stats + .new_reduced_deposit(deps.as_mut().storage, &bob, &coin(5_000_000, DENOM)) + .unwrap(); + + assert_eq!( + stats + .deposits_with_custom_price + .load(deps.as_ref().storage, alice.clone()) + .unwrap(), + 2 + ); + assert_eq!( + stats + .deposits_with_custom_price + .load(deps.as_ref().storage, bob.clone()) + .unwrap(), + 1 + ); + assert_eq!( + stats + .deposits_with_custom_price_amounts + .load(deps.as_ref().storage, alice) + .unwrap(), + coin(REDUCED_AMOUNT * 2, DENOM) + ); + assert_eq!( + stats + .deposits_with_custom_price_amounts + .load(deps.as_ref().storage, bob) + .unwrap(), + coin(5_000_000, DENOM) + ); + } + + #[test] + fn default_and_reduced_stats_do_not_interfere() { + let mut deps = mock_dependencies(); + let stats = init_stats(deps.as_mut().storage); + let alice = deps.api.addr_make("alice"); + + stats + .new_default_deposit(deps.as_mut().storage, &coin(DEFAULT_AMOUNT, DENOM)) + .unwrap(); + stats + .new_reduced_deposit(deps.as_mut().storage, &alice, &coin(REDUCED_AMOUNT, DENOM)) + .unwrap(); + stats + .new_default_deposit(deps.as_mut().storage, &coin(DEFAULT_AMOUNT, DENOM)) + .unwrap(); + + assert_eq!( + stats + .deposits_with_default_price + .load(deps.as_ref().storage) + .unwrap(), + 2 + ); + assert_eq!( + stats + .deposits_with_custom_price + .load(deps.as_ref().storage, alice) + .unwrap(), + 1 + ); + } +} diff --git a/contracts/ecash/src/helpers.rs b/contracts/ecash/src/helpers.rs index 82f5767ecc..ec313af2a6 100644 --- a/contracts/ecash/src/helpers.rs +++ b/contracts/ecash/src/helpers.rs @@ -22,6 +22,9 @@ pub(crate) const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); pub struct Config { pub group_addr: Cw4Contract, pub holding_account: Addr, + + /// Specifies the expected default deposit amount if the sender is not in the whitelisted set. + #[serde(alias = "default_deposit_amount")] pub deposit_amount: Coin, } diff --git a/contracts/ecash/src/lib.rs b/contracts/ecash/src/lib.rs index a66566e247..83359af34b 100644 --- a/contracts/ecash/src/lib.rs +++ b/contracts/ecash/src/lib.rs @@ -1,15 +1,10 @@ // Copyright 2024 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 -#![warn(clippy::expect_used)] -#![warn(clippy::unwrap_used)] -#![warn(clippy::todo)] -#![warn(clippy::dbg_macro)] - mod constants; pub mod contract; mod deposit; +mod deposit_stats; mod helpers; #[cfg(test)] pub mod multitest; -mod support; diff --git a/contracts/ecash/src/multitest.rs b/contracts/ecash/src/multitest.rs index 74b3d2c443..e157eeda00 100644 --- a/contracts/ecash/src/multitest.rs +++ b/contracts/ecash/src/multitest.rs @@ -10,6 +10,9 @@ use sylvia::{cw_multi_test::App as MtApp, multitest::App}; use crate::contract::sv::mt::{CodeId, NymEcashContractProxy}; +const DENOM: &str = "unym"; +const DEPOSIT_AMOUNT: u128 = 75_000_000; + #[test] fn invalid_deposit() { let owner = "owner".into_bech32(); @@ -73,3 +76,198 @@ fn invalid_deposit() { EcashContractError::InvalidDeposit(PaymentError::MissingDenom(denom.to_string())) ); } + +#[test] +fn wrong_deposit_amount() { + let owner = "owner".into_bech32(); + + let mtapp = MtApp::new(|router, _, storage| { + router + .bank + .init_balance(storage, &owner, vec![Coin::new(1_000_000_000u128, DENOM)]) + .unwrap() + }); + let app = App::new(mtapp); + let code_id = CodeId::store_code(&app); + let contract = code_id + .instantiate( + MockApi::default().addr_make("holding_account").to_string(), + MockApi::default().addr_make("multisig_addr").to_string(), + MockApi::default().addr_make("group_addr").to_string(), + coin(DEPOSIT_AMOUNT, DENOM), + ) + .call(&owner) + .unwrap(); + + let vk = "GLdR2NRVZBiCoCbv4fNqt9wUJZAnNjGXHkx3TjVAUzrK"; + + // too little + assert_eq!( + contract + .deposit_ticket_book_funds(vk.to_string()) + .with_funds(&[coin(1_000_000u128, DENOM)]) + .call(&owner) + .unwrap_err(), + EcashContractError::WrongAmount { + received: coin(1_000_000u128, DENOM), + amount: coin(DEPOSIT_AMOUNT, DENOM), + } + ); + + // too much + assert_eq!( + contract + .deposit_ticket_book_funds(vk.to_string()) + .with_funds(&[coin(100_000_000u128, DENOM)]) + .call(&owner) + .unwrap_err(), + EcashContractError::WrongAmount { + received: coin(100_000_000u128, DENOM), + amount: coin(DEPOSIT_AMOUNT, DENOM), + } + ); +} + +#[test] +fn correct_default_deposit_succeeds() { + let owner = "owner".into_bech32(); + + let mtapp = MtApp::new(|router, _, storage| { + router + .bank + .init_balance(storage, &owner, vec![Coin::new(1_000_000_000u128, DENOM)]) + .unwrap() + }); + let app = App::new(mtapp); + let code_id = CodeId::store_code(&app); + let contract = code_id + .instantiate( + MockApi::default().addr_make("holding_account").to_string(), + MockApi::default().addr_make("multisig_addr").to_string(), + MockApi::default().addr_make("group_addr").to_string(), + coin(DEPOSIT_AMOUNT, DENOM), + ) + .call(&owner) + .unwrap(); + + let vk = "GLdR2NRVZBiCoCbv4fNqt9wUJZAnNjGXHkx3TjVAUzrK"; + + contract + .deposit_ticket_book_funds(vk.to_string()) + .with_funds(&[coin(DEPOSIT_AMOUNT, DENOM)]) + .call(&owner) + .unwrap(); +} + +#[test] +fn reduced_price_deposit_end_to_end() { + let owner = "owner".into_bech32(); + let whitelisted = "whitelisted".into_bech32(); + let non_whitelisted = "non_whitelisted".into_bech32(); + let reduced_amount: u128 = 10_000_000; + + let mtapp = MtApp::new(|router, _, storage| { + router + .bank + .init_balance( + storage, + &whitelisted, + vec![Coin::new(1_000_000_000u128, DENOM)], + ) + .unwrap(); + router + .bank + .init_balance( + storage, + &non_whitelisted, + vec![Coin::new(1_000_000_000u128, DENOM)], + ) + .unwrap(); + }); + let app = App::new(mtapp); + let code_id = CodeId::store_code(&app); + let contract = code_id + .instantiate( + MockApi::default().addr_make("holding_account").to_string(), + MockApi::default().addr_make("multisig_addr").to_string(), + MockApi::default().addr_make("group_addr").to_string(), + coin(DEPOSIT_AMOUNT, DENOM), + ) + .call(&owner) + .unwrap(); + + let vk = "GLdR2NRVZBiCoCbv4fNqt9wUJZAnNjGXHkx3TjVAUzrK"; + + // whitelist an address with a reduced price + contract + .set_reduced_deposit_price(whitelisted.to_string(), coin(reduced_amount, DENOM)) + .call(&owner) + .unwrap(); + + // whitelisted address can deposit at the reduced price + contract + .deposit_ticket_book_funds(vk.to_string()) + .with_funds(&[coin(reduced_amount, DENOM)]) + .call(&whitelisted) + .unwrap(); + + // whitelisted address can also deposit at the default price — + // treated as a normal (non-reduced) deposit for statistics purposes + contract + .deposit_ticket_book_funds(vk.to_string()) + .with_funds(&[coin(DEPOSIT_AMOUNT, DENOM)]) + .call(&whitelisted) + .unwrap(); + + // whitelisted address is rejected when sending an amount that is + // neither the reduced nor the default price + assert_eq!( + contract + .deposit_ticket_book_funds(vk.to_string()) + .with_funds(&[coin(50_000_000, DENOM)]) + .call(&whitelisted) + .unwrap_err(), + EcashContractError::WrongAmount { + received: coin(50_000_000, DENOM), + amount: coin(reduced_amount, DENOM), + } + ); + + // non-whitelisted address is rejected at the reduced amount + assert_eq!( + contract + .deposit_ticket_book_funds(vk.to_string()) + .with_funds(&[coin(reduced_amount, DENOM)]) + .call(&non_whitelisted) + .unwrap_err(), + EcashContractError::WrongAmount { + received: coin(reduced_amount, DENOM), + amount: coin(DEPOSIT_AMOUNT, DENOM), + } + ); + + // non-whitelisted address succeeds at the default amount + contract + .deposit_ticket_book_funds(vk.to_string()) + .with_funds(&[coin(DEPOSIT_AMOUNT, DENOM)]) + .call(&non_whitelisted) + .unwrap(); + + let stats = contract.get_deposits_statistics().unwrap(); + assert_eq!(stats.total_deposits_made, 3); + assert_eq!( + stats.total_deposited, + coin(reduced_amount + DEPOSIT_AMOUNT * 2, DENOM) + ); + // whitelisted depositing at default price + non-whitelisted = 2 default deposits + assert_eq!(stats.total_deposits_made_with_default_price, 2); + assert_eq!( + stats.total_deposited_with_default_price, + coin(DEPOSIT_AMOUNT * 2, DENOM) + ); + assert_eq!(stats.total_deposits_made_with_custom_price, 1); + assert_eq!( + stats.total_deposited_with_custom_price, + coin(reduced_amount, DENOM) + ); +} diff --git a/contracts/ecash/src/support/mod.rs b/contracts/ecash/src/support/mod.rs deleted file mode 100644 index f217570509..0000000000 --- a/contracts/ecash/src/support/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -// Copyright 2024 - Nym Technologies SA -// SPDX-License-Identifier: Apache-2.0 - -#[cfg(test)] -pub mod tests; diff --git a/contracts/ecash/src/support/tests.rs b/contracts/ecash/src/support/tests.rs deleted file mode 100644 index de31ea4e6d..0000000000 --- a/contracts/ecash/src/support/tests.rs +++ /dev/null @@ -1,104 +0,0 @@ -// Copyright 2024 - Nym Technologies SA -// SPDX-License-Identifier: Apache-2.0 - -use crate::contract::NymEcashContract; -use crate::helpers::Config; -use cosmwasm_std::testing::{message_info, mock_dependencies, mock_env, MockApi, MockQuerier}; -use cosmwasm_std::{coin, Addr, Deps, Empty, Env, MemoryStorage, MessageInfo, OwnedDeps}; -use rand_chacha::rand_core::SeedableRng; -use rand_chacha::ChaCha20Rng; -use sylvia::ctx::{ExecCtx, InstantiateCtx, QueryCtx}; - -pub fn test_rng() -> ChaCha20Rng { - let dummy_seed = [42u8; 32]; - ChaCha20Rng::from_seed(dummy_seed) -} - -const CONTRACT: NymEcashContract = NymEcashContract::new(); - -const DENOM: &str = "unym"; - -#[allow(dead_code)] -pub struct TestSetupSimple { - pub deps: OwnedDeps>, - pub env: Env, - pub rng: ChaCha20Rng, - pub owner: Addr, - pub holding_account: Addr, - pub multisig_contract: Addr, - pub group_contract: Addr, -} - -impl TestSetupSimple { - pub fn new() -> Self { - let mut deps = mock_dependencies(); - let env = mock_env(); - let owner = deps.api.addr_make("owner"); - - let rng = test_rng(); - let holding_account = deps.api.addr_make("holding_account"); - let multisig_contract = deps.api.addr_make("multisig_contract"); - let group_contract = deps.api.addr_make("group_contract"); - - let init_ctx = - InstantiateCtx::from((deps.as_mut(), env.clone(), message_info(&owner, &[]))); - - CONTRACT - .instantiate( - init_ctx, - holding_account.to_string(), - multisig_contract.to_string(), - group_contract.to_string(), - coin(75_000_000, DENOM.to_string()), - ) - .unwrap(); - - TestSetupSimple { - deps, - env, - rng, - owner, - holding_account, - multisig_contract, - group_contract, - } - } - - pub fn admin(&self) -> MessageInfo { - let admin = CONTRACT - .contract_admin - .get(self.deps.as_ref()) - .unwrap() - .unwrap(); - message_info(&admin, &[]) - } - - pub fn execute_ctx(&mut self, sender: MessageInfo) -> ExecCtx<'_> { - let env = self.env.clone(); - ExecCtx::from((self.deps.as_mut(), env, sender)) - } - - #[allow(dead_code)] - pub fn query_ctx(&self) -> QueryCtx<'_> { - QueryCtx::from((self.deps.as_ref(), self.env.clone())) - } - - pub fn contract(&self) -> NymEcashContract { - CONTRACT - } - - pub fn deps(&self) -> Deps<'_> { - self.deps.as_ref() - } - - pub fn config(&self) -> Config { - CONTRACT.config.load(self.deps().storage).unwrap() - } - - pub fn with_deposit_amount(mut self, amount: u128) -> Self { - CONTRACT - .update_deposit_value(self.execute_ctx(self.admin()), coin(amount, DENOM)) - .unwrap(); - self - } -} diff --git a/nym-wallet/Cargo.lock b/nym-wallet/Cargo.lock index 0d8b48e1b2..da28ebe4e7 100644 --- a/nym-wallet/Cargo.lock +++ b/nym-wallet/Cargo.lock @@ -2992,7 +2992,7 @@ dependencies = [ "once_cell", "rand 0.9.2", "ring", - "rustls 0.23.36", + "rustls 0.23.37", "thiserror 2.0.12", "tinyvec", "tokio", @@ -3017,7 +3017,7 @@ dependencies = [ "parking_lot", "rand 0.9.2", "resolv-conf", - "rustls 0.23.36", + "rustls 0.23.37", "smallvec", "thiserror 2.0.12", "tokio", @@ -3256,7 +3256,7 @@ dependencies = [ "http 1.3.1", "hyper 1.8.1", "hyper-util", - "rustls 0.23.36", + "rustls 0.23.37", "rustls-pki-types", "tokio", "tokio-rustls 0.26.2", @@ -4726,6 +4726,7 @@ dependencies = [ "nym-http-api-common", "once_cell", "reqwest 0.13.2", + "rustls 0.23.37", "serde", "serde_json", "serde_plain", @@ -6103,7 +6104,7 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash", - "rustls 0.23.36", + "rustls 0.23.37", "socket2 0.5.9", "thiserror 2.0.12", "tokio", @@ -6123,7 +6124,7 @@ dependencies = [ "rand 0.9.2", "ring", "rustc-hash", - "rustls 0.23.36", + "rustls 0.23.37", "rustls-pki-types", "slab", "thiserror 2.0.12", @@ -6426,7 +6427,7 @@ dependencies = [ "percent-encoding", "pin-project-lite", "quinn", - "rustls 0.23.36", + "rustls 0.23.37", "rustls-pemfile 2.2.0", "rustls-pki-types", "serde", @@ -6469,7 +6470,7 @@ dependencies = [ "percent-encoding", "pin-project-lite", "quinn", - "rustls 0.23.36", + "rustls 0.23.37", "rustls-pki-types", "rustls-platform-verifier", "serde", @@ -6641,9 +6642,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.36" +version = "0.23.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" dependencies = [ "aws-lc-rs", "log", @@ -6718,7 +6719,7 @@ dependencies = [ "jni", "log", "once_cell", - "rustls 0.23.36", + "rustls 0.23.37", "rustls-native-certs 0.8.3", "rustls-platform-verifier-android", "rustls-webpki 0.103.9", @@ -8291,7 +8292,7 @@ version = "0.26.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b" dependencies = [ - "rustls 0.23.36", + "rustls 0.23.37", "tokio", ] From 7b77091fb111649bcbdfad1f6f2439cb3ef1e428 Mon Sep 17 00:00:00 2001 From: Jack Wampler Date: Thu, 26 Mar 2026 11:27:14 -0600 Subject: [PATCH 16/23] Nym Node spam logging (#6621) prevent spam logs when downstream node is slow --- common/client-libs/mixnet-client/src/client.rs | 2 +- nym-node/src/node/mixnet/packet_forwarding/mod.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/common/client-libs/mixnet-client/src/client.rs b/common/client-libs/mixnet-client/src/client.rs index ffd4d025e1..0bdb06a198 100644 --- a/common/client-libs/mixnet-client/src/client.rs +++ b/common/client-libs/mixnet-client/src/client.rs @@ -342,7 +342,7 @@ impl SendWithoutResponse for Client { sending_res.map_err(|err| { match err { TrySendError::Full(_) => { - warn!( + trace!( event = "mixclient.try_send", peer = %address, result = "full_dropped", diff --git a/nym-node/src/node/mixnet/packet_forwarding/mod.rs b/nym-node/src/node/mixnet/packet_forwarding/mod.rs index a6a5b32d73..6fa21d31c5 100644 --- a/nym-node/src/node/mixnet/packet_forwarding/mod.rs +++ b/nym-node/src/node/mixnet/packet_forwarding/mod.rs @@ -56,7 +56,7 @@ impl PacketForwarder { if let Err(err) = self.mixnet_client.send_without_response(packet) { if err.kind() == io::ErrorKind::WouldBlock { // we only know for sure if we dropped a packet if our sending queue was full - warn!( + trace!( event = "packet.dropped.buffer_full", next_hop = %next_hop, "dropping packet: egress connection buffer full (WouldBlock)" From dd4bbc070884505cfc1d90e3a3e13ec68f06a39b Mon Sep 17 00:00:00 2001 From: jmwample Date: Thu, 26 Mar 2026 11:36:04 -0600 Subject: [PATCH 17/23] nym-api moved default --- common/http-api-client/src/dns/constants.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/http-api-client/src/dns/constants.rs b/common/http-api-client/src/dns/constants.rs index 5a7c35037f..3359310d94 100644 --- a/common/http-api-client/src/dns/constants.rs +++ b/common/http-api-client/src/dns/constants.rs @@ -4,7 +4,7 @@ use std::collections::HashMap; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; pub const NYM_API_DOMAIN: &str = "validator.nymtech.net"; -pub const NYM_API_IPS: &[IpAddr] = &[IpAddr::V4(Ipv4Addr::new(212, 71, 233, 232))]; +pub const NYM_API_IPS: &[IpAddr] = &[IpAddr::V4(Ipv4Addr::new(92, 39, 63, 14))]; pub const NYM_VPN_API_DOMAIN: &str = "nymvpn.com"; pub const NYM_VPN_API_IPS: &[IpAddr] = &[IpAddr::V4(Ipv4Addr::new(76, 76, 21, 21))]; From c07ef0253d9fd858a96179f031c100d773f8b61e Mon Sep 17 00:00:00 2001 From: mfahampshire Date: Fri, 27 Mar 2026 20:35:26 +0000 Subject: [PATCH 18/23] Max/sdk stream wrapper (#6320) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Replace MixnetStream with LP framing - Replace custom header with LpFrameHeader - Added sequence number for message ordering * IPR: support LP Stream-framed client connections - Detect and route LP Stream frames in mixnet_listener - Wrap inline responses in LP Stream frames - Thread stream_id to ConnectedClientHandler for TUN responses * sdk: add ipr_wrapper module with IpMixStream - IpMixStream wraps MixnetStream for IPR tunnel over mixnet - LP Stream framing handled automatically by MixnetStream - Gateway discovery, connect handshake, IP packet send/receive * sdk: remove superseded stream_wrapper module * Trim obvious comments, add architecture.md stub * sdk: add missing deps and fix warnings * Cut down architecture diagram until finished with rest of the code, leaving stubs * sdk: refactor IpMixStream, extract shared helpers - Extract gateway discovery and connect response parsing - Add recv() to MixnetStream, remove 64KB read buffer - Simplify IpMixStream constructor * Fix SphinxStream renames missed during rebase * Add IpPacketResponse::from_bytes() for stream-based deserialization * Clean up ip_packet_client: delete stale connect.rs, take raw bytes not ReconstructedMessage * Clippy * Delete unused ip_packet_client modules - Remove helpers.rs (ICMP utilities moved to example) - Remove error.rs (errors consolidated into sdk/error.rs) - Remove README.md - Update module root to only export discovery + listener * Simplify listener, IpMixStream, and network_env - Collapse IprListener struct into standalone handle_ipr_response() - Move check_ipr_message_version() into listener.rs - Remove IpMixStream test module (moved to example) - Remove parse_network() and commented-out Sandbox arms - Return Result from find_workspace_root() instead of panicking - Add IprTunnelDisconnected and WorkspaceRootNotFound error variants * Refactor IPR stream handling and document seq conventions - Inline stream_id tracking (remove current_stream_id field) - Re-export encode_stream_frame from clients module - Document seq=0 reservation for inline control responses - Document data-path counter starting at 1 with skip-on-wrap * Add ipr_tunnel example for integration testing - ICMP ping through IPR with --gateway flag for targeting specific exits - Move pnet_packet from dependencies to dev-dependencies * Add message reordering to stream router - Buffer out-of-order messages per-stream using BTreeMap - Drain contiguous sequences individually to preserve message boundaries - Drop duplicate/old sequence numbers with a warning - Remove dead_code allow on StreamFrame::sequence_num * Clean up comments and fill architecture.md - Remove separator line comments - Update stale comments about ordering not being implemented - Remove collapsible_if allows, use let-else instead - Fill in architecture.md data flow and connection lifecycle * Simplify ipr_tunnel example to minimal smoke test - Single ping instead of multi-ping loop - Remove identifier and PING_COUNT - Collapse ICMP helpers into single build_icmp_ping function * Add dual-stack IPv6 ping and rename gateway → ipr - Rename --gateway flag to --ipr and new_with_gateway() to new_with_ipr() - Add ICMPv6 ping to ipr_tunnel example for dual-stack smoke test - Tighten echo reply validation (protocol field check, diagnostic output) - Document IP allocation (subnets, static vs dynamic, client keying) in architecture.md - Promote LP Stream Open handshake log to INFO * Tweak subnet comment in docs * Don't stop IPR listener on decode failure - Change break to continue so garbage packets can't kill the listener - Remaining valid packets in the bundle are still processed * Fix license headers and use workspace dep for pnet_packet - Switch GPL-3.0 to Apache-2.0 on all SDK library files - Add missing license headers to 7 files - Use workspace version for pnet_packet dependency * Document IP pool isolation from WG/LP dVPN pool - IPR uses 10.0.0.0/16 on nymtun, WG uses 10.1.0.0/16 on nymwg - Reference constants.rs as source of truth * Remove network_env.rs and simplify IpMixStream API - Default to mainnet via setup_env(None) instead of requiring env param - Remove NetworkEnvironment enum and workspace root detection - Remove WorkspaceRootNotFound error variant - Update ipr_tunnel example to match new signatures * Use weighted random selection for IPR gateway discovery - Replace max_by_key with choose_weighted biased by performance score - Prevents all clients converging on a single highest-performing IPR * Cap stream reorder buffer to prevent unbounded memory growth - Add MAX_REORDER_BUFFER (256) to limit per-stream pending messages: - buffer overflows = skip ahead to lowest buffered seq and drain - protects against malicious senders that deliberately skip sequence numbers * Extract shared IPR response helpers into nym-ip-packet-requests - Add response_helpers module with version check, connect response parsing, and control response dispatch - SDK ip_packet_client now delegates to shared module - Monorepo nym-ip-packet-client uses shared version check and connect response parsing - Fix doc comment attributing fork to nym-vpn-client * Extract ICMP test helpers into nym-ip-packet-requests - Add icmp_utils module behind test-utils feature flag - Move build_icmp_ping, build_icmpv6_ping, is_echo_reply_v4/v6 from example - Update ipr_tunnel example to use shared helpers * Add protocol v9 LP-framed transport marker - Add v9 module (re-exports v8, VERSION=9) - Accept v9 requests and responses in IPR - Switch SDK IpMixStream to send v9 * Log protocol version in dynamic connect requests * Remove KCP from IPR and fix unwrap_or_default in SDK - Remove all KCP session management from ip-packet-router (replaced by LP Stream framing) - Drop nym-kcp dependency and KcpError variant from IPR - Replace unwrap_or_default with ok_or(Error::NoNymAPIUrl) in IpMixStream::new() * Add v9 protocol wrapper constructors and enforce version/transport consistency - Add v9::new_connect_request(), new_data_request(), new_ip_packet_response() to centralise version stamping - Replace manual protocol.version overrides in SDK and IPR with v9 wrapper calls - Bump nym-ip-packet-client current re-export from v8 to v9 - Enforce LP Stream frames must carry v9+ payloads, non-stream must be v8 or lower * Filter IPR exit nodes by minimum v9-compatible release version - Define MIN_RELEASE_VERSION (1.30.0) in ip-packet-requests/v9 alongside protocol constants - Add semver-based filtering in SDK gateway discovery to skip nodes below v9 threshold - Add semver dependency to ip-packet-requests and nym-sdk * Use numeric version comparison for transport/version enforcement - Compare version as u8 instead of enum equality so future v10+ is handled correctly - Remove unused `use super::*` import left over from KCP test removal --- Cargo.lock | 8 +- common/ip-packet-requests/Cargo.toml | 6 + common/ip-packet-requests/src/icmp_utils.rs | 117 ++++ common/ip-packet-requests/src/lib.rs | 6 + .../src/response_helpers.rs | 134 +++++ common/ip-packet-requests/src/v8/response.rs | 8 +- common/ip-packet-requests/src/v9/mod.rs | 34 ++ nym-ip-packet-client/src/connect.rs | 45 +- nym-ip-packet-client/src/helpers.rs | 36 +- nym-ip-packet-client/src/lib.rs | 2 +- sdk/rust/nym-sdk/Cargo.toml | 5 + sdk/rust/nym-sdk/examples/ipr_tunnel.rs | 92 ++++ sdk/rust/nym-sdk/src/error.rs | 36 ++ sdk/rust/nym-sdk/src/ip_packet_client.rs | 17 + .../nym-sdk/src/ip_packet_client/discovery.rs | 121 +++++ .../nym-sdk/src/ip_packet_client/listener.rs | 20 + sdk/rust/nym-sdk/src/ipr_wrapper.rs | 13 + .../nym-sdk/src/ipr_wrapper/architecture.md | 80 +++ .../nym-sdk/src/ipr_wrapper/ip_mix_stream.rs | 192 +++++++ sdk/rust/nym-sdk/src/lib.rs | 6 + .../src/mixnet/stream/mixnet_stream.rs | 14 + sdk/rust/nym-sdk/src/mixnet/stream/mod.rs | 113 +++- .../nym-sdk/src/mixnet/stream/protocol.rs | 4 +- service-providers/ip-packet-router/Cargo.toml | 2 +- .../src/clients/connected_client_handler.rs | 61 ++- .../ip-packet-router/src/clients/mod.rs | 2 +- .../ip-packet-router/src/error.rs | 4 +- .../ip-packet-router/src/ip_packet_router.rs | 1 - .../src/kcp_session_manager.rs | 504 ------------------ service-providers/ip-packet-router/src/lib.rs | 1 - .../ip-packet-router/src/messages/mod.rs | 2 + .../ip-packet-router/src/messages/request.rs | 26 + .../src/messages/request/v9.rs | 27 + .../ip-packet-router/src/messages/response.rs | 1 + .../ip-packet-router/src/mixnet_listener.rs | 471 +++++++--------- 35 files changed, 1338 insertions(+), 873 deletions(-) create mode 100644 common/ip-packet-requests/src/icmp_utils.rs create mode 100644 common/ip-packet-requests/src/response_helpers.rs create mode 100644 common/ip-packet-requests/src/v9/mod.rs create mode 100644 sdk/rust/nym-sdk/examples/ipr_tunnel.rs create mode 100644 sdk/rust/nym-sdk/src/ip_packet_client.rs create mode 100644 sdk/rust/nym-sdk/src/ip_packet_client/discovery.rs create mode 100644 sdk/rust/nym-sdk/src/ip_packet_client/listener.rs create mode 100644 sdk/rust/nym-sdk/src/ipr_wrapper.rs create mode 100644 sdk/rust/nym-sdk/src/ipr_wrapper/architecture.md create mode 100644 sdk/rust/nym-sdk/src/ipr_wrapper/ip_mix_stream.rs delete mode 100644 service-providers/ip-packet-router/src/kcp_session_manager.rs create mode 100644 service-providers/ip-packet-router/src/messages/request/v9.rs diff --git a/Cargo.lock b/Cargo.lock index f24cf1b3a7..59c16f3898 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6957,12 +6957,15 @@ dependencies = [ "nym-crypto", "nym-service-provider-requests-common", "nym-sphinx", + "pnet_packet", "rand 0.8.5", + "semver 1.0.27", "serde", "thiserror 2.0.12", "time", "tokio", "tokio-util", + "tracing", ] [[package]] @@ -6985,7 +6988,7 @@ dependencies = [ "nym-exit-policy", "nym-id", "nym-ip-packet-requests", - "nym-kcp", + "nym-lp", "nym-network-defaults", "nym-network-requester", "nym-sdk", @@ -7792,6 +7795,7 @@ dependencies = [ "nym-crypto", "nym-gateway-requests", "nym-http-api-client", + "nym-ip-packet-requests", "nym-lp", "nym-network-defaults", "nym-ordered-buffer", @@ -7805,8 +7809,10 @@ dependencies = [ "nym-topology", "nym-validator-client", "parking_lot", + "pnet_packet", "rand 0.8.5", "reqwest 0.13.1", + "semver 1.0.27", "serde", "tap", "tempfile", diff --git a/common/ip-packet-requests/Cargo.toml b/common/ip-packet-requests/Cargo.toml index 487ff4d782..0d062b2d45 100644 --- a/common/ip-packet-requests/Cargo.toml +++ b/common/ip-packet-requests/Cargo.toml @@ -10,7 +10,11 @@ license.workspace = true description = "Codec, signing functionality, and different version definitions for IP packet request and responses" +[features] +test-utils = ["pnet_packet"] + [dependencies] +pnet_packet = { workspace = true, optional = true } bincode = { workspace = true } bytes = { workspace = true } nym-bin-common = { workspace = true } @@ -18,8 +22,10 @@ nym-crypto = { workspace = true } nym-service-provider-requests-common = { workspace = true } nym-sphinx = { workspace = true } rand = { workspace = true } +semver = { workspace = true } serde = { workspace = true, features = ["derive"] } thiserror = { workspace = true } time = { workspace = true } tokio = { workspace = true, features = ["time"] } tokio-util = { workspace = true, features = ["codec"] } +tracing = { workspace = true } diff --git a/common/ip-packet-requests/src/icmp_utils.rs b/common/ip-packet-requests/src/icmp_utils.rs new file mode 100644 index 0000000000..9dd9b1baea --- /dev/null +++ b/common/ip-packet-requests/src/icmp_utils.rs @@ -0,0 +1,117 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +// Extracted from sdk/rust/nym-sdk/examples/ipr_tunnel.rs + +//! ICMP/ICMPv6 packet construction and reply detection helpers for testing +//! IPR connectivity. Gated behind the `test-utils` feature. + +use std::net::{Ipv4Addr, Ipv6Addr}; + +use pnet_packet::Packet; +use pnet_packet::icmp::echo_reply::EchoReplyPacket; +use pnet_packet::icmp::echo_request::MutableEchoRequestPacket; +use pnet_packet::icmp::{IcmpPacket, IcmpTypes}; +use pnet_packet::icmpv6::Icmpv6Types; +use pnet_packet::ipv4::{Ipv4Flags, MutableIpv4Packet}; +use pnet_packet::ipv6::MutableIpv6Packet; + +/// Build a complete IPv4 ICMP echo request packet. +pub fn build_icmp_ping(src: Ipv4Addr, dst: Ipv4Addr, seq: u16) -> Option> { + let mut echo = MutableEchoRequestPacket::owned(vec![0u8; 64])?; + echo.set_icmp_type(IcmpTypes::EchoRequest); + echo.set_icmp_code(pnet_packet::icmp::IcmpCode::new(0)); + echo.set_sequence_number(seq); + let cksum = pnet_packet::icmp::checksum(&IcmpPacket::new(echo.packet())?); + echo.set_checksum(cksum); + + let total_len = 20 + echo.packet().len(); + let mut ip = MutableIpv4Packet::owned(vec![0u8; total_len])?; + ip.set_version(4); + ip.set_header_length(5); + ip.set_total_length(total_len as u16); + ip.set_ttl(64); + ip.set_next_level_protocol(pnet_packet::ip::IpNextHeaderProtocols::Icmp); + ip.set_source(src); + ip.set_destination(dst); + ip.set_flags(Ipv4Flags::DontFragment); + ip.set_payload(echo.packet()); + + let mut buf = ip.consume_to_immutable().packet().to_vec(); + let cksum = ipv4_checksum(&buf); + buf[10] = (cksum >> 8) as u8; + buf[11] = cksum as u8; + Some(buf) +} + +/// Build a complete IPv6 ICMPv6 echo request packet. +pub fn build_icmpv6_ping(src: Ipv6Addr, dst: Ipv6Addr, seq: u16) -> Option> { + let mut echo = + pnet_packet::icmpv6::echo_request::MutableEchoRequestPacket::owned(vec![0u8; 64])?; + echo.set_icmpv6_type(Icmpv6Types::EchoRequest); + echo.set_icmpv6_code(pnet_packet::icmpv6::Icmpv6Code::new(0)); + echo.set_sequence_number(seq); + let cksum = pnet_packet::icmpv6::checksum( + &pnet_packet::icmpv6::Icmpv6Packet::new(echo.packet())?, + &src, + &dst, + ); + echo.set_checksum(cksum); + + let payload_len = echo.packet().len(); + let mut ip = MutableIpv6Packet::owned(vec![0u8; 40 + payload_len])?; + ip.set_version(6); + ip.set_payload_length(payload_len as u16); + ip.set_next_header(pnet_packet::ip::IpNextHeaderProtocols::Icmpv6); + ip.set_hop_limit(64); + ip.set_source(src); + ip.set_destination(dst); + ip.set_payload(echo.packet()); + + Some(ip.consume_to_immutable().packet().to_vec()) +} + +/// Check if a raw packet is an IPv4 ICMP echo reply destined to `expected_dst`. +pub fn is_echo_reply_v4(data: &[u8], expected_dst: Ipv4Addr) -> bool { + let Some(ip) = pnet_packet::ipv4::Ipv4Packet::new(data) else { + return false; + }; + if ip.get_destination() != expected_dst { + return false; + } + if ip.get_next_level_protocol() != pnet_packet::ip::IpNextHeaderProtocols::Icmp { + return false; + } + let Some(reply) = EchoReplyPacket::new(ip.payload()) else { + return false; + }; + reply.get_icmp_type() == IcmpTypes::EchoReply +} + +/// Check if a raw packet is an IPv6 ICMPv6 echo reply destined to `expected_dst`. +pub fn is_echo_reply_v6(data: &[u8], expected_dst: Ipv6Addr) -> bool { + let Some(ip) = pnet_packet::ipv6::Ipv6Packet::new(data) else { + return false; + }; + if ip.get_destination() != expected_dst { + return false; + } + if ip.get_next_header() != pnet_packet::ip::IpNextHeaderProtocols::Icmpv6 { + return false; + } + let Some(reply) = pnet_packet::icmpv6::echo_reply::EchoReplyPacket::new(ip.payload()) else { + return false; + }; + reply.get_icmpv6_type() == Icmpv6Types::EchoReply +} + +fn ipv4_checksum(header: &[u8]) -> u16 { + let mut sum = 0u32; + for i in (0..20).step_by(2) { + sum += ((header[i] as u32) << 8) | header[i + 1] as u32; + } + while (sum >> 16) > 0 { + sum = (sum & 0xFFFF) + (sum >> 16); + } + !sum as u16 +} diff --git a/common/ip-packet-requests/src/lib.rs b/common/ip-packet-requests/src/lib.rs index 977a277446..f3014a69ec 100644 --- a/common/ip-packet-requests/src/lib.rs +++ b/common/ip-packet-requests/src/lib.rs @@ -3,10 +3,14 @@ use std::fmt::{Display, Formatter}; use std::net::{Ipv4Addr, Ipv6Addr}; pub mod codec; +#[cfg(feature = "test-utils")] +pub mod icmp_utils; +pub mod response_helpers; pub mod sign; pub mod v6; pub mod v7; pub mod v8; +pub mod v9; // version 3: initial version // version 4: IPv6 support @@ -14,6 +18,8 @@ pub mod v8; // version 6: Increase the available IPs // version 7: Add signature support (for the future) // version 8: Anonymous sends +// version 9: LP-framed transport (SphinxStream) +// response_helpers: shared IPR response parsing (nym-ip-packet-client + nym-sdk) #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct IpPair { diff --git a/common/ip-packet-requests/src/response_helpers.rs b/common/ip-packet-requests/src/response_helpers.rs new file mode 100644 index 0000000000..5caa81ab0d --- /dev/null +++ b/common/ip-packet-requests/src/response_helpers.rs @@ -0,0 +1,134 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use bytes::{Bytes, BytesMut}; +use tokio_util::codec::Decoder; +use tracing::{error, info, warn}; + +use crate::{ + IpPair, + codec::MultiIpPacketCodec, + v8::response::{ + ConnectResponseReply, ControlResponse, InfoLevel, IpPacketResponse, IpPacketResponseData, + }, +}; + +#[derive(Debug, thiserror::Error)] +pub enum IprResponseError { + #[error("no version byte in message")] + NoVersionByte, + + #[error("version mismatch: received v{received}, expected v{expected}")] + VersionMismatch { expected: u8, received: u8 }, + + #[error("expected control response, got {0:?}")] + UnexpectedResponse(IpPacketResponseData), + + #[error("connect denied: {0:?}")] + ConnectDenied(crate::v8::response::ConnectFailureReason), +} + +pub enum MixnetMessageOutcome { + IpPackets(Vec), + Disconnect, +} + +// Extracted from: +// nym-ip-packet-client/src/helpers.rs — check_ipr_message_version() +// sdk/rust/nym-sdk/src/ip_packet_client/listener.rs — check_ipr_message_version() +/// Check that the first byte of an IPR message matches the expected protocol version. +pub fn check_ipr_message_version(data: &[u8], expected: u8) -> Result<(), IprResponseError> { + let version = data.first().ok_or(IprResponseError::NoVersionByte)?; + if *version != expected { + return Err(IprResponseError::VersionMismatch { + expected, + received: *version, + }); + } + Ok(()) +} + +// Extracted from: +// nym-ip-packet-client/src/connect.rs — handle_connect_response() + handle_ip_packet_router_response() +// sdk/rust/nym-sdk/src/ip_packet_client/discovery.rs — parse_connect_response() +/// Parse an IPR connect response, returning allocated IPs on success. +pub fn parse_connect_response(response: IpPacketResponse) -> Result { + let control_response = match response.data { + IpPacketResponseData::Control(c) => c, + other => return Err(IprResponseError::UnexpectedResponse(other)), + }; + + match *control_response { + ControlResponse::Connect(connect_resp) => match connect_resp.reply { + ConnectResponseReply::Success(success) => Ok(success.ips), + ConnectResponseReply::Failure(reason) => Err(IprResponseError::ConnectDenied(reason)), + }, + _ => Err(IprResponseError::UnexpectedResponse( + IpPacketResponseData::Control(control_response), + )), + } +} + +// Extracted from: +// nym-ip-packet-client/src/listener.rs — IprListener::handle_reconstructed_message() +// sdk/rust/nym-sdk/src/ip_packet_client/listener.rs — handle_ipr_response() +/// Parse raw IPR response bytes into an outcome. +/// +/// Logs non-fatal conditions (unknown control messages, deserialization +/// failures) and returns `None` for them. +pub fn handle_ipr_response(data: &[u8]) -> Option { + match IpPacketResponse::from_bytes(data) { + Ok(response) => match response.data { + IpPacketResponseData::Data(data_response) => { + let mut codec = MultiIpPacketCodec::new(); + let mut buf = BytesMut::from(data_response.ip_packet.as_ref()); + let mut packets = Vec::new(); + loop { + match codec.decode(&mut buf) { + Ok(Some(packet)) => packets.push(packet.into_bytes()), + Ok(None) => break, + Err(e) => { + warn!("Failed to decode bundled IP packet: {e}"); + break; + } + } + } + Some(MixnetMessageOutcome::IpPackets(packets)) + } + IpPacketResponseData::Control(control_response) => match *control_response { + ControlResponse::Connect(_) => { + info!("Received connect response when already connected - ignoring"); + None + } + ControlResponse::Disconnect(_) | ControlResponse::UnrequestedDisconnect(_) => { + info!("Received disconnect from IPR"); + Some(MixnetMessageOutcome::Disconnect) + } + ControlResponse::Pong(_) => { + info!("Received pong response"); + None + } + ControlResponse::Health(_) => { + info!("Received health response"); + None + } + ControlResponse::Info(info_resp) => { + let msg = format!( + "Received info response from the mixnet: {}", + info_resp.reply + ); + match info_resp.level { + InfoLevel::Info => info!("{msg}"), + InfoLevel::Warn => warn!("{msg}"), + InfoLevel::Error => error!("{msg}"), + } + None + } + }, + }, + Err(err) => { + warn!("Failed to deserialize IPR response: {err}"); + None + } + } +} diff --git a/common/ip-packet-requests/src/v8/response.rs b/common/ip-packet-requests/src/v8/response.rs index 12256252bd..1dce990caa 100644 --- a/common/ip-packet-requests/src/v8/response.rs +++ b/common/ip-packet-requests/src/v8/response.rs @@ -179,11 +179,15 @@ impl IpPacketResponse { make_bincode_serializer().serialize(self) } + pub fn from_bytes(data: &[u8]) -> Result { + use bincode::Options; + make_bincode_serializer().deserialize(data) + } + pub fn from_reconstructed_message( message: &nym_sphinx::receiver::ReconstructedMessage, ) -> Result { - use bincode::Options; - make_bincode_serializer().deserialize(&message.message) + Self::from_bytes(&message.message) } } diff --git a/common/ip-packet-requests/src/v9/mod.rs b/common/ip-packet-requests/src/v9/mod.rs new file mode 100644 index 0000000000..881a7c19d9 --- /dev/null +++ b/common/ip-packet-requests/src/v9/mod.rs @@ -0,0 +1,34 @@ +pub const VERSION: u8 = 9; + +/// Minimum nym-node release version that supports v9 (LP Stream framing). +/// Nodes running older versions will not understand LP-wrapped packets. +pub const MIN_RELEASE_VERSION: semver::Version = semver::Version::new(1, 30, 0); + +// v9 uses the same wire format as v8. The version bump indicates +// the message was sent with LP framing (SphinxStream). +// +// Types are re-exported for deserialization/matching. Use the wrapper +// constructors below to create correctly-versioned packets — never +// manually set `protocol.version` or `response.version`. +pub use super::v8::{request, response}; + +/// Create a v9 connect request (version byte set to 9). +pub fn new_connect_request(buffer_timeout: Option) -> (request::IpPacketRequest, u64) { + let (mut req, id) = request::IpPacketRequest::new_connect_request(buffer_timeout); + req.protocol.version = VERSION; + (req, id) +} + +/// Create a v9 data request (version byte set to 9). +pub fn new_data_request(data: bytes::Bytes) -> request::IpPacketRequest { + let mut req = request::IpPacketRequest::new_data_request(data); + req.protocol.version = VERSION; + req +} + +/// Create a v9 IP packet response (version byte set to 9). +pub fn new_ip_packet_response(ip_packet: bytes::Bytes) -> response::IpPacketResponse { + let mut resp = response::IpPacketResponse::new_ip_packet(ip_packet); + resp.version = VERSION; + resp +} diff --git a/nym-ip-packet-client/src/connect.rs b/nym-ip-packet-client/src/connect.rs index bf679f67cf..0448c0cf40 100644 --- a/nym-ip-packet-client/src/connect.rs +++ b/nym-ip-packet-client/src/connect.rs @@ -11,14 +11,10 @@ use tokio::time::sleep; use tokio_util::sync::CancellationToken; use tracing::{debug, error}; +use nym_ip_packet_requests::response_helpers::{self, IprResponseError}; + use crate::{ - current::{ - request::IpPacketRequest, - response::{ - ConnectResponse, ConnectResponseReply, ControlResponse, IpPacketResponse, - IpPacketResponseData, - }, - }, + current::{request::IpPacketRequest, response::IpPacketResponse}, error::{Error, Result}, helpers::check_ipr_message_version, }; @@ -101,32 +97,6 @@ impl IprClientConnect { Ok(request_id) } - async fn handle_connect_response(&self, response: ConnectResponse) -> Result { - debug!("Handling dynamic connect response"); - match response.reply { - ConnectResponseReply::Success(r) => Ok(r.ips), - ConnectResponseReply::Failure(reason) => Err(Error::ConnectRequestDenied { reason }), - } - } - - async fn handle_ip_packet_router_response(&self, response: IpPacketResponse) -> Result { - let control_response = match response.data { - IpPacketResponseData::Control(control_response) => control_response, - _ => { - error!("Received non-control response while waiting for connect response"); - return Err(Error::UnexpectedConnectResponse); - } - }; - - match *control_response { - ControlResponse::Connect(resp) => self.handle_connect_response(resp).await, - response => { - error!("Unexpected response: {response:?}"); - Err(Error::UnexpectedConnectResponse) - } - } - } - async fn listen_for_connect_response(&mut self, request_id: u64) -> Result { // Connecting is basically synchronous from the perspective of the mixnet client, so it's safe // to just grab ahold of the mutex and keep it until we get the response. @@ -173,7 +143,14 @@ impl IprClientConnect { if response.id() == Some(request_id) { tracing::debug!("Got response with matching id"); - return self.handle_ip_packet_router_response(response).await; + // Replaces local handle_ip_packet_router_response() + handle_connect_response() + return response_helpers::parse_connect_response(response) + .map_err(|e| match e { + IprResponseError::ConnectDenied(reason) => { + Error::ConnectRequestDenied { reason } + } + _ => Error::UnexpectedConnectResponse, + }); } } } diff --git a/nym-ip-packet-client/src/helpers.rs b/nym-ip-packet-client/src/helpers.rs index 8198a8a7b7..1b31870ff8 100644 --- a/nym-ip-packet-client/src/helpers.rs +++ b/nym-ip-packet-client/src/helpers.rs @@ -1,30 +1,24 @@ // Copyright 2023-2024 - Nym Technologies SA // SPDX-License-Identifier: GPL-3.0-only -use std::cmp::Ordering; - +use nym_ip_packet_requests::response_helpers::IprResponseError; use nym_sdk::mixnet::ReconstructedMessage; -use crate::{Error, current::VERSION as CURRENT_VERSION, error::Result}; +use crate::{current::VERSION as CURRENT_VERSION, error::Result}; pub(crate) fn check_ipr_message_version(message: &ReconstructedMessage) -> Result<()> { - // Assuming it's a IPR message, it will have a version as its first byte - if let Some(version) = message.message.first() { - match version.cmp(&CURRENT_VERSION) { - Ordering::Greater => Err(Error::ReceivedResponseWithNewVersion { - expected: CURRENT_VERSION, - received: *version, - }), - Ordering::Less => Err(Error::ReceivedResponseWithOldVersion { - expected: CURRENT_VERSION, - received: *version, - }), - Ordering::Equal => { - // We're good - Ok(()) - } + nym_ip_packet_requests::response_helpers::check_ipr_message_version( + &message.message, + CURRENT_VERSION, + ) + .map_err(|e| match e { + IprResponseError::NoVersionByte => crate::Error::NoVersionInMessage, + IprResponseError::VersionMismatch { expected, received } if received < expected => { + crate::Error::ReceivedResponseWithOldVersion { expected, received } } - } else { - Err(Error::NoVersionInMessage) - } + IprResponseError::VersionMismatch { expected, received } => { + crate::Error::ReceivedResponseWithNewVersion { expected, received } + } + _ => crate::Error::NoVersionInMessage, + }) } diff --git a/nym-ip-packet-client/src/lib.rs b/nym-ip-packet-client/src/lib.rs index 8aa5ffdf42..0ab5c2eb24 100644 --- a/nym-ip-packet-client/src/lib.rs +++ b/nym-ip-packet-client/src/lib.rs @@ -11,4 +11,4 @@ pub use error::Error; pub use listener::{IprListener, MixnetMessageOutcome}; // Re-export the currently used version -pub use nym_ip_packet_requests::v8 as current; +pub use nym_ip_packet_requests::v9 as current; diff --git a/sdk/rust/nym-sdk/Cargo.toml b/sdk/rust/nym-sdk/Cargo.toml index ded83e807a..9fddca8295 100644 --- a/sdk/rust/nym-sdk/Cargo.toml +++ b/sdk/rust/nym-sdk/Cargo.toml @@ -65,6 +65,9 @@ url = { workspace = true } toml = { workspace = true } tempfile = { workspace = true } +nym-ip-packet-requests = { workspace = true } +semver = { workspace = true } + # tcpproxy dependencies clap = { workspace = true, features = ["derive"] } anyhow.workspace = true @@ -94,6 +97,8 @@ tokio-stream = { workspace = true } tokio-util = { workspace = true, features = ["codec"] } parking_lot = { workspace = true } hex = { workspace = true } +pnet_packet = { workspace = true } +nym-ip-packet-requests = { workspace = true, features = ["test-utils"] } [features] libp2p-vanilla = [] diff --git a/sdk/rust/nym-sdk/examples/ipr_tunnel.rs b/sdk/rust/nym-sdk/examples/ipr_tunnel.rs new file mode 100644 index 0000000000..9bf242827e --- /dev/null +++ b/sdk/rust/nym-sdk/examples/ipr_tunnel.rs @@ -0,0 +1,92 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +// Smoke test for IpMixStream: connect to an IPR, send a ping, check we get a reply. +// Tests both IPv4 and IPv6 paths. +// +// Usage: +// cargo run --example ipr_tunnel +// cargo run --example ipr_tunnel -- --ipr +// +// e.g. cargo run --example ipr_tunnel -- --ipr 6B6iuWX4bQP4GVA4Yq7XmZencaaGw6BaPY6xJWYSwsbF.6g6LRx1fgU2Q2A4ZPKonYHtfBARh1GPMe1LtXk6vpRR8@q2A2cbooyC16YJzvdYaSMH9X3cSiieZNtfBr8cE8Fi1 + +use std::net::{Ipv4Addr, Ipv6Addr}; +use std::time::Duration; + +use nym_ip_packet_requests::codec::MultiIpPacketCodec; +use nym_ip_packet_requests::icmp_utils::{ + build_icmp_ping, build_icmpv6_ping, is_echo_reply_v4, is_echo_reply_v6, +}; +use nym_sdk::ipr_wrapper::IpMixStream; + +const PING4_TARGET: Ipv4Addr = Ipv4Addr::new(8, 8, 8, 8); +const PING6_TARGET: Ipv6Addr = Ipv6Addr::new(0x2001, 0x4860, 0x4860, 0, 0, 0, 0, 0x8888); + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + nym_bin_common::logging::setup_tracing_logger(); + + let args: Vec = std::env::args().collect(); + let ipr_addr = args + .iter() + .position(|a| a == "--ipr") + .and_then(|i| args.get(i + 1)); + + let mut tunnel = if let Some(addr) = ipr_addr { + let recipient = addr.parse().expect("invalid IPR address"); + IpMixStream::new_with_ipr(recipient).await? + } else { + IpMixStream::new().await? + }; + + let ips = tunnel.allocated_ips(); + let src4 = ips.ipv4; + let src6 = ips.ipv6; + println!("Tunnel up — IPv4: {src4}, IPv6: {src6}"); + + // Send IPv4 ping (ICMP seq=0, unrelated to LP Stream sequence numbers) + let pkt4 = build_icmp_ping(src4, PING4_TARGET, 0).expect("failed to build ICMP packet"); + let bundled = MultiIpPacketCodec::bundle_one_packet(pkt4.into()); + tunnel.send_ip_packet(&bundled).await?; + println!("Sent ping → {PING4_TARGET}"); + + // Send IPv6 ping + let pkt6 = build_icmpv6_ping(src6, PING6_TARGET, 0).expect("failed to build ICMPv6 packet"); + let bundled = MultiIpPacketCodec::bundle_one_packet(pkt6.into()); + tunnel.send_ip_packet(&bundled).await?; + println!("Sent ping → {PING6_TARGET}"); + + let mut got_v4 = false; + let mut got_v6 = false; + let deadline = tokio::time::sleep(Duration::from_secs(30)); + tokio::pin!(deadline); + + loop { + tokio::select! { + _ = &mut deadline => { + if !got_v4 { println!("FAIL — no IPv4 reply within 30s"); } + if !got_v6 { println!("FAIL — no IPv6 reply within 30s"); } + break; + } + result = tunnel.handle_incoming() => { + for pkt in result? { + if !got_v4 && is_echo_reply_v4(&pkt, src4) { + println!("OK — got IPv4 echo reply"); + got_v4 = true; + } + if !got_v6 && is_echo_reply_v6(&pkt, src6) { + println!("OK — got IPv6 echo reply"); + got_v6 = true; + } + if got_v4 && got_v6 { + tunnel.disconnect().await; + return Ok(()); + } + } + } + } + } + + tunnel.disconnect().await; + Ok(()) +} diff --git a/sdk/rust/nym-sdk/src/error.rs b/sdk/rust/nym-sdk/src/error.rs index 57e486172a..dc2a5da492 100644 --- a/sdk/rust/nym-sdk/src/error.rs +++ b/sdk/rust/nym-sdk/src/error.rs @@ -1,3 +1,8 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use nym_ip_packet_requests::v8::response::{ConnectFailureReason, IpPacketResponseData}; +use nym_validator_client::nym_api::error::NymAPIError; use nym_validator_client::nyxd::error::NyxdError; use std::path::PathBuf; @@ -108,6 +113,37 @@ pub enum Error { #[error("Stream subsystem failed to initialise: reconstructed_receiver unavailable")] StreamInitFailure, + + #[error("client not connected")] + IprStreamClientNotConnected, + + #[error("listening for connection response timed out")] + IPRConnectResponseTimeout, + + #[error("stream closed")] + IPRClientStreamClosed, + + #[error("expected control response, got {0:?}")] + UnexpectedResponseType(IpPacketResponseData), + + #[error("connect denied: {0:?}")] + ConnectDenied(ConnectFailureReason), + + #[allow(clippy::result_large_err)] + #[error("api directory error: {0}")] + GatewayDirectoryError(#[from] NymAPIError), + + #[error("did not receive Nym API URL")] + NoNymAPIUrl, + + #[error("no available gateway")] + NoGatewayAvailable, + + #[error("tunnel disconnected by IPR")] + IprTunnelDisconnected, + + #[error("message version check failed: {0}")] + IPRMessageVersionCheckFailed(String), } impl Error { diff --git a/sdk/rust/nym-sdk/src/ip_packet_client.rs b/sdk/rust/nym-sdk/src/ip_packet_client.rs new file mode 100644 index 0000000000..93a4dae694 --- /dev/null +++ b/sdk/rust/nym-sdk/src/ip_packet_client.rs @@ -0,0 +1,17 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +//! IPR helpers for [`IpMixStream`](crate::ipr_wrapper::IpMixStream). +//! +//! - **Discovery** (`discovery`): queries the Nym API for IPR-enabled exit gateways. +//! - **Response handling** (`listener`): thin wrappers around +//! [`nym_ip_packet_requests::response_helpers`] that add version checking +//! and error mapping for SDK use. + +pub mod discovery; +pub mod listener; + +pub use listener::{handle_ipr_response, MixnetMessageOutcome}; + +// Re-export the currently used version +pub use nym_ip_packet_requests::v9 as current; diff --git a/sdk/rust/nym-sdk/src/ip_packet_client/discovery.rs b/sdk/rust/nym-sdk/src/ip_packet_client/discovery.rs new file mode 100644 index 0000000000..998567a951 --- /dev/null +++ b/sdk/rust/nym-sdk/src/ip_packet_client/discovery.rs @@ -0,0 +1,121 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +//! IPR gateway discovery — find and rank IPR-enabled exit gateways via the Nym API. + +use std::collections::HashMap; + +use nym_crypto::asymmetric::ed25519; +use nym_ip_packet_requests::v9; +use nym_network_defaults::ApiUrl; +use nym_sphinx::addressing::clients::Recipient; +use nym_validator_client::nym_api::NymApiClientExt; +use tracing::{debug, error, info}; + +use rand::seq::SliceRandom; + +use crate::Error; + +#[derive(Clone)] +pub struct IprWithPerformance { + pub address: Recipient, + pub identity: ed25519::PublicKey, + pub performance: u8, +} + +#[allow(clippy::result_large_err)] +pub fn create_nym_api_client( + nym_api_urls: Vec, +) -> Result { + let user_agent = format!("nym-sdk/{}", env!("CARGO_PKG_VERSION")); + + let urls = nym_api_urls + .into_iter() + .map(|url| url.url.parse()) + .collect::, _>>() + .map_err(|err| { + error!("malformed nym-api url: {err}"); + Error::NoNymAPIUrl + })?; + + if urls.is_empty() { + return Err(Error::NoNymAPIUrl); + } + + let client = nym_http_api_client::ClientBuilder::new_with_urls(urls)? + .with_user_agent(user_agent) + .build()?; + + Ok(client) +} + +pub async fn retrieve_exit_nodes_with_performance( + client: nym_http_api_client::Client, +) -> Result, Error> { + let all_nodes = client + .get_all_described_nodes_v2() + .await? + .into_iter() + .map(|described| (described.ed25519_identity_key(), described)) + .collect::>(); + + let exit_gateways = client.get_all_basic_nodes_with_metadata().await?.nodes; + + let mut described = Vec::new(); + + for exit in exit_gateways { + let Some(node) = all_nodes.get(&exit.ed25519_identity_pubkey) else { + continue; + }; + + // Only select nodes running a version that supports v9 (LP Stream framing) + let Ok(node_version) = semver::Version::parse(node.version()) else { + debug!( + "Skipping node {}: unable to parse version '{}'", + exit.ed25519_identity_pubkey, + node.version() + ); + continue; + }; + if node_version < v9::MIN_RELEASE_VERSION { + debug!( + "Skipping node {}: version {} < minimum {}", + exit.ed25519_identity_pubkey, + node_version, + v9::MIN_RELEASE_VERSION + ); + continue; + } + + if let Some(ipr_info) = node.description.ip_packet_router.clone() { + if let Ok(parsed_address) = ipr_info.address.parse() { + described.push(IprWithPerformance { + address: parsed_address, + identity: exit.ed25519_identity_pubkey, + performance: exit.performance.round_to_integer(), + }) + } + } + } + + Ok(described) +} + +/// Select the highest-performance IPR gateway from the directory. +pub async fn get_best_ipr(client: nym_http_api_client::Client) -> Result { + let nodes = retrieve_exit_nodes_with_performance(client).await?; + info!("Found {} Exit Gateways", nodes.len()); + + let selected_ipr = nodes + .choose_weighted(&mut rand::thread_rng(), |gw| gw.performance as f64) + .map_err(|_| Error::NoGatewayAvailable)?; + + let ipr_address = selected_ipr.address; + + info!( + "Using IPR: {} (Gateway: {}, Performance: {:?})", + ipr_address, selected_ipr.identity, selected_ipr.performance + ); + + Ok(ipr_address) +} diff --git a/sdk/rust/nym-sdk/src/ip_packet_client/listener.rs b/sdk/rust/nym-sdk/src/ip_packet_client/listener.rs new file mode 100644 index 0000000000..28ab5cc61a --- /dev/null +++ b/sdk/rust/nym-sdk/src/ip_packet_client/listener.rs @@ -0,0 +1,20 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +pub use nym_ip_packet_requests::response_helpers::MixnetMessageOutcome; + +use nym_ip_packet_requests::response_helpers; + +use crate::ip_packet_client::current::VERSION as CURRENT_VERSION; + +/// Check that the first byte of an IPR message matches the expected protocol version. +pub(crate) fn check_ipr_message_version(data: &[u8]) -> Result<(), crate::Error> { + response_helpers::check_ipr_message_version(data, CURRENT_VERSION) + .map_err(|e| crate::Error::IPRMessageVersionCheckFailed(e.to_string())) +} + +/// Parse raw IPR response bytes into an outcome. +pub fn handle_ipr_response(data: &[u8]) -> Result, crate::Error> { + check_ipr_message_version(data)?; + Ok(response_helpers::handle_ipr_response(data)) +} diff --git a/sdk/rust/nym-sdk/src/ipr_wrapper.rs b/sdk/rust/nym-sdk/src/ipr_wrapper.rs new file mode 100644 index 0000000000..084b74bda0 --- /dev/null +++ b/sdk/rust/nym-sdk/src/ipr_wrapper.rs @@ -0,0 +1,13 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +//! High-level IPR (IP Packet Router) stream wrapper. +//! +//! [`IpMixStream`] tunnels IP packets through the Nym mixnet to an exit +//! gateway running an IP Packet Router. Both requests and responses are +//! wrapped in LP Stream frames for type-safe detection at the IPR and +//! dispatch by the client's stream router. + +mod ip_mix_stream; + +pub use ip_mix_stream::IpMixStream; diff --git a/sdk/rust/nym-sdk/src/ipr_wrapper/architecture.md b/sdk/rust/nym-sdk/src/ipr_wrapper/architecture.md new file mode 100644 index 0000000000..0c19e01527 --- /dev/null +++ b/sdk/rust/nym-sdk/src/ipr_wrapper/architecture.md @@ -0,0 +1,80 @@ +# IpMixStream Architecture + +## Overview + +`IpMixStream` tunnels IP packets through the Nym mixnet to an IP Packet Router +(IPR) exit gateway. It provides a high-level API over a single `MixnetStream`, +which handles LP Stream framing and Sphinx packet transport automatically. + +## Data Flow + +```text +Client IPR + | | + |-- IpPacketRequest (connect) ---- LP Stream ----->| + |<--- IpPacketResponse (ips) ---- LP Stream (s=0) -| + | | + |-- IpPacketRequest (data) ------- LP Stream ----->| -> TUN -> internet + |<--- IpPacketResponse (data) --- LP Stream (s=1+) | <- TUN <- internet +``` + +## Layer Stack + +```text +IpMixStream IPR protocol (connect, data, disconnect) +MixnetStream AsyncRead + AsyncWrite, LP Stream framing, seq numbers +Stream Router Dispatches inbound messages by stream_id +MixnetClient Sphinx packet encryption, SURB management +Mixnet Entry GW -> Mix1 -> Mix2 -> Mix3 -> Exit GW +``` + +## LP Stream Framing + +All messages between client and IPR are wrapped in LP Stream frames: + +- **Client -> IPR**: `MixnetStream.write()` wraps each write in an LP Stream + Data frame (stream_id, sequence number, payload). The IPR detects + `LpFrameKind::Stream` and strips the header before processing. + +- **IPR -> Client**: Both inline responses (connect handshake, pong) and async + TUN responses are wrapped in LP Stream frames with the same stream_id. The + client's stream router dispatches by stream_id to the correct `MixnetStream`. + +## IP Allocation + +The IPR owns a subnet (`10.0.0.0/16` for IPv4, `fc00::/112` for IPv6) and +allocates addresses to clients on connect. Two modes are supported: + +- **Dynamic** (used by `IpMixStream`): The IPR picks a random unused `IpPair` + from the pool (up to 100 retries). `IpMixStream` uses this path. +- **Static**: The client requests specific IPs; the IPR checks availability. + +On dynamic connect: + +1. IPR calls `find_new_ip()` — random selection from the subnet +2. Registers the client in dual `HashMap` / `HashMap` + maps (keyed by allocated IP). The client identity (anonymous sender tag + for v8, Nym address for v6/v7) is stored inside the `ConnectedClient` value. +3. Returns `DynamicConnectSuccess { ips }` to the client + +The client uses the allocated IPv4/IPv6 as its source address when constructing +packets. When the TUN device receives a response destined for that IP, the IPR +looks up which client owns it and forwards the packet back through the mixnet. + +Reserved addresses: `10.0.0.1` / `fc00::1` (TUN gateway, never allocated). + +> **Note — pool isolation from LP dVPN:** This pool (`10.0.0.0/16`, `fc00::/112` +> on `nymtun`) is separate from the authenticator/LP dVPN WireGuard pool +> (`10.1.0.0/16`, `fc01::/112` on `nymwg`). The second octet differs (`10.0` vs +> `10.1`) and each uses its own TUN interface. Both sets of constants live in +> `common/network-defaults/src/constants.rs` (`mixnet_vpn` vs `wireguard` modules). +> The IPR addresses are hardcoded; the WG addresses are configurable but default to +> the non-overlapping range. + +## Connection Lifecycle + +1. `IpMixStream::new()` discovers the best IPR via Nym API +2. Opens a `MixnetStream` to the IPR (`client.open_stream()`) +3. Sends a dynamic connect request, IPR allocates an `IpPair` +4. Ready for `send_ip_packet()` / `handle_incoming()` loop +5. `disconnect()` shuts down the mixnet client diff --git a/sdk/rust/nym-sdk/src/ipr_wrapper/ip_mix_stream.rs b/sdk/rust/nym-sdk/src/ipr_wrapper/ip_mix_stream.rs new file mode 100644 index 0000000000..13cfb56e23 --- /dev/null +++ b/sdk/rust/nym-sdk/src/ipr_wrapper/ip_mix_stream.rs @@ -0,0 +1,192 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 +use crate::ip_packet_client::{ + discovery::{create_nym_api_client, get_best_ipr}, + handle_ipr_response, + listener::check_ipr_message_version, + MixnetMessageOutcome, +}; +use crate::mixnet::{MixnetClient, MixnetStream, Recipient}; +use crate::Error; +use bytes::Bytes; +use nym_ip_packet_requests::response_helpers; +use nym_ip_packet_requests::{ + v9::{self, response::IpPacketResponse}, + IpPair, +}; +use nym_network_defaults::NymNetworkDetails; +use std::time::Duration; +use tokio::io::AsyncWriteExt; +use tracing::{debug, info}; + +const IPR_CONNECT_TIMEOUT: Duration = Duration::from_secs(60); + +/// A bidirectional tunnel for sending and receiving IP packets through the mixnet. +/// +/// Wraps a [`MixnetStream`] (opened to an IPR exit gateway) and provides a +/// high-level API for the IPR protocol. The underlying `MixnetStream` handles +/// LP Stream framing and stream multiplexing automatically. +/// +/// # Data flow +/// +/// ```text +/// IpMixStream.send_ip_packet(bytes) +/// → IpPacketRequest::to_bytes() → MixnetStream.write() +/// → LP Stream frame (stream_id, seq, Data) +/// → Sphinx packets → mixnet → IPR +/// +/// IPR processes request → TUN → internet → response +/// → IPR wraps in LP Stream frame → Sphinx → mixnet → client +/// → stream router dispatches by stream_id +/// → MixnetStream.recv() → IpPacketResponse bytes +/// → handle_ipr_response() → extract IP packets +/// ``` +pub struct IpMixStream { + stream: MixnetStream, + client: MixnetClient, + allocated_ips: IpPair, + connected: bool, +} + +impl IpMixStream { + /// Discover the best IPR, connect through the mixnet, and establish the IP tunnel. + /// + /// Returns a ready-to-use tunnel with allocated IP addresses. + pub async fn new() -> Result { + let network_defaults = NymNetworkDetails::new_mainnet(); + let api_client = + create_nym_api_client(network_defaults.nym_api_urls.ok_or(Error::NoNymAPIUrl)?)?; + let ipr_address = get_best_ipr(api_client).await?; + Self::new_with_ipr(ipr_address).await + } + + /// Connect to a specific IPR address. + /// + /// Use this when you already know the IPR `Recipient` address (e.g. for + /// testing against a specific exit node). For automatic discovery, use + /// [`IpMixStream::new`] instead. + pub async fn new_with_ipr(ipr_address: Recipient) -> Result { + nym_network_defaults::setup_env(None::<&str>); + let mut client = MixnetClient::connect_new().await?; + let mut stream = client.open_stream(ipr_address, Some(10)).await?; + + info!("Connecting to IP packet router at {ipr_address}"); + let allocated_ips = Self::connect_tunnel(&mut stream).await?; + info!( + "Connected — IPv4: {}, IPv6: {}", + allocated_ips.ipv4, allocated_ips.ipv6 + ); + + Ok(Self { + stream, + client, + allocated_ips, + connected: true, + }) + } + + pub fn nym_address(&self) -> &Recipient { + self.client.nym_address() + } + + pub fn allocated_ips(&self) -> &IpPair { + &self.allocated_ips + } + + pub fn is_connected(&self) -> bool { + self.connected + } + + /// Check that the tunnel is connected, returning an error if not. + pub fn check_connected(&self) -> Result<(), Error> { + if self.connected { + Ok(()) + } else { + Err(Error::IprStreamClientNotConnected) + } + } + + async fn connect_tunnel(stream: &mut MixnetStream) -> Result { + let (request, request_id) = v9::new_connect_request(None); + debug!("Sending connect request with ID: {}", request_id); + + let request_bytes = request.to_bytes()?; + stream + .write_all(&request_bytes) + .await + .map_err(|_| Error::MessageSendingFailure)?; + + let timeout = tokio::time::sleep(IPR_CONNECT_TIMEOUT); + tokio::pin!(timeout); + + loop { + tokio::select! { + _ = &mut timeout => { + return Err(Error::IPRConnectResponseTimeout); + } + result = stream.recv() => { + let data = result.ok_or(Error::IPRClientStreamClosed)?; + + check_ipr_message_version(&data)?; + if let Ok(response) = IpPacketResponse::from_bytes(&data) { + if response.id() == Some(request_id) { + return response_helpers::parse_connect_response(response) + .map_err(|e| match e { + response_helpers::IprResponseError::ConnectDenied(r) => Error::ConnectDenied(r), + response_helpers::IprResponseError::UnexpectedResponse(d) => Error::UnexpectedResponseType(d), + other => Error::IPRMessageVersionCheckFailed(other.to_string()), + }); + } + } + } + } + } + } + + /// Send an IP packet through the tunnel. + pub async fn send_ip_packet(&mut self, packet: &[u8]) -> Result<(), Error> { + self.check_connected()?; + let request = v9::new_data_request(packet.to_vec().into()); + let request_bytes = request.to_bytes()?; + self.stream + .write_all(&request_bytes) + .await + .map_err(|_| Error::MessageSendingFailure) + } + + /// Handle incoming messages from the mixnet. + /// + /// Reads from the underlying `MixnetStream`, parses IPR responses, and + /// extracts IP packets. Returns an empty vec on timeout (10 s). + pub async fn handle_incoming(&mut self) -> Result, Error> { + let data = match tokio::time::timeout(Duration::from_secs(10), self.stream.recv()).await { + Err(_) => return Ok(Vec::new()), + Ok(None) => { + self.connected = false; + return Err(Error::IPRClientStreamClosed); + } + Ok(Some(data)) => data, + }; + + match handle_ipr_response(&data) { + Ok(Some(MixnetMessageOutcome::IpPackets(packets))) => { + debug!("Extracted {} IP packets", packets.len()); + Ok(packets) + } + Ok(Some(MixnetMessageOutcome::Disconnect)) => { + info!("Received disconnect"); + self.connected = false; + Err(Error::IprTunnelDisconnected) + } + Ok(None) => Ok(Vec::new()), + Err(e) => Err(e), + } + } + + /// Disconnect from the Mixnet. Disconnected clients cannot be reconnected. + pub async fn disconnect(self) { + debug!("Disconnecting"); + self.client.disconnect().await; + debug!("Disconnected"); + } +} diff --git a/sdk/rust/nym-sdk/src/lib.rs b/sdk/rust/nym-sdk/src/lib.rs index 8b5bb45568..03fc5ccdbe 100644 --- a/sdk/rust/nym-sdk/src/lib.rs +++ b/sdk/rust/nym-sdk/src/lib.rs @@ -1,13 +1,19 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + //! Rust SDK for the Nym platform //! //! The main component currently is [`mixnet`]. //! [`tcp_proxy`] is probably a good place to start for anyone wanting to integrate with existing app code and read/write from a socket. //! [`client_pool`] is a configurable client pool. +//! [`ipr_wrapper`] tunnels IP packets through the mixnet to an IPR exit gateway. mod error; pub mod bandwidth; pub mod client_pool; +pub mod ip_packet_client; +pub mod ipr_wrapper; pub mod mixnet; pub mod tcp_proxy; diff --git a/sdk/rust/nym-sdk/src/mixnet/stream/mixnet_stream.rs b/sdk/rust/nym-sdk/src/mixnet/stream/mixnet_stream.rs index 5ba1d08853..fd8a9c879f 100644 --- a/sdk/rust/nym-sdk/src/mixnet/stream/mixnet_stream.rs +++ b/sdk/rust/nym-sdk/src/mixnet/stream/mixnet_stream.rs @@ -1,3 +1,6 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + //! Per-stream handle implementing `AsyncRead + AsyncWrite`. use std::pin::Pin; @@ -111,6 +114,17 @@ impl MixnetStream { self.id } + /// Receive a single message payload directly from the stream channel. + /// + /// Returns `None` on EOF (channel closed). Drains any leftover from + /// a prior `AsyncRead` call first. + pub async fn recv(&mut self) -> Option> { + if !self.read_buf.is_empty() { + return Some(self.read_buf.split().to_vec()); + } + self.inbound_rx.recv().await + } + /// Wrap `data` in the appropriate `InputMessage` for this stream's destination. fn make_input_message(&self, data: Vec) -> InputMessage { match &self.destination { diff --git a/sdk/rust/nym-sdk/src/mixnet/stream/mod.rs b/sdk/rust/nym-sdk/src/mixnet/stream/mod.rs index 2e648099a9..d3e85bd800 100644 --- a/sdk/rust/nym-sdk/src/mixnet/stream/mod.rs +++ b/sdk/rust/nym-sdk/src/mixnet/stream/mod.rs @@ -1,3 +1,6 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + //! Stream multiplexing for `MixnetClient`. //! //! A [`MixnetStream`] is a byte channel (`AsyncRead + AsyncWrite`) to a @@ -9,12 +12,12 @@ //! stream's channel (or to the listener for `Open` messages). mod mixnet_stream; -mod protocol; +pub(crate) mod protocol; pub use mixnet_stream::MixnetStream; pub use protocol::StreamId; -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use std::sync::atomic::Ordering; use std::sync::Arc; use std::time::Duration; @@ -47,11 +50,25 @@ pub(crate) const DEFAULT_STREAM_IDLE_TIMEOUT: Duration = Duration::from_secs(30 const MAX_CLEANUP_INTERVAL: Duration = Duration::from_secs(10); /// Per-stream state stored in the routing table. +/// +/// Reorder buffer uses the same BTreeMap pattern as `OrderedMessageBuffer` +/// (`common/socks5/ordered-buffer/`) but drains per-message instead of +/// concatenating, so `recv()` preserves message boundaries. struct StreamEntry { sender: mpsc::UnboundedSender>, last_activity: Instant, + next_seq: u32, + pending: BTreeMap>, } +/// Maximum number of out-of-order messages buffered per stream before we +/// skip ahead. Without this cap, a malicious sender that deliberately skips +/// a sequence number (e.g. never sends seq 1) could cause the buffer to +/// grow indefinitely while the drain loop waits for the missing seq. +/// The idle timeout only reaps *inactive* streams, so an actively-sending +/// attacker would bypass it. +const MAX_REORDER_BUFFER: usize = 256; + /// The shared stream routing table. /// /// Wraps the map of active streams behind an async mutex with focused @@ -76,6 +93,8 @@ impl StreamMap { StreamEntry { sender: tx, last_activity: Instant::now(), + next_seq: 0, + pending: BTreeMap::new(), }, ); rx @@ -95,17 +114,49 @@ impl StreamMap { }); } - /// Dispatch data to a stream's channel. Updates `last_activity` on - /// success. Removes the entry if the receiver has been dropped. - async fn send_to_stream(&self, stream_id: &StreamId, data: Vec) { + /// Buffer a message and flush any contiguous sequence to the channel. + /// Updates `last_activity` on success; removes the entry if the + /// receiver has been dropped. + async fn send_to_stream(&self, stream_id: &StreamId, seq: u32, data: Vec) { let mut map = self.inner.lock().await; let should_remove = if let Some(entry) = map.get_mut(stream_id) { - if entry.sender.send(data).is_err() { - true + if seq < entry.next_seq { + warn!( + "Stream {stream_id}: dropping old seq {seq} (expected >= {})", + entry.next_seq + ); } else { - entry.last_activity = Instant::now(); - false + entry.pending.insert(seq, data); } + + // If the buffer has grown too large, skip ahead to the lowest + // buffered seq so we don't accumulate unbounded memory. + if entry.pending.len() > MAX_REORDER_BUFFER { + if let Some(&lowest) = entry.pending.keys().next() { + warn!( + "Stream {stream_id}: reorder buffer overflow ({} pending), \ + skipping seq {} -> {lowest}", + entry.pending.len(), + entry.next_seq + ); + entry.next_seq = lowest; + } + } + + // Drain contiguous messages + let mut failed = false; + while let Some(msg) = entry.pending.remove(&entry.next_seq) { + if entry.sender.send(msg).is_err() { + failed = true; + break; + } + entry.next_seq += 1; + } + + if !failed { + entry.last_activity = Instant::now(); + } + failed } else { false }; @@ -242,7 +293,7 @@ async fn run_router( } SphinxStreamMsgType::Data => { streams - .send_to_stream(&stream_id, frame.data.to_vec()) + .send_to_stream(&stream_id, frame.sequence_num, frame.data.to_vec()) .await; } } @@ -295,10 +346,9 @@ pub(crate) async fn open_stream( let stream_id = StreamId::random(); let rx = streams.register_stream(stream_id).await; - // Currently hardcoded as we don't have message ordering in place *yet* in the SDK - // streams - when it *is* added then it will set the receiver's expected starting seq - // number. Gives us the ability down the road to e.g. pick up a dropped stream from - // where it left off. + // Open message with seq=0. The receiver's reorder buffer starts at + // next_seq=0 so this could later carry an initial seq to resume a + // dropped stream from where it left off. let wire = encode_stream_message(&stream_id, SphinxStreamMsgType::Open, 0, &[]); let msg = InputMessage::new_anonymous( recipient, @@ -379,7 +429,7 @@ mod tests { tokio::time::advance(Duration::from_secs(8)).await; // Activity on the stream resets its timer - map.send_to_stream(&id, vec![1, 2, 3]).await; + map.send_to_stream(&id, 0, vec![1, 2, 3]).await; // Advance past the original timeout, but only 5s since last activity tokio::time::advance(Duration::from_secs(5)).await; @@ -405,4 +455,37 @@ mod tests { assert_eq!(map.inner.lock().await.len(), 1); } + + #[tokio::test] + async fn out_of_order_messages_delivered_in_sequence() { + let map = StreamMap::new(); + let id = StreamId::random(); + let mut rx = map.register_stream(id).await; + + // Send seq 2, 0, 1 out of order + map.send_to_stream(&id, 2, vec![20]).await; + map.send_to_stream(&id, 0, vec![0]).await; + + // seq 0 should be delivered now, but 2 is buffered (gap at 1) + assert_eq!(rx.recv().await.unwrap(), vec![0]); + + // Fill the gap — both 1 and 2 should flush + map.send_to_stream(&id, 1, vec![10]).await; + assert_eq!(rx.recv().await.unwrap(), vec![10]); + assert_eq!(rx.recv().await.unwrap(), vec![20]); + } + + #[tokio::test] + async fn duplicate_seq_is_dropped() { + let map = StreamMap::new(); + let id = StreamId::random(); + let mut rx = map.register_stream(id).await; + + map.send_to_stream(&id, 0, vec![0]).await; + map.send_to_stream(&id, 0, vec![99]).await; // duplicate, dropped + map.send_to_stream(&id, 1, vec![1]).await; + + assert_eq!(rx.recv().await.unwrap(), vec![0]); + assert_eq!(rx.recv().await.unwrap(), vec![1]); + } } diff --git a/sdk/rust/nym-sdk/src/mixnet/stream/protocol.rs b/sdk/rust/nym-sdk/src/mixnet/stream/protocol.rs index 68e34176fc..058e6c445d 100644 --- a/sdk/rust/nym-sdk/src/mixnet/stream/protocol.rs +++ b/sdk/rust/nym-sdk/src/mixnet/stream/protocol.rs @@ -1,3 +1,6 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + //! Wire protocol for stream multiplexing. //! //! Every message between streams carries an LP frame header prepended to @@ -53,7 +56,6 @@ impl fmt::Display for StreamId { pub struct StreamFrame<'a> { pub stream_id: StreamId, pub msg_type: SphinxStreamMsgType, - #[allow(dead_code)] // will be used for reordering pub sequence_num: u32, pub data: &'a [u8], } diff --git a/service-providers/ip-packet-router/Cargo.toml b/service-providers/ip-packet-router/Cargo.toml index 5e3340871f..fdb12ae514 100644 --- a/service-providers/ip-packet-router/Cargo.toml +++ b/service-providers/ip-packet-router/Cargo.toml @@ -26,7 +26,7 @@ nym-crypto = { workspace = true } nym-exit-policy = { workspace = true } nym-id = { workspace = true } nym-ip-packet-requests = { workspace = true } -nym-kcp = { path = "../../common/nym-kcp" } # TODO MAX add to workspace dependencies +nym-lp = { workspace = true } nym-network-defaults = { workspace = true } nym-network-requester = { path = "../network-requester" } nym-sdk = { workspace = true } diff --git a/service-providers/ip-packet-router/src/clients/connected_client_handler.rs b/service-providers/ip-packet-router/src/clients/connected_client_handler.rs index ba9f9b3290..e44b543276 100644 --- a/service-providers/ip-packet-router/src/clients/connected_client_handler.rs +++ b/service-providers/ip-packet-router/src/clients/connected_client_handler.rs @@ -1,6 +1,7 @@ // Copyright 2025 - Nym Technologies SA // SPDX-License-Identifier: GPL-3.0-only +use std::sync::atomic::{AtomicU32, Ordering}; use std::time::Duration; use bytes::{Bytes, BytesMut}; @@ -9,6 +10,10 @@ use nym_ip_packet_requests::{ v6::response::IpPacketResponse as IpPacketResponseV6, v7::response::IpPacketResponse as IpPacketResponseV7, v8::response::IpPacketResponse as IpPacketResponseV8, + v9, +}; +use nym_lp::packet::frame::{ + LpFrame, LpFrameHeader, SphinxStreamFrameAttributes, SphinxStreamMsgType, }; use nym_sdk::mixnet::{ InputMessage, MixnetClientSender, MixnetMessageSender, MixnetMessageSinkTranslator, @@ -26,6 +31,19 @@ use crate::{ messages::ClientVersion, }; +/// Encode a payload inside an LP Stream frame: `[LpFrameHeader][payload]`. +pub(crate) fn encode_stream_frame(stream_id: u64, sequence_num: u32, payload: Vec) -> Vec { + let attrs = SphinxStreamFrameAttributes { + stream_id, + msg_type: SphinxStreamMsgType::Data, + sequence_num, + }; + let frame = LpFrame::new_stream(attrs, payload); + let mut buf = BytesMut::with_capacity(LpFrameHeader::SIZE + frame.content.len()); + frame.encode(&mut buf); + buf.to_vec() +} + // Data flow // Out: mixnet_listener -> decode -> handle_packet -> write_to_tun // In: tun_listener -> [connected_client_handler -> encode] -> mixnet_sender @@ -64,6 +82,7 @@ impl ConnectedClientHandler { buffer_timeout: Duration, client_version: ClientVersion, mixnet_client_sender: MixnetClientSender, + stream_id: Option, ) -> ( mpsc::UnboundedSender>, oneshot::Sender<()>, @@ -71,6 +90,9 @@ impl ConnectedClientHandler { ) { log::debug!("Starting connected client handler for: {client_id}"); log::debug!("client version: {client_version:?}"); + if let Some(sid) = stream_id { + log::debug!("LP Stream mode: stream_id={sid:#018x}"); + } let (close_tx, close_rx) = oneshot::channel(); let (forward_from_tun_tx, forward_from_tun_rx) = mpsc::unbounded_channel(); @@ -86,6 +108,13 @@ impl ConnectedClientHandler { let input_message_creator = ToIprDataResponse { send_to: client_id.clone(), client_version, + stream_id, + // Start at 1 — seq=0 is reserved for inline control responses + // (connect handshake, pong, health) sent by + // handle_stream_response() in mixnet_listener.rs. + // The skip-on-wrap logic in to_input_message() ensures we never + // emit seq=0 from the data path either. + next_response_seq: AtomicU32::new(1), }; let connected_client_handler = ConnectedClientHandler { @@ -189,15 +218,21 @@ fn create_ip_packet_response( ClientVersion::V6 => IpPacketResponseV6::new_ip_packet(packets).to_bytes(), ClientVersion::V7 => IpPacketResponseV7::new_ip_packet(packets).to_bytes(), ClientVersion::V8 => IpPacketResponseV8::new_ip_packet(packets).to_bytes(), + ClientVersion::V9 => v9::new_ip_packet_response(packets).to_bytes(), } } -// This struct is used by the sink to translate the the bundled IP packets into a IPR packet -// responses that can be sent to the mixnet. -#[derive(Clone, Debug)] +// This struct is used by the sink to translate the bundled IP packets into IPR packet +// responses that can be sent to the mixnet. When `stream_id` is set, responses are +// wrapped in LP Stream frames so the client's stream router can dispatch them. +#[derive(Debug)] struct ToIprDataResponse { send_to: ConnectedClientId, client_version: ClientVersion, + /// When Some, wrap responses in LP Stream frames with this stream_id. + stream_id: Option, + /// Sequence number for LP Stream response frames. + next_response_seq: AtomicU32, } impl MixnetMessageSinkTranslator for ToIprDataResponse { @@ -205,12 +240,24 @@ impl MixnetMessageSinkTranslator for ToIprDataResponse { &self, bundled_ip_packets: &[u8], ) -> std::result::Result { - // Create a IPR packet response that the recipient can understand let response_packet = create_ip_packet_response(bundled_ip_packets, self.client_version)?; - // Wrap the response packet in a mixnet input message + // Optionally wrap in LP Stream frame for stream-mode clients. + // Seq counter starts at 1 and skips 0 on wrap-around because seq=0 + // is used by handle_stream_response() for inline control responses + // (see comment in mixnet_listener.rs for the full story). + let final_packet = if let Some(stream_id) = self.stream_id { + let mut seq = self.next_response_seq.fetch_add(1, Ordering::Relaxed); + if seq == 0 { + seq = self.next_response_seq.fetch_add(1, Ordering::Relaxed); + } + encode_stream_frame(stream_id, seq, response_packet) + } else { + response_packet + }; + let input_message = - crate::util::create_message::create_input_message(&self.send_to, response_packet) + crate::util::create_message::create_input_message(&self.send_to, final_packet) .with_max_retransmissions(0); Ok(input_message) @@ -282,6 +329,8 @@ mod tests { let bytes_to_input_message = ToIprDataResponse { send_to: client_id.clone(), client_version, + stream_id: None, + next_response_seq: AtomicU32::new(0), }; let mixnet_ip_packet_sender = MixnetMessageSink::new_with_custom_translator( diff --git a/service-providers/ip-packet-router/src/clients/mod.rs b/service-providers/ip-packet-router/src/clients/mod.rs index fc6b39a359..1be30a4fa9 100644 --- a/service-providers/ip-packet-router/src/clients/mod.rs +++ b/service-providers/ip-packet-router/src/clients/mod.rs @@ -6,7 +6,7 @@ mod connected_client_handler; mod connected_clients; pub(crate) use client_id::ConnectedClientId; -pub(crate) use connected_client_handler::ConnectedClientHandler; +pub(crate) use connected_client_handler::{ConnectedClientHandler, encode_stream_frame}; pub(crate) use connected_clients::{ ConnectEvent, ConnectedClientEvent, ConnectedClients, DisconnectEvent, }; diff --git a/service-providers/ip-packet-router/src/error.rs b/service-providers/ip-packet-router/src/error.rs index b77b641455..ed1ca47263 100644 --- a/service-providers/ip-packet-router/src/error.rs +++ b/service-providers/ip-packet-router/src/error.rs @@ -117,8 +117,8 @@ pub enum IpPacketRouterError { #[error("failed to deserialize protocol: {source}")] FailedToDeserializeProtocol { source: ProtocolError }, - #[error("KCP protocol error: {0}")] - KcpError(String), + #[error("{0}")] + Other(String), } pub type Result = std::result::Result; diff --git a/service-providers/ip-packet-router/src/ip_packet_router.rs b/service-providers/ip-packet-router/src/ip_packet_router.rs index 084adb4009..13d5a7f6a8 100644 --- a/service-providers/ip-packet-router/src/ip_packet_router.rs +++ b/service-providers/ip-packet-router/src/ip_packet_router.rs @@ -180,7 +180,6 @@ impl IpPacketRouter { mixnet_client, shutdown_token: self.shutdown.clone_shutdown_token(), connected_clients, - kcp_session_manager: crate::kcp_session_manager::KcpSessionManager::new(), }; log::info!("The address of this client is: {self_address}"); diff --git a/service-providers/ip-packet-router/src/kcp_session_manager.rs b/service-providers/ip-packet-router/src/kcp_session_manager.rs deleted file mode 100644 index 9175f5d44e..0000000000 --- a/service-providers/ip-packet-router/src/kcp_session_manager.rs +++ /dev/null @@ -1,504 +0,0 @@ -// Copyright 2025 - Nym Technologies SA -// SPDX-License-Identifier: GPL-3.0-only - -#![allow(unused)] - -//! KCP Session Manager for LP clients at the exit gateway. -//! -//! This module sits between Sphinx unwrapping and IPR message processing. -//! It maintains per-client KCP state (keyed by conv_id from KCP packets), -//! reassembles KCP fragments into complete messages, and wraps responses -//! in KCP for SURB replies. -//! -//! # Architecture -//! -//! ```text -//! Mixnet → [Sphinx unwrap] → [KCP Session Manager] → [IPR message handling] -//! ↓ -//! KCP sessions per conv_id -//! ↓ -//! Reassemble fragments → DataRequest -//! ``` -//! -//! # Design Notes -//! -//! - Conv ID is extracted from the first 4 bytes of KCP packet data -//! - SURBs are stored per conv_id for sending replies -//! - Pattern follows `nym-lp-node::Node` from lewes-protocol - -use bytes::BytesMut; -use nym_kcp::driver::KcpDriver; -use nym_kcp::packet::KcpPacket; -use nym_kcp::session::KcpSession; -use nym_sphinx::anonymous_replies::ReplySurb; -use nym_sphinx::anonymous_replies::requests::AnonymousSenderTag; -use std::collections::{HashMap, VecDeque}; -use std::time::{Duration, Instant}; - -use crate::error::IpPacketRouterError; - -/// Default session timeout (5 minutes, matching IPR client timeout) -const SESSION_TIMEOUT: Duration = Duration::from_secs(300); - -/// Maximum concurrent KCP sessions per exit gateway -const MAX_SESSIONS: usize = 10000; - -/// State for a single KCP session -struct KcpSessionState { - driver: KcpDriver, - /// SURBs for sending replies back to this client - surbs: VecDeque, - /// Last activity timestamp - last_activity: Instant, - /// The sender tag associated with this session (for logging/debugging) - sender_tag: Option, -} - -impl KcpSessionState { - fn new(conv_id: u32) -> Self { - let session = KcpSession::new(conv_id); - Self { - driver: KcpDriver::new(session), - surbs: VecDeque::new(), - last_activity: Instant::now(), - sender_tag: None, - } - } - - fn touch(&mut self) { - self.last_activity = Instant::now(); - } - - fn is_expired(&self, timeout: Duration) -> bool { - self.last_activity.elapsed() > timeout - } - - fn add_surbs(&mut self, surbs: Vec) { - self.surbs.extend(surbs); - } - - fn take_surb(&mut self) -> Option { - self.surbs.pop_front() - } - - fn surb_count(&self) -> usize { - self.surbs.len() - } -} - -/// KCP Session Manager maintains per-client KCP state for LP clients. -/// -/// It intercepts incoming Sphinx payloads containing KCP data, extracts KCP frames, -/// reassembles them into complete messages, and returns the assembled data for IPR processing. -/// -/// Sessions are keyed by `conv_id` (first 4 bytes of KCP packet), which is derived -/// by clients from their local and remote addresses. -pub struct KcpSessionManager { - /// KCP sessions keyed by conv_id (from KCP packet header) - sessions: HashMap, - /// Session timeout duration - timeout: Duration, - /// Maximum number of sessions - max_sessions: usize, -} - -/// Result of processing incoming KCP data from a client -pub(crate) struct KcpProcessingResult { - /// The conv_id extracted from the KCP packet - pub(crate) conversation_id: u32, - - /// Vector of decoded KCP packets (for inspection/logging) - pub(crate) decoded_packets: Vec, - - /// Vector of complete reassembled messages ready for IPR processing - pub(crate) reassembled_messages: Vec>, -} - -impl Default for KcpSessionManager { - fn default() -> Self { - Self::new() - } -} - -impl KcpSessionManager { - /// Create a new KCP Session Manager with default settings - pub fn new() -> Self { - Self { - sessions: HashMap::new(), - timeout: SESSION_TIMEOUT, - max_sessions: MAX_SESSIONS, - } - } - - /// Create a new KCP Session Manager with custom settings - pub fn with_config(timeout: Duration, max_sessions: usize) -> Self { - Self { - sessions: HashMap::new(), - timeout, - max_sessions, - } - } - - /// Process incoming KCP data from a client. - /// - /// Takes raw KCP-encoded data (from a RepliableMessage). The conv_id is extracted - /// from the first 4 bytes of the KCP data. - /// - /// # Arguments - /// * `kcp_data` - Raw KCP packet data (conv_id is in first 4 bytes) - /// * `reply_surbs` - SURBs attached to the message for sending replies - /// * `sender_tag` - The anonymous sender tag (for logging/association) - /// * `current_time_ms` - Current time in milliseconds for KCP timing - /// - /// # Returns - /// A tuple containing: - /// - The conv_id extracted from the KCP packet - /// - A vector of decoded KCP packets (for inspection/logging) - /// - A vector of complete reassembled messages ready for IPR processing - pub fn process_incoming( - &mut self, - kcp_data: &[u8], - reply_surbs: Vec, - sender_tag: Option, - current_time_ms: u64, - ) -> Result { - if kcp_data.len() < 4 { - return Err(IpPacketRouterError::KcpError( - "KCP data too short to contain conv_id".to_string(), - )); - } - - // Extract conv_id from first 4 bytes of KCP packet - let conv_id = u32::from_le_bytes([kcp_data[0], kcp_data[1], kcp_data[2], kcp_data[3]]); - - // Get or create session - self.ensure_session(conv_id, sender_tag)?; - - let session = self - .sessions - .get_mut(&conv_id) - .ok_or_else(|| IpPacketRouterError::KcpError("Session not found".to_string()))?; - - session.touch(); - - // Store SURBs for later replies - session.add_surbs(reply_surbs); - - // Input the KCP data and get decoded packets - let decoded_packets = match session.driver.input(kcp_data) { - Ok(pkts) => pkts, - Err(e) => { - log::warn!("KCP input error for conv_id {}: {}", conv_id, e); - return Err(IpPacketRouterError::KcpError(e.to_string())); - } - }; - - // Update KCP state machine - session.driver.update(current_time_ms); - - // Collect any complete messages - let incoming_messages: Vec> = session - .driver - .fetch_incoming() - .into_iter() - .map(|buf| buf.to_vec()) - .collect(); - - Ok(KcpProcessingResult { - conversation_id: conv_id, - decoded_packets, - reassembled_messages: incoming_messages, - }) - } - - /// Wrap outgoing data in KCP for sending via SURB. - /// - /// # Arguments - /// * `conv_id` - The conversation ID - /// * `data` - The data to wrap in KCP - /// * `current_time_ms` - Current time in milliseconds for KCP timing - /// - /// # Returns - /// KCP-encoded packets ready to send - pub fn wrap_response( - &mut self, - conv_id: u32, - data: &[u8], - current_time_ms: u64, - ) -> Result, IpPacketRouterError> { - let session = self - .sessions - .get_mut(&conv_id) - .ok_or_else(|| IpPacketRouterError::KcpError("No session for conv_id".to_string()))?; - - session.touch(); - - // Queue the data for sending - session.driver.send(data); - - // Update to generate outgoing packets - session.driver.update(current_time_ms); - - // Fetch outgoing KCP packets and encode - let packets = session.driver.fetch_outgoing(); - let mut buf = BytesMut::new(); - for pkt in packets { - pkt.encode(&mut buf); - } - - Ok(buf.to_vec()) - } - - /// Take a SURB for sending a reply to a client. - /// - /// # Arguments - /// * `conv_id` - The conversation ID - /// - /// # Returns - /// A SURB if available, None otherwise - pub fn take_surb(&mut self, conv_id: u32) -> Option { - self.sessions.get_mut(&conv_id)?.take_surb() - } - - /// Get the number of available SURBs for a session - pub fn surb_count(&self, conv_id: u32) -> usize { - self.sessions - .get(&conv_id) - .map(|s| s.surb_count()) - .unwrap_or(0) - } - - /// Get the sender_tag associated with a session. - /// - /// Returns None if the session doesn't exist or has no sender_tag. - pub fn get_sender_tag(&self, conv_id: u32) -> Option { - self.sessions.get(&conv_id)?.sender_tag - } - - /// Fetch any pending outgoing KCP packets for a specific session. - /// - /// This is used to send immediate ACKs after receiving packets, - /// rather than waiting for the periodic tick. - pub fn fetch_outgoing_for_conv( - &mut self, - conv_id: u32, - current_time_ms: u64, - ) -> Option> { - let session = self.sessions.get_mut(&conv_id)?; - session.driver.update(current_time_ms); - let packets = session.driver.fetch_outgoing(); - - if packets.is_empty() { - return None; - } - - let mut buf = BytesMut::new(); - for pkt in packets { - pkt.encode(&mut buf); - } - Some(buf.to_vec()) - } - - /// Periodic update for all sessions. - /// - /// This should be called periodically (e.g., every 10-100ms) to: - /// - Drive KCP state machines (retransmissions, etc.) - /// - Clean up expired sessions - /// - /// Returns a list of (conv_id, outgoing_data) pairs for any sessions - /// that have pending outgoing packets. - pub fn tick(&mut self, current_time_ms: u64) -> Vec<(u32, Vec)> { - let mut outgoing = Vec::new(); - - for (&conv_id, session) in self.sessions.iter_mut() { - session.driver.update(current_time_ms); - let packets = session.driver.fetch_outgoing(); - - if !packets.is_empty() { - let mut buf = BytesMut::new(); - for pkt in packets { - pkt.encode(&mut buf); - } - outgoing.push((conv_id, buf.to_vec())); - } - } - - // Clean up expired sessions - self.cleanup_expired(); - - outgoing - } - - /// Remove expired sessions. - pub fn cleanup_expired(&mut self) { - let timeout = self.timeout; - self.sessions.retain(|conv_id, state| { - let expired = state.is_expired(timeout); - if expired { - log::debug!("Removing expired KCP session for conv_id {}", conv_id); - } - !expired - }); - } - - /// Get the number of active sessions - pub fn session_count(&self) -> usize { - self.sessions.len() - } - - /// Check if a session exists for the given conv_id - pub fn has_session(&self, conv_id: u32) -> bool { - self.sessions.contains_key(&conv_id) - } - - /// Ensure a session exists for the given conv_id, creating one if needed - fn ensure_session( - &mut self, - conv_id: u32, - sender_tag: Option, - ) -> Result<(), IpPacketRouterError> { - if self.sessions.contains_key(&conv_id) { - // Update sender_tag if provided - if let Some(tag) = sender_tag - && let Some(session) = self.sessions.get_mut(&conv_id) - { - session.sender_tag = Some(tag); - } - - return Ok(()); - } - - // Check session limit - if self.sessions.len() >= self.max_sessions { - // Try to clean up expired sessions first - self.cleanup_expired(); - - // Still at limit? - if self.sessions.len() >= self.max_sessions { - return Err(IpPacketRouterError::KcpError( - "Maximum KCP sessions reached".to_string(), - )); - } - } - - log::debug!("Creating new KCP session for conv_id {}", conv_id); - let mut state = KcpSessionState::new(conv_id); - state.sender_tag = sender_tag; - self.sessions.insert(conv_id, state); - - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_session_creation() { - let mut manager = KcpSessionManager::new(); - - assert!(!manager.has_session(12345)); - assert_eq!(manager.session_count(), 0); - - // Create a minimal KCP packet (just conv_id) - let conv_id: u32 = 12345; - let mut kcp_data = conv_id.to_le_bytes().to_vec(); - // Add minimal header padding to make it look like a packet - kcp_data.extend_from_slice(&[0u8; 21]); // KCP header is 25 bytes total - - // Processing data should create a session - let result = manager.process_incoming(&kcp_data, vec![], None, 0); - // May error due to invalid KCP packet, but session should be created - let _ = result; - - assert!(manager.has_session(conv_id)); - assert_eq!(manager.session_count(), 1); - } - - #[test] - fn test_session_expiry() { - let mut manager = KcpSessionManager::with_config(Duration::from_millis(10), 100); - let conv_id: u32 = 99999; - - // Create session directly - manager.ensure_session(conv_id, None).unwrap(); - assert!(manager.has_session(conv_id)); - - // Wait for expiry - std::thread::sleep(Duration::from_millis(20)); - - manager.cleanup_expired(); - assert!(!manager.has_session(conv_id)); - } - - #[test] - fn test_max_sessions_limit() { - let mut manager = KcpSessionManager::with_config(Duration::from_secs(300), 2); - - manager.ensure_session(1, None).unwrap(); - manager.ensure_session(2, None).unwrap(); - - assert_eq!(manager.session_count(), 2); - - // Third session should fail - let result = manager.ensure_session(3, None); - assert!(result.is_err()); - } - - #[test] - fn test_kcp_roundtrip() { - use nym_kcp::driver::KcpDriver; - use nym_kcp::session::KcpSession; - - let mut manager = KcpSessionManager::new(); - let conv_id: u32 = 42424242; - - // Create a "client" KCP session to send data - let client_session = KcpSession::new(conv_id); - let mut client_driver = KcpDriver::new(client_session); - - // Client sends a message - let message = b"Hello, IPR via KCP!"; - client_driver.send(message); - client_driver.update(100); - - // Get the KCP packets from the client - let outgoing = client_driver.fetch_outgoing(); - assert!(!outgoing.is_empty(), "Client should produce KCP packets"); - - // Encode packets - let mut kcp_data = BytesMut::new(); - for pkt in outgoing { - pkt.encode(&mut kcp_data); - } - - // Feed to the session manager - let res = manager - .process_incoming(&kcp_data, vec![], None, 100) - .expect("process_incoming should succeed"); - - // Verify conv_id was extracted correctly - assert_eq!(res.conversation_id, conv_id); - - // Should have received the complete message - assert_eq!(res.reassembled_messages.len(), 1); - assert_eq!(res.reassembled_messages[0], message); - } - - #[test] - fn test_surb_storage() { - let mut manager = KcpSessionManager::new(); - let conv_id: u32 = 11111; - - // Create session - manager.ensure_session(conv_id, None).unwrap(); - - // Initially no SURBs - assert_eq!(manager.surb_count(conv_id), 0); - assert!(manager.take_surb(conv_id).is_none()); - - // Note: We can't easily create ReplySurbs in tests without complex setup, - // but the storage mechanism is tested via the session state - } -} diff --git a/service-providers/ip-packet-router/src/lib.rs b/service-providers/ip-packet-router/src/lib.rs index 7810b07e74..fe66577b22 100644 --- a/service-providers/ip-packet-router/src/lib.rs +++ b/service-providers/ip-packet-router/src/lib.rs @@ -17,7 +17,6 @@ pub(crate) mod non_linux_dummy; mod clients; mod constants; mod ip_packet_router; -mod kcp_session_manager; mod mixnet_client; mod mixnet_listener; mod tun_listener; diff --git a/service-providers/ip-packet-router/src/messages/mod.rs b/service-providers/ip-packet-router/src/messages/mod.rs index a695fc5e2f..392e590186 100644 --- a/service-providers/ip-packet-router/src/messages/mod.rs +++ b/service-providers/ip-packet-router/src/messages/mod.rs @@ -9,6 +9,7 @@ pub(crate) enum ClientVersion { V6, V7, V8, + V9, } impl ClientVersion { @@ -17,6 +18,7 @@ impl ClientVersion { ClientVersion::V6 => 6, ClientVersion::V7 => 7, ClientVersion::V8 => 8, + ClientVersion::V9 => 9, } } } diff --git a/service-providers/ip-packet-router/src/messages/request.rs b/service-providers/ip-packet-router/src/messages/request.rs index 9f5704fb40..0f8419e5e1 100644 --- a/service-providers/ip-packet-router/src/messages/request.rs +++ b/service-providers/ip-packet-router/src/messages/request.rs @@ -4,6 +4,7 @@ mod v6; mod v7; mod v8; +mod v9; use nym_ip_packet_requests::{ IpPair, v6::request::IpPacketRequest as IpPacketRequestV6, @@ -129,6 +130,16 @@ impl TryFrom<&ReconstructedMessage> for IpPacketRequest { .ok_or(IpPacketRouterError::MissingSenderTag)?; Ok(IpPacketRequest::from((request_v8, sender_tag))) } + 9 => { + let request_v8 = IpPacketRequestV8::from_reconstructed_message(reconstructed) + .map_err( + |source| IpPacketRouterError::FailedToDeserializeTaggedPacket { source }, + )?; + let sender_tag = reconstructed + .sender_tag + .ok_or(IpPacketRouterError::MissingSenderTag)?; + Ok(v9::convert(request_v8, sender_tag)) + } _ => { log::info!("Received packet with invalid version: v{request_version}"); Err(IpPacketRouterError::InvalidPacketVersion(request_version)) @@ -137,6 +148,21 @@ impl TryFrom<&ReconstructedMessage> for IpPacketRequest { } } +impl IpPacketRequest { + pub(crate) fn version(&self) -> ClientVersion { + match self { + IpPacketRequest::Data(r) => r.version, + IpPacketRequest::Control(c) => match c { + ControlRequest::StaticConnect(r) => r.version, + ControlRequest::DynamicConnect(r) => r.version, + ControlRequest::Disconnect(r) => r.version, + ControlRequest::Ping(r) => r.version, + ControlRequest::Health(r) => r.version, + }, + } + } +} + impl fmt::Display for IpPacketRequest { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { diff --git a/service-providers/ip-packet-router/src/messages/request/v9.rs b/service-providers/ip-packet-router/src/messages/request/v9.rs new file mode 100644 index 0000000000..317fd9b458 --- /dev/null +++ b/service-providers/ip-packet-router/src/messages/request/v9.rs @@ -0,0 +1,27 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use nym_ip_packet_requests::v8::request::{ + IpPacketRequest as IpPacketRequestV8, IpPacketRequestData as IpPacketRequestDataV8, +}; +use nym_sdk::mixnet::AnonymousSenderTag; + +use super::{ClientVersion, IpPacketRequest}; + +// v9 uses the same wire format as v8, so we reuse the v8 deserialization +// and just tag the result with ClientVersion::V9. +// +// We cannot implement From<(IpPacketRequestV8, AnonymousSenderTag)> again +// because v8.rs already has that impl (same concrete types). +pub(crate) fn convert( + request: IpPacketRequestV8, + sender_tag: AnonymousSenderTag, +) -> IpPacketRequest { + let version = ClientVersion::V9; + match request.data { + IpPacketRequestDataV8::Data(inner) => IpPacketRequest::Data((inner, version).into()), + IpPacketRequestDataV8::Control(inner) => { + IpPacketRequest::Control((*inner, sender_tag, version).into()) + } + } +} diff --git a/service-providers/ip-packet-router/src/messages/response.rs b/service-providers/ip-packet-router/src/messages/response.rs index 56b6680500..4801983f48 100644 --- a/service-providers/ip-packet-router/src/messages/response.rs +++ b/service-providers/ip-packet-router/src/messages/response.rs @@ -130,6 +130,7 @@ impl VersionedResponse { ClientVersion::V6 => IpPacketResponseV6::try_from(self)?.to_bytes(), ClientVersion::V7 => IpPacketResponseV7::try_from(self)?.to_bytes(), ClientVersion::V8 => IpPacketResponseV8::try_from(self)?.to_bytes(), + ClientVersion::V9 => IpPacketResponseV8::try_from(self)?.to_bytes(), } .map_err(|err| IpPacketRouterError::FailedToSerializeResponsePacket { source: err }) } diff --git a/service-providers/ip-packet-router/src/mixnet_listener.rs b/service-providers/ip-packet-router/src/mixnet_listener.rs index fc5b2b20d6..fa25adbf71 100644 --- a/service-providers/ip-packet-router/src/mixnet_listener.rs +++ b/service-providers/ip-packet-router/src/mixnet_listener.rs @@ -6,7 +6,6 @@ use crate::{ config::Config, constants::DISCONNECT_TIMER_INTERVAL, error::{IpPacketRouterError, Result}, - kcp_session_manager::KcpSessionManager, messages::{ ClientVersion, request::{ @@ -24,6 +23,7 @@ use crate::{ }; use futures::StreamExt; use nym_ip_packet_requests::codec::MultiIpPacketCodec; +use nym_lp::packet::frame::{LpFrameHeader, LpFrameKind, SphinxStreamFrameAttributes}; use nym_sdk::mixnet::MixnetMessageSender; use nym_sphinx::receiver::ReconstructedMessage; use nym_task::ShutdownToken; @@ -31,9 +31,6 @@ use std::{net::SocketAddr, time::Duration}; use tokio::io::AsyncWriteExt; use tokio_util::codec::FramedRead; -/// KCP tick interval for session updates (retransmissions, ACKs, cleanup) -const KCP_TICK_INTERVAL: Duration = Duration::from_millis(10); - #[cfg(not(target_os = "linux"))] type TunDevice = crate::non_linux_dummy::DummyDevice; @@ -60,45 +57,6 @@ pub(crate) struct MixnetListener { // The map of connected clients that the mixnet listener keeps track of. It monitors // activity and disconnects clients that have been inactive for too long. pub(crate) connected_clients: ConnectedClients, - - // KCP session manager for LP clients sending KCP-wrapped messages - pub(crate) kcp_session_manager: KcpSessionManager, -} - -/// Check if a message payload appears to be KCP-wrapped. -/// -/// KCP packets have a 25-byte header with the command byte at position 4. -/// Valid KCP commands are: Push(81), Ack(82), Wask(83), Wins(84). -/// -/// This is distinguishable from IPR protocol messages which have: -/// - Version byte at position 0: 6, 7, or 8 -/// - ServiceProviderType at position 1: 0, 1, or 2 (for v8+) -/// -/// We use a two-step heuristic: -/// 1. Exclude messages that look like IPR protocol headers -/// 2. Check if byte 4 contains a valid KCP command (81-84) -/// -/// See: `Protocol::try_from` in service-provider-requests-common for header format. -fn is_kcp_message(data: &[u8]) -> bool { - // Need at least 25 bytes for KCP header - if data.len() < 25 { - return false; - } - - // First, check if this looks like an IPR protocol message. - // IPR messages have: byte 0 = version (6-8), byte 1 = ServiceProviderType (0-2 for v8+) - // See: IpPacketRequest::try_from in messages/request.rs - let version_byte = data[0]; - let service_type_byte = data[1]; - if (6..=8).contains(&version_byte) && service_type_byte <= 2 { - // This matches IPR protocol header pattern - not a KCP message - return false; - } - - // Check KCP command byte at position 4 - let cmd = data[4]; - // Valid KCP commands: Push=81, Ack=82, Wask=83, Wins=84 - (81..=84).contains(&cmd) } // #[cfg(target_os = "linux")] @@ -172,11 +130,19 @@ impl MixnetListener { let decoder = MultiIpPacketCodec::new(); let mut framed_reader = FramedRead::new(data_request.ip_packets.as_ref(), decoder); - while let Some(Ok(packet)) = framed_reader.next().await { - let result = self - .handle_packet(packet.as_bytes(), data_request.version) - .await; - responses.push(result); + while let Some(result) = framed_reader.next().await { + match result { + Ok(packet) => { + let result = self + .handle_packet(packet.as_bytes(), data_request.version) + .await; + responses.push(result); + } + Err(e) => { + log::warn!("Failed to decode bundled IP packet: {e}"); + continue; + } + } } Ok(responses) @@ -187,6 +153,7 @@ impl MixnetListener { async fn on_static_connect_request( &mut self, connect_request: StaticConnectRequest, + stream_id: Option, ) -> PacketHandleResult { log::info!( "Received static connect request from {}", @@ -209,6 +176,31 @@ impl MixnetListener { let is_client_id_taken = self.connected_clients.is_client_connected(&sent_by); let response = match (is_ip_taken, is_client_id_taken) { + (true, true) if stream_id.is_some() => { + // Stream-mode reconnect: tear down the old handler (which has a + // stale stream_id) and create a fresh one for the new stream. + log::info!("Stream-mode client reconnecting, replacing handler"); + self.connected_clients.disconnect_client(&sent_by); + + let (forward_from_tun_tx, close_tx, handle) = ConnectedClientHandler::start( + sent_by.clone(), + buffer_timeout, + version, + self.mixnet_client.split_sender(), + stream_id, + ); + self.connected_clients.connect( + requested_ips, + sent_by.clone(), + forward_from_tun_tx, + close_tx, + handle, + ); + Response::StaticConnect { + request_id, + reply: StaticConnectResponse::Success, + } + } (true, true) => { log::info!("Connecting an already connected client"); if self @@ -233,6 +225,7 @@ impl MixnetListener { buffer_timeout, version, self.mixnet_client.split_sender(), + stream_id, ); // Register the new client in the set of connected clients @@ -274,9 +267,11 @@ impl MixnetListener { fn on_dynamic_connect_request( &mut self, connect_request: DynamicConnectRequest, + stream_id: Option, ) -> PacketHandleResult { log::info!( - "Received dynamic connect request from {}", + "Received v{} dynamic connect request from {}", + connect_request.version.into_u8(), connect_request.sent_by ); @@ -289,15 +284,22 @@ impl MixnetListener { .unwrap_or(nym_ip_packet_requests::codec::BUFFER_TIMEOUT); if let Some(ips) = self.connected_clients.lookup_ip_from_client_id(&reply_to) { - log::debug!("Reconnecting to the previous session"); - return Ok(Some(VersionedResponse { - version, - reply_to, - response: Response::DynamicConnect { - request_id, - reply: DynamicConnectSuccess { ips }.into(), - }, - })); + if stream_id.is_some() { + // Stream-mode reconnect: tear down old handler (stale stream_id) + // and create a fresh one below with the new stream. + log::info!("Stream-mode client reconnecting, replacing handler"); + self.connected_clients.disconnect_client(&reply_to); + } else { + log::debug!("Reconnecting to the previous session"); + return Ok(Some(VersionedResponse { + version, + reply_to, + response: Response::DynamicConnect { + request_id, + reply: DynamicConnectSuccess { ips }.into(), + }, + })); + } } let Some(new_ips) = self.connected_clients.find_new_ip() else { @@ -318,6 +320,7 @@ impl MixnetListener { buffer_timeout, version, self.mixnet_client.split_sender(), + stream_id, ); // Register the new client in the set of connected clients @@ -403,10 +406,14 @@ impl MixnetListener { })) } - async fn on_control_request(&mut self, control_request: ControlRequest) -> PacketHandleResult { + async fn on_control_request( + &mut self, + control_request: ControlRequest, + stream_id: Option, + ) -> PacketHandleResult { match control_request { - ControlRequest::StaticConnect(r) => self.on_static_connect_request(r).await, - ControlRequest::DynamicConnect(r) => self.on_dynamic_connect_request(r), + ControlRequest::StaticConnect(r) => self.on_static_connect_request(r, stream_id).await, + ControlRequest::DynamicConnect(r) => self.on_dynamic_connect_request(r, stream_id), ControlRequest::Disconnect(r) => self.on_disconnect_request(r), ControlRequest::Ping(r) => self.on_ping_request(r), ControlRequest::Health(r) => self.on_health_request(r), @@ -436,127 +443,106 @@ impl MixnetListener { .unwrap_or("missing".to_owned()) ); - // Check if this is a KCP-wrapped message from an LP client - if is_kcp_message(&reconstructed.message) { - return self.on_kcp_message(reconstructed).await; + // Check if this is an LP Stream frame + if reconstructed.message.len() >= LpFrameHeader::SIZE + && let Ok(header) = LpFrameHeader::parse(&reconstructed.message) + && header.kind == LpFrameKind::SphinxStream + { + return self.on_stream_frame(reconstructed, header).await; } // Regular IPR protocol message (websocket clients) - self.on_ipr_message(reconstructed).await + self.on_ipr_message(reconstructed, None).await } - /// Handle KCP-wrapped messages from LP clients. + /// Handle LP Stream-framed messages. /// - /// LP clients send: KCP(IpPacketRequest) - /// We unwrap the KCP layer, reassemble fragments, then process the inner IpPacketRequest. - /// Responses are wrapped in KCP and sent directly via the sender_tag reply mechanism, - /// rather than being returned for standard handling. - async fn on_kcp_message( + /// Parses stream attributes, processes the inner IPR payload, and handles + /// responses inline (wrapped in LP Stream frames). + async fn on_stream_frame( &mut self, reconstructed: ReconstructedMessage, + header: LpFrameHeader, ) -> Result> { - let current_time_ms = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_millis() as u64) - .unwrap_or(0); - - // Process the KCP data through the session manager. - // AIDEV-NOTE: Reply mechanism for LP clients: - // 1. LP clients MUST send SURBs (in RepliableMessage) when connecting/sending data - // 2. The SDK's client-core layer (ReceivedMessagesBuffer + ReplyController) automatically - // extracts SURBs from incoming RepliableMessages and stores them keyed by sender_tag - // 3. When we call InputMessage::new_reply(sender_tag, ...), the SDK looks up stored SURBs - // 4. The vec![] here is for KcpSessionManager's internal SURB storage (currently unused) - // since the SDK handles SURB storage at a lower layer - // 5. If replies fail, check that LP client is sending SURBs in its messages - let processing_result = self - .kcp_session_manager - .process_incoming( - &reconstructed.message, - vec![], // SDK handles SURB extraction/storage automatically - reconstructed.sender_tag, - current_time_ms, - ) - .inspect_err(|e| { - log::warn!("KCP processing error: {e}"); - })?; - - let conv_id = processing_result.conversation_id; - log::debug!( - "KCP conv_id={}: received {} packets, {} complete messages", - processing_result.conversation_id, - processing_result.decoded_packets.len(), - processing_result.reassembled_messages.len() + "Received LP Stream frame ({} bytes)", + reconstructed.message.len() ); - // Process each reassembled message as an IpPacketRequest - for message_data in processing_result.reassembled_messages { - // Create a synthetic ReconstructedMessage for the inner payload - let inner_reconstructed = ReconstructedMessage { - message: message_data, - sender_tag: reconstructed.sender_tag, - }; + let attrs = SphinxStreamFrameAttributes::parse(&header.frame_attributes).map_err(|e| { + IpPacketRouterError::Other(format!("Invalid stream frame attributes: {e}")) + })?; - match self.on_ipr_message(inner_reconstructed).await { - Ok(results) => { - // Handle responses by wrapping in KCP and sending directly - for result in results { - // false positive: this if can't be collapsed due to `response` being moved - // between calls - #[allow(clippy::collapsible_if)] - if let Ok(Some(response)) = result { - if let Err(e) = self - .handle_kcp_response(conv_id, response, current_time_ms) - .await - { - log::warn!( - "Error sending KCP-wrapped response for conv_id={conv_id}: {e}", - ); - } - } + let stream_id = attrs.stream_id; + log::debug!( + "LP Stream: stream_id={stream_id:#018x}, msg_type={:?}, seq={}", + attrs.msg_type, + attrs.sequence_num + ); + + let payload = &reconstructed.message[LpFrameHeader::SIZE..]; + + // Open frames may carry an empty payload (stream handshake). + if payload.is_empty() { + log::info!("LP Stream: new stream opened (stream_id={stream_id:#018x})"); + return Ok(vec![]); + } + + let inner_reconstructed = ReconstructedMessage { + message: payload.to_vec(), + sender_tag: reconstructed.sender_tag, + }; + + match self + .on_ipr_message(inner_reconstructed, Some(stream_id)) + .await + { + Ok(results) => { + for result in results { + let Ok(Some(response)) = result else { continue }; + if let Err(e) = self.handle_stream_response(stream_id, response).await { + log::warn!( + "Error sending LP Stream response for stream_id={stream_id:#018x}: {e}" + ); } } - Err(e) => { - log::warn!("Error processing KCP inner message: {}", e); - // Continue processing other messages - } + } + Err(e) => { + log::warn!("Error processing LP Stream inner message: {e}"); } } - // Return empty - we handled responses directly above Ok(vec![]) } - /// Wrap a response in KCP and send it via the mixnet reply mechanism. + /// Wrap a response in an LP Stream frame and send it via the mixnet. /// - /// This is used for LP clients that communicate via KCP-wrapped messages. - async fn handle_kcp_response( + /// Used for inline control responses (connect handshake, pong, health) + /// that are sent directly from the message handler — outside the + /// `ConnectedClientHandler` which owns the data-path sequence counter. + /// + /// # Sequence numbering + /// + /// These inline responses always use **seq=0**. The data-path counter in + /// `ConnectedClientHandler` starts at 1 and skips 0 on wrap-around, so + /// the two paths never collide. + /// + /// Limitation: if multiple inline responses are sent on the same stream + /// (e.g. connect + later pong), they share seq=0. The client's reorder + /// buffer will see the second as a duplicate and drop it. In practice + /// this is fine because control responses are rare and idempotent, but + /// if it becomes a problem, give inline responses their own counter. + async fn handle_stream_response( &mut self, - conv_id: u32, + stream_id: u64, response: VersionedResponse, - current_time_ms: u64, ) -> Result<()> { let reply_to = response.reply_to.clone(); - - // Serialize the response let response_bytes = response.try_into_bytes()?; - // Wrap in KCP - let kcp_wrapped = - self.kcp_session_manager - .wrap_response(conv_id, &response_bytes, current_time_ms)?; + let wrapped = crate::clients::encode_stream_frame(stream_id, 0, response_bytes); - log::debug!( - "KCP conv_id={}: wrapped {} byte response into {} bytes", - conv_id, - response_bytes.len(), - kcp_wrapped.len() - ); - - // Send via mixnet using the sender_tag reply mechanism - let input_message = - crate::util::create_message::create_input_message(&reply_to, kcp_wrapped); + let input_message = crate::util::create_message::create_input_message(&reply_to, wrapped); self.mixnet_client.send(input_message).await.map_err(|err| { IpPacketRouterError::FailedToSendPacketToMixnet { @@ -566,9 +552,21 @@ impl MixnetListener { } /// Handle regular IPR protocol messages (from websocket clients). + /// + /// `stream_id` is `Some` when processing a payload extracted from an LP + /// Stream frame, so that connect handlers can thread the id to the + /// `ConnectedClientHandler` for LP-wrapping TUN responses. + /// + /// # Version / transport enforcement + /// + /// - LP Stream frames (`stream_id` is `Some`) **must** carry v9+ payloads. + /// - Non-stream messages (`stream_id` is `None`) **must** be v8 or lower. + /// + /// Messages that violate these rules are dropped. async fn on_ipr_message( &mut self, reconstructed: ReconstructedMessage, + stream_id: Option, ) -> Result> { // First deserialize the request let request = match IpPacketRequest::try_from(&reconstructed) { @@ -579,11 +577,27 @@ impl MixnetListener { req => req, }?; + // Enforce version/transport consistency: + // - LP Stream frames must carry v9+ payloads + // - Non-stream messages must be v8 or lower + let version_num = request.version().into_u8(); + + if stream_id.is_some() && version_num < 9 { + log::warn!("LP Stream frame contains v{version_num} payload, expected v9+; dropping",); + return Ok(vec![]); + } + if stream_id.is_none() && version_num >= 9 { + log::warn!("Non-stream message claims v{version_num}, expected v8 or lower; dropping",); + return Ok(vec![]); + } + log::debug!("Received request: {request}"); match request { IpPacketRequest::Data(request) => self.on_data_request(request).await, - IpPacketRequest::Control(request) => Ok(vec![self.on_control_request(request).await]), + IpPacketRequest::Control(request) => { + Ok(vec![self.on_control_request(request, stream_id).await]) + } } } @@ -640,45 +654,8 @@ impl MixnetListener { } } - /// Handle KCP session tick - drives retransmissions, ACKs, and cleanup. - /// - /// Sends any pending outgoing KCP packets (ACKs, retransmissions) via the - /// sender_tag reply mechanism. - async fn handle_kcp_tick(&mut self) { - let current_time_ms = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_millis() as u64) - .unwrap_or(0); - - // Tick all KCP sessions - generates ACKs, retransmissions, etc. - let outgoing = self.kcp_session_manager.tick(current_time_ms); - - // Send any pending outgoing KCP protocol packets - for (conv_id, data) in outgoing { - // Get the sender_tag for this session to reply via mixnet - let Some(sender_tag) = self.kcp_session_manager.get_sender_tag(conv_id) else { - log::warn!( - "KCP tick: conv_id={} has {} bytes but no sender_tag, dropping", - conv_id, - data.len() - ); - continue; - }; - - log::trace!("KCP tick: conv_id={} sending {} bytes", conv_id, data.len()); - - let reply_to = crate::clients::ConnectedClientId::AnonymousSenderTag(sender_tag); - let input_message = crate::util::create_message::create_input_message(&reply_to, data); - - if let Err(e) = self.mixnet_client.send(input_message).await { - log::warn!("KCP tick: failed to send for conv_id={}: {}", conv_id, e); - } - } - } - pub(crate) async fn run(mut self) -> Result<()> { let mut disconnect_timer = tokio::time::interval(DISCONNECT_TIMER_INTERVAL); - let mut kcp_tick_timer = tokio::time::interval(KCP_TICK_INTERVAL); loop { tokio::select! { @@ -690,9 +667,6 @@ impl MixnetListener { _ = disconnect_timer.tick() => { self.handle_disconnect_timer().await; }, - _ = kcp_tick_timer.tick() => { - self.handle_kcp_tick().await; - }, msg = self.mixnet_client.next() => { if let Some(msg) = msg { match self.on_reconstructed_message(msg).await { @@ -719,98 +693,31 @@ pub(crate) type PacketHandleResult = Result>; #[cfg(test)] mod tests { - use super::*; - #[test] - fn test_is_kcp_message_rejects_ipr_protocol() { - // IPR v8 message: version=8, service_provider_type=1 (IpPacketRouter) - // Even if byte 4 happens to be a valid KCP command, we should reject it - let mut ipr_message = vec![0u8; 30]; - ipr_message[0] = 8; // version - ipr_message[1] = 1; // ServiceProviderType::IpPacketRouter - ipr_message[4] = 81; // This would be KCP Push command, but should be ignored + fn test_lp_stream_frame_detected() { + use bytes::BytesMut; + use nym_lp::packet::frame::{ + LpFrameHeader, LpFrameKind, SphinxStreamFrameAttributes, SphinxStreamMsgType, + }; - assert!( - !is_kcp_message(&ipr_message), - "IPR v8 message should not be detected as KCP" - ); + let attrs = SphinxStreamFrameAttributes { + stream_id: 0x1234, + msg_type: SphinxStreamMsgType::Data, + sequence_num: 42, + }; + let frame = nym_lp::packet::frame::LpFrame::new_stream(attrs, vec![8, 1, 0]); // fake IPR payload + let mut buf = BytesMut::new(); + frame.encode(&mut buf); - // IPR v6 message - ipr_message[0] = 6; - ipr_message[1] = 0; // v6 doesn't use service_provider_type but byte could be 0 - assert!( - !is_kcp_message(&ipr_message), - "IPR v6 message should not be detected as KCP" - ); + let header = LpFrameHeader::parse(&buf).unwrap(); + assert_eq!(header.kind, LpFrameKind::SphinxStream); - // IPR v7 message - ipr_message[0] = 7; - ipr_message[1] = 2; // Authenticator type - assert!( - !is_kcp_message(&ipr_message), - "IPR v7 message should not be detected as KCP" - ); - } + let parsed_attrs = SphinxStreamFrameAttributes::parse(&header.frame_attributes).unwrap(); + assert_eq!(parsed_attrs.stream_id, 0x1234); + assert_eq!(parsed_attrs.msg_type, SphinxStreamMsgType::Data); + assert_eq!(parsed_attrs.sequence_num, 42); - #[test] - fn test_is_kcp_message_accepts_kcp() { - // Valid KCP message: conv_id in bytes 0-3, cmd=Push(81) at byte 4 - // First bytes are conv_id (little-endian u32), so they won't look like IPR version - let mut kcp_message = vec![0u8; 30]; - kcp_message[0] = 0x12; // conv_id byte 0 (not 6-8, so not IPR version) - kcp_message[1] = 0x34; // conv_id byte 1 - kcp_message[2] = 0x56; // conv_id byte 2 - kcp_message[3] = 0x78; // conv_id byte 3 - kcp_message[4] = 81; // KCP Push command - - assert!( - is_kcp_message(&kcp_message), - "Valid KCP message should be detected" - ); - - // Test all valid KCP commands - for cmd in [81u8, 82, 83, 84] { - kcp_message[4] = cmd; - assert!( - is_kcp_message(&kcp_message), - "KCP command {} should be accepted", - cmd - ); - } - } - - #[test] - fn test_is_kcp_message_rejects_short_messages() { - // Less than 25 bytes should be rejected - let short_message = vec![0u8; 24]; - assert!( - !is_kcp_message(&short_message), - "Short message should not be detected as KCP" - ); - - let empty_message: Vec = vec![]; - assert!( - !is_kcp_message(&empty_message), - "Empty message should not be detected as KCP" - ); - } - - #[test] - fn test_is_kcp_message_rejects_invalid_kcp_command() { - // Message with invalid KCP command at byte 4 - let mut message = vec![0u8; 30]; - message[0] = 0x12; // Not IPR version - message[4] = 80; // Invalid KCP command (valid are 81-84) - - assert!( - !is_kcp_message(&message), - "Invalid KCP command should be rejected" - ); - - message[4] = 85; // Also invalid - assert!( - !is_kcp_message(&message), - "Invalid KCP command 85 should be rejected" - ); + // Content is everything after the header + assert_eq!(&buf[LpFrameHeader::SIZE..], &[8, 1, 0]); } } From 1672135308d7ecaf17ef7fa5d4b8197e1b1e22bf Mon Sep 17 00:00:00 2001 From: benedettadavico Date: Mon, 30 Mar 2026 07:11:55 +0200 Subject: [PATCH 19/23] bump versions --- Cargo.lock | 14 +++++++------- clients/native/Cargo.toml | 2 +- clients/socks5/Cargo.toml | 2 +- nym-api/Cargo.toml | 2 +- nym-node/Cargo.toml | 2 +- service-providers/network-requester/Cargo.toml | 2 +- tools/nym-cli/Cargo.toml | 2 +- tools/nymvisor/Cargo.toml | 2 +- 8 files changed, 14 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 59c16f3898..0377d3b8e5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5547,7 +5547,7 @@ dependencies = [ [[package]] name = "nym-api" -version = "1.1.76" +version = "1.1.77" dependencies = [ "anyhow", "async-trait", @@ -5792,7 +5792,7 @@ dependencies = [ [[package]] name = "nym-cli" -version = "1.1.73" +version = "1.1.74" dependencies = [ "anyhow", "base64 0.22.1", @@ -5875,7 +5875,7 @@ dependencies = [ [[package]] name = "nym-client" -version = "1.1.73" +version = "1.1.74" dependencies = [ "bs58", "clap", @@ -7284,7 +7284,7 @@ dependencies = [ [[package]] name = "nym-network-requester" -version = "1.1.74" +version = "1.1.75" dependencies = [ "addr", "anyhow", @@ -7334,7 +7334,7 @@ dependencies = [ [[package]] name = "nym-node" -version = "1.28.0" +version = "1.29.0" dependencies = [ "anyhow", "arc-swap", @@ -7884,7 +7884,7 @@ dependencies = [ [[package]] name = "nym-socks5-client" -version = "1.1.73" +version = "1.1.74" dependencies = [ "bs58", "clap", @@ -8682,7 +8682,7 @@ dependencies = [ [[package]] name = "nymvisor" -version = "0.1.38" +version = "0.1.39" dependencies = [ "anyhow", "bytes", diff --git a/clients/native/Cargo.toml b/clients/native/Cargo.toml index 358ca50198..755ebcf189 100644 --- a/clients/native/Cargo.toml +++ b/clients/native/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nym-client" -version = "1.1.73" +version = "1.1.74" authors = ["Dave Hrycyszyn ", "Jędrzej Stuczyński "] description = "Implementation of the Nym Client" edition = "2021" diff --git a/clients/socks5/Cargo.toml b/clients/socks5/Cargo.toml index bfa71ee74f..249d801cc2 100644 --- a/clients/socks5/Cargo.toml +++ b/clients/socks5/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nym-socks5-client" -version = "1.1.73" +version = "1.1.74" authors = ["Dave Hrycyszyn "] description = "A SOCKS5 localhost proxy that converts incoming messages to Sphinx and sends them to a Nym address" edition = "2021" diff --git a/nym-api/Cargo.toml b/nym-api/Cargo.toml index 47b992d409..8ce67d316a 100644 --- a/nym-api/Cargo.toml +++ b/nym-api/Cargo.toml @@ -4,7 +4,7 @@ [package] name = "nym-api" license = "GPL-3.0" -version = "1.1.76" +version = "1.1.77" authors.workspace = true edition = "2021" rust-version.workspace = true diff --git a/nym-node/Cargo.toml b/nym-node/Cargo.toml index 73c192f13f..26eb3d6f12 100644 --- a/nym-node/Cargo.toml +++ b/nym-node/Cargo.toml @@ -3,7 +3,7 @@ [package] name = "nym-node" -version = "1.28.0" +version = "1.29.0" authors.workspace = true repository.workspace = true homepage.workspace = true diff --git a/service-providers/network-requester/Cargo.toml b/service-providers/network-requester/Cargo.toml index 71ec9783e5..fcb1d3a0d6 100644 --- a/service-providers/network-requester/Cargo.toml +++ b/service-providers/network-requester/Cargo.toml @@ -4,7 +4,7 @@ [package] name = "nym-network-requester" license = "GPL-3.0" -version = "1.1.74" +version = "1.1.75" authors.workspace = true edition.workspace = true rust-version = "1.85" diff --git a/tools/nym-cli/Cargo.toml b/tools/nym-cli/Cargo.toml index c3a1600b87..1c5bced200 100644 --- a/tools/nym-cli/Cargo.toml +++ b/tools/nym-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nym-cli" -version = "1.1.73" +version = "1.1.74" authors.workspace = true edition = "2021" license.workspace = true diff --git a/tools/nymvisor/Cargo.toml b/tools/nymvisor/Cargo.toml index 724ed219b4..a99cb29472 100644 --- a/tools/nymvisor/Cargo.toml +++ b/tools/nymvisor/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nymvisor" -version = "0.1.38" +version = "0.1.39" authors.workspace = true repository.workspace = true homepage.workspace = true From caf21076c9b4af03b349ba89b1c3ac38f7f4ac88 Mon Sep 17 00:00:00 2001 From: benedettadavico Date: Mon, 30 Mar 2026 10:37:45 +0200 Subject: [PATCH 20/23] .. --- common/cosmwasm-smart-contracts/ecash-contract/src/counters.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/common/cosmwasm-smart-contracts/ecash-contract/src/counters.rs b/common/cosmwasm-smart-contracts/ecash-contract/src/counters.rs index f4cd70720b..48ee6766bd 100644 --- a/common/cosmwasm-smart-contracts/ecash-contract/src/counters.rs +++ b/common/cosmwasm-smart-contracts/ecash-contract/src/counters.rs @@ -14,5 +14,6 @@ pub struct PoolCounters { /// Represents the total amount of tickets requested to be redeemed from the contract and get moved into the holding account, /// after that functionality got disabled. + #[serde(default)] pub tickets_requested_and_not_redeemed: u64, } From ef20b8c7d1f063e185e286522e0118972f9888f3 Mon Sep 17 00:00:00 2001 From: Simon Wicky Date: Tue, 31 Mar 2026 14:39:34 +0200 Subject: [PATCH 21/23] serde magic on ecash contract --- contracts/ecash/src/contract/mod.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/contracts/ecash/src/contract/mod.rs b/contracts/ecash/src/contract/mod.rs index a832ce0c5d..ad989902d4 100644 --- a/contracts/ecash/src/contract/mod.rs +++ b/contracts/ecash/src/contract/mod.rs @@ -186,6 +186,8 @@ impl NymEcashContract { } #[sv::msg(query)] + #[sv::attr(serde(alias = "get_required_deposit_amount"))] + #[sv::attr(serde(alias = "GetRequiredDepositAmount"))] pub fn get_default_deposit_amount(&self, ctx: QueryCtx) -> StdResult { let deposit_amount = self.config.load(ctx.deps.storage)?.deposit_amount; From 01e3c8206bb3df6f7ef7a5f195e92b6c624d709d Mon Sep 17 00:00:00 2001 From: Simon Wicky Date: Tue, 31 Mar 2026 17:32:57 +0200 Subject: [PATCH 22/23] alias not working, adding separate method --- contracts/ecash/src/contract/mod.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/contracts/ecash/src/contract/mod.rs b/contracts/ecash/src/contract/mod.rs index ad989902d4..78b415c457 100644 --- a/contracts/ecash/src/contract/mod.rs +++ b/contracts/ecash/src/contract/mod.rs @@ -186,14 +186,18 @@ impl NymEcashContract { } #[sv::msg(query)] - #[sv::attr(serde(alias = "get_required_deposit_amount"))] - #[sv::attr(serde(alias = "GetRequiredDepositAmount"))] pub fn get_default_deposit_amount(&self, ctx: QueryCtx) -> StdResult { let deposit_amount = self.config.load(ctx.deps.storage)?.deposit_amount; Ok(deposit_amount) } + // Poor man's alias for backwards compatibility as sv::attr didn't seem to work + #[sv::msg(query)] + pub fn get_required_deposit_amount(&self, ctx: QueryCtx) -> StdResult { + self.get_default_deposit_amount(ctx) + } + #[sv::msg(query)] pub fn get_reduced_deposit_amount( &self, From 97068b2aace6626e3dff6864a26e7b1ff35f5725 Mon Sep 17 00:00:00 2001 From: benedettadavico Date: Tue, 7 Apr 2026 15:51:44 +0200 Subject: [PATCH 23/23] update changelog --- CHANGELOG.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ddc2bbf14..f41a9eb2d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,28 @@ Post 1.0.0 release, the changelog format is based on [Keep a Changelog](https:// ## [Unreleased] +## [2026.7-tola] (2026-04-07) + +- Simon/ecash contract serde fix ([#6634]) +- Update Fallback IP for Nym API ([#6622]) +- Nym Node spam logging ([#6621]) +- feat: multiple deposit prices ([#6608]) +- move format_debug_bytes in common crate ([#6580]) +- bugfix: make sure client keys are generated before requesting credentials ([#6579]) +- Fix socks5 GW probe regression ([#6576]) +- Max/lp stream framing ([#6573]) +- HTTP domain rotation conditions ([#6570]) + +[#6634]: https://github.com/nymtech/nym/pull/6634 +[#6622]: https://github.com/nymtech/nym/pull/6622 +[#6621]: https://github.com/nymtech/nym/pull/6621 +[#6608]: https://github.com/nymtech/nym/pull/6608 +[#6580]: https://github.com/nymtech/nym/pull/6580 +[#6579]: https://github.com/nymtech/nym/pull/6579 +[#6576]: https://github.com/nymtech/nym/pull/6576 +[#6573]: https://github.com/nymtech/nym/pull/6573 +[#6570]: https://github.com/nymtech/nym/pull/6570 + ## [2026.6-stilton] (2026-03-25) - lp fixes ([#6601])