diff --git a/.github/workflows/build-deb-meta.yml b/.github/workflows/build-deb-meta.yml index be9da77868..bb87aff2ef 100644 --- a/.github/workflows/build-deb-meta.yml +++ b/.github/workflows/build-deb-meta.yml @@ -25,14 +25,14 @@ jobs: echo "file2=$(ls nym-vpn*.deb)" >> $GITHUB_ENV - name: Upload nym-repo-setup - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: ${{ env.file1 }} path: ppa/packages/nym-repo-setup*.deb retention-days: 10 - name: Upload nym-vpn - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: ${{ env.file2 }} path: ppa/packages/nym-vpn*.deb diff --git a/.github/workflows/cd-docs.yml b/.github/workflows/cd-docs.yml index 98995e6986..cb49561a7a 100644 --- a/.github/workflows/cd-docs.yml +++ b/.github/workflows/cd-docs.yml @@ -21,12 +21,12 @@ jobs: run: sudo apt-get install -y rsync - uses: rlespinasse/github-slug-action@v3.x - name: Setup pnpm - uses: pnpm/action-setup@v4.2.0 + uses: pnpm/action-setup@v5.0.0 with: - version: 9 + version: 11.1.2 - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 24 - name: Install Rust toolchain uses: actions-rs/toolchain@v1 with: @@ -37,6 +37,9 @@ jobs: command: build args: --workspace --release + - name: Verify doc versions + run: ${{ github.workspace }}/documentation/scripts/verify-doc-versions.sh + working-directory: ${{ github.workspace }} - name: Install project dependencies run: pnpm i - name: Generate llms-full.txt diff --git a/.github/workflows/ci-build-ts.yml b/.github/workflows/ci-build-ts.yml index 5c61977c48..3586a0dc54 100644 --- a/.github/workflows/ci-build-ts.yml +++ b/.github/workflows/ci-build-ts.yml @@ -17,13 +17,16 @@ jobs: run: sudo apt-get install rsync continue-on-error: true - uses: rlespinasse/github-slug-action@v3.x + - name: Setup pnpm + uses: pnpm/action-setup@v5.0.0 + with: + version: 11.1.2 - uses: actions/setup-node@v4 with: - node-version: 20 - - name: Setup yarn - run: npm install -g yarn + node-version: 24 + cache: pnpm - name: Build - run: yarn && yarn build && yarn build:ci:storybook + run: pnpm install && pnpm build && pnpm build:ci:storybook - name: Deploy branch to CI www (storybook) continue-on-error: true uses: easingthemes/ssh-deploy@main diff --git a/.github/workflows/ci-build-upload-binaries.yml b/.github/workflows/ci-build-upload-binaries.yml index 03ac19b7f2..0ddd5afe7f 100644 --- a/.github/workflows/ci-build-upload-binaries.yml +++ b/.github/workflows/ci-build-upload-binaries.yml @@ -110,7 +110,7 @@ jobs: - name: Upload Artifact if: github.event_name == 'workflow_dispatch' - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: nym-binaries-artifacts path: | diff --git a/.github/workflows/ci-build-upload-network-monitor-agent.yml b/.github/workflows/ci-build-upload-network-monitor-agent.yml new file mode 100644 index 0000000000..92bbc3d250 --- /dev/null +++ b/.github/workflows/ci-build-upload-network-monitor-agent.yml @@ -0,0 +1,63 @@ +name: ci-build-upload-network-monitor-agent + +on: + workflow_dispatch: + +jobs: + build-and-upload: + strategy: + fail-fast: false + matrix: + platform: [arc-ubuntu-22.04] + + runs-on: ${{ matrix.platform }} + env: + CARGO_TERM_COLOR: always + RUSTUP_PERMIT_COPY_RENAME: 1 + + steps: + - uses: actions/checkout@v6 + + - name: Prepare build output directory + shell: bash + env: + OUTPUT_DIR: ci-builds/${{ github.ref_name }} + run: | + rm -rf ci-builds || true + mkdir -p "$OUTPUT_DIR" + + - name: Install Dependencies (Linux) + run: sudo apt-get update && sudo apt-get -y install libudev-dev + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@master + with: + toolchain: ${{ vars.REQUIRED_RUSTC_VERSION }} + + - name: Build nym-network-monitor-agent + shell: bash + run: cargo build -p nym-network-monitor-agent --release + + - name: Upload artifact + uses: actions/upload-artifact@v6 + with: + name: nym-network-monitor-agent + path: target/release/nym-network-monitor-agent + retention-days: 30 + + - name: Prepare build output + shell: bash + env: + OUTPUT_DIR: ci-builds/${{ github.ref_name }} + run: cp target/release/nym-network-monitor-agent "$OUTPUT_DIR" + + - name: Deploy to CI www + uses: easingthemes/ssh-deploy@main + env: + SSH_PRIVATE_KEY: ${{ secrets.CI_WWW_SSH_PRIVATE_KEY }} + ARGS: "-avzr" + SOURCE: "ci-builds/" + REMOTE_HOST: ${{ secrets.CI_WWW_REMOTE_HOST }} + REMOTE_USER: ${{ secrets.CI_WWW_REMOTE_USER }} + TARGET: ${{ secrets.CI_WWW_REMOTE_TARGET }}/builds/ + EXCLUDE: "/dist/, /node_modules/" diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index 1546b9d4dc..b4d5a87a6d 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -23,7 +23,6 @@ on: - 'sdk/ffi/**' - 'sdk/rust/**' - 'service-providers/**' - - 'nym-browser-extension/storage/**' - 'tools/**' - 'wasm/**' - 'Cargo.toml' diff --git a/.github/workflows/ci-crates-preflight.yml b/.github/workflows/ci-crates-preflight.yml new file mode 100644 index 0000000000..28948a1572 --- /dev/null +++ b/.github/workflows/ci-crates-preflight.yml @@ -0,0 +1,19 @@ +name: ci-crates-preflight +on: + workflow_dispatch: + pull_request: + paths: + - 'Cargo.toml' + - '**/Cargo.toml' + - 'tools/internal/check_publish_preflight.py' + - '.github/workflows/ci-crates-preflight.yml' + +jobs: + preflight: + runs-on: ubuntu-latest + steps: + - name: Checkout repo + uses: actions/checkout@v6 + + - name: Preflight publish checks + run: python3 tools/internal/check_publish_preflight.py diff --git a/.github/workflows/ci-crates-publish-dry-run.yml b/.github/workflows/ci-crates-publish-dry-run.yml index fa2714e7bb..5aef78c8c3 100644 --- a/.github/workflows/ci-crates-publish-dry-run.yml +++ b/.github/workflows/ci-crates-publish-dry-run.yml @@ -40,7 +40,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: "20" + node-version: 24 - name: Validate version format run: | @@ -57,7 +57,8 @@ jobs: - name: Update workspace dependencies run: | - sed -i '/path = /s/version = "${{ steps.current_version.outputs.version }}"/version = "${{ inputs.version }}"/g' Cargo.toml + # Match any semver version on lines with `path = `, not just the current workspace version. + sed -i '/path = /s/version = "[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*"/version = "${{ inputs.version }}"/g' Cargo.toml - name: Bump versions (local only) run: | diff --git a/.github/workflows/ci-crates-publish.yml b/.github/workflows/ci-crates-publish.yml index 94290c8a2d..57f744e7f2 100644 --- a/.github/workflows/ci-crates-publish.yml +++ b/.github/workflows/ci-crates-publish.yml @@ -33,7 +33,11 @@ jobs: - name: Install cargo-workspaces run: cargo install cargo-workspaces - # `--publish-as-is` skips version bumping since that's done in a separate CI job. + - name: Preflight publish checks + run: | + python3 tools/internal/check_publish_preflight.py + + # --publish-as-is skips version bumping since that's done in a separate CI job. - name: Publish env: CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} diff --git a/.github/workflows/ci-crates-version-bump.yml b/.github/workflows/ci-crates-version-bump.yml index eebc0d64ac..21c34e0784 100644 --- a/.github/workflows/ci-crates-version-bump.yml +++ b/.github/workflows/ci-crates-version-bump.yml @@ -19,6 +19,7 @@ jobs: RUSTUP_PERMIT_COPY_RENAME: 1 permissions: contents: write + pull-requests: write steps: - name: Checkout repo uses: actions/checkout@v6 @@ -41,7 +42,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: "20" + node-version: 24 - name: Validate version format run: | @@ -58,7 +59,9 @@ jobs: - name: Update workspace dependencies run: | - sed -i '/path = /s/version = "${{ steps.current_version.outputs.version }}"/version = "${{ inputs.version }}"/g' Cargo.toml + # Match any semver version on lines with `path = `, not just the current workspace version. + # This catches entries whose version has drifted (e.g. nym-sqlx-pool-guard at 1.2.0). + sed -i '/path = /s/version = "[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*"/version = "${{ inputs.version }}"/g' Cargo.toml - name: Bump versions run: | @@ -68,9 +71,33 @@ jobs: - name: Commit and push version bump run: | + set -euo pipefail + BASE_BRANCH="${GITHUB_REF_NAME}" + PR_BRANCH="ci/crates-version-bump-${{ inputs.version }}-${GITHUB_RUN_ID}" + + git checkout -b "$PR_BRANCH" git add -A git commit -m "crates release: bump version to ${{ inputs.version }}" - git push + git push -u origin "$PR_BRANCH" + + cat > /tmp/crates-version-bump-pr-body.md <<'EOF' + This PR was created by CI because direct pushes to the release branch are blocked by branch protection rules. + + ## Summary + - Bump workspace crate versions to the requested release version. + - Update workspace dependency versions accordingly. + + ## Notes + - Merge this PR to proceed with crates.io publishing. + EOF + + gh pr create \ + --base "$BASE_BRANCH" \ + --head "$PR_BRANCH" \ + --title "crates release: bump version to ${{ inputs.version }}" \ + --body-file /tmp/crates-version-bump-pr-body.md + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Show package versions run: cargo workspaces list --long diff --git a/.github/workflows/ci-docs.yml b/.github/workflows/ci-docs.yml index 50b168b82d..7a76619701 100644 --- a/.github/workflows/ci-docs.yml +++ b/.github/workflows/ci-docs.yml @@ -28,12 +28,12 @@ jobs: run: sudo apt-get install -y rsync - uses: rlespinasse/github-slug-action@v3.x - name: Setup pnpm - uses: pnpm/action-setup@v4.2.0 + uses: pnpm/action-setup@v5.0.0 with: - version: 9 + version: 11.1.2 - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 24 - name: Install Rust toolchain uses: actions-rs/toolchain@v1 with: @@ -61,6 +61,9 @@ jobs: cd ${{ github.workspace }}/sdk/typescript/packages/sdk && typedoc --skipErrorChecking cd ${{ github.workspace }}/sdk/typescript/packages/mix-fetch && typedoc --skipErrorChecking + - name: Verify doc versions + run: ${{ github.workspace }}/documentation/scripts/verify-doc-versions.sh + working-directory: ${{ github.workspace }} - name: Install project dependencies run: pnpm i - name: Generate llms-full.txt diff --git a/.github/workflows/ci-lint-typescript.yml b/.github/workflows/ci-lint-typescript.yml index 51bacc44f8..ad5ae63fac 100644 --- a/.github/workflows/ci-lint-typescript.yml +++ b/.github/workflows/ci-lint-typescript.yml @@ -20,12 +20,14 @@ jobs: - uses: actions/checkout@v6 - uses: rlespinasse/github-slug-action@v3.x + - name: Setup pnpm + uses: pnpm/action-setup@v5.0.0 + with: + version: 11.1.2 - uses: actions/setup-node@v4 with: - node-version: 20 - - - name: Setup yarn - run: npm install -g yarn + node-version: 24 + cache: pnpm - name: Install Rust toolchain uses: actions-rs/toolchain@v1 @@ -44,16 +46,16 @@ jobs: go-version: "1.24.6" - name: Install - run: yarn + run: pnpm i - name: Build packages - run: yarn build:ci + run: pnpm build:ci - name: Install again - run: yarn + run: pnpm i - name: Lint - run: yarn lint + run: pnpm lint - name: Typecheck with tsc - run: yarn tsc + run: pnpm tsc diff --git a/.github/workflows/ci-nym-wallet-frontend.yml b/.github/workflows/ci-nym-wallet-frontend.yml index 2365379634..3bc746959a 100644 --- a/.github/workflows/ci-nym-wallet-frontend.yml +++ b/.github/workflows/ci-nym-wallet-frontend.yml @@ -12,30 +12,34 @@ jobs: steps: - uses: actions/checkout@v6 + - name: Setup pnpm + uses: pnpm/action-setup@v5.0.0 + with: + version: 11.1.2 + - uses: actions/setup-node@v4 with: node-version-file: nym-wallet/.nvmrc - cache: yarn - cache-dependency-path: yarn.lock + cache: pnpm - name: Install dependencies - run: yarn install --network-timeout 100000 + run: pnpm install - name: Build TypeScript packages (wallet depends on @nymproject/types, etc.) - run: yarn build:types + run: pnpm build:types - name: Build @nymproject/mui-theme and @nymproject/react (wallet imports subpaths) - run: yarn build:packages + run: pnpm build:packages - name: Typecheck nym-wallet - run: yarn --cwd nym-wallet tsc + run: pnpm --filter @nymproject/nym-wallet-app tsc - name: Lint nym-wallet - run: yarn --cwd nym-wallet lint + run: pnpm --filter @nymproject/nym-wallet-app lint - - name: Yarn audit (workspace lockfile; informational) - run: yarn audit --level critical + - name: pnpm audit (workspace lockfile; informational) + run: pnpm audit --audit-level critical continue-on-error: true - name: Unit tests (nym-wallet) - run: yarn --cwd nym-wallet test + run: pnpm --filter @nymproject/nym-wallet-app test diff --git a/.github/workflows/ci-sdk-wasm.yml b/.github/workflows/ci-sdk-wasm.yml index 702cdfbfa3..94cdca9e22 100644 --- a/.github/workflows/ci-sdk-wasm.yml +++ b/.github/workflows/ci-sdk-wasm.yml @@ -20,7 +20,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 24 - uses: actions-rs/toolchain@v1 with: diff --git a/.github/workflows/nightly-security-audit.yml b/.github/workflows/nightly-security-audit.yml index 8d03483a9c..624e050a28 100644 --- a/.github/workflows/nightly-security-audit.yml +++ b/.github/workflows/nightly-security-audit.yml @@ -20,7 +20,7 @@ jobs: find . -name Cargo.toml -exec cargo deny --manifest-path {} check \ advisories -A advisory-not-detected --hide-inclusion-graph \; &> \ >(uniq &> .github/workflows/support-files/notifications/deny.message ) - - uses: actions/upload-artifact@v6 + - uses: actions/upload-artifact@v7 with: name: report path: .github/workflows/support-files/notifications/deny.message diff --git a/.github/workflows/publish-nym-binaries.yml b/.github/workflows/publish-nym-binaries.yml index 0a15eb2717..03f56d573e 100644 --- a/.github/workflows/publish-nym-binaries.yml +++ b/.github/workflows/publish-nym-binaries.yml @@ -66,7 +66,7 @@ jobs: args: --workspace --release ${{ env.CARGO_FEATURES }} - name: Upload Artifact - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: my-artifact path: | diff --git a/.github/workflows/publish-nym-contracts.yml b/.github/workflows/publish-nym-contracts.yml index a93b0d5c93..63687caeca 100644 --- a/.github/workflows/publish-nym-contracts.yml +++ b/.github/workflows/publish-nym-contracts.yml @@ -27,14 +27,14 @@ jobs: run: make contracts - name: Upload Mixnet Contract Artifact - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: mixnet_contract.wasm path: contracts/target/wasm32-unknown-unknown/release/mixnet_contract.wasm retention-days: 5 - name: Upload Vesting Contract Artifact - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: vesting_contract.wasm path: contracts/target/wasm32-unknown-unknown/release/vesting_contract.wasm diff --git a/.github/workflows/publish-nym-wallet-macos.yml b/.github/workflows/publish-nym-wallet-macos.yml index 1cd4887570..95746a92f2 100644 --- a/.github/workflows/publish-nym-wallet-macos.yml +++ b/.github/workflows/publish-nym-wallet-macos.yml @@ -23,10 +23,13 @@ jobs: steps: - uses: actions/checkout@v6 - - name: Node - uses: actions/setup-node@v4 + - name: Setup pnpm + uses: pnpm/action-setup@v5.0.0 with: - node-version: 22.13.0 + version: 11.1.2 + - uses: actions/setup-node@v4 + with: + node-version: 24 - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable @@ -68,17 +71,17 @@ jobs: fileName: '.env' encodedString: ${{ secrets.WALLET_ADMIN_ADDRESS }} - - name: Yarn cache clean + - name: pnpm cache clean shell: bash - run: cd .. && yarn cache clean + run: cd .. && pnpm cache delete - name: Install project dependencies shell: bash - run: cd .. && yarn --network-timeout 100000 + run: cd .. && pnpm i - - name: Yarn build + - name: Build shell: bash - run: cd .. && yarn build + run: cd .. && pnpm build - name: Install dependencies and build it env: @@ -97,7 +100,7 @@ jobs: TAURI_NOTARIZATION_PASSWORD: ${{ secrets.APPLE_PASSWORD }} TAURI_NOTARIZATION_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} run: | - yarn build-macx86 + pnpm build-macx86 - name: Create app tarball run: | @@ -108,7 +111,7 @@ jobs: cd - - name: Upload Artifact - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: nym-wallet.app.tar.gz path: nym-wallet/target/x86_64-apple-darwin/release/bundle/macos/nym-wallet.app.tar.gz diff --git a/.github/workflows/publish-nym-wallet-ubuntu.yml b/.github/workflows/publish-nym-wallet-ubuntu.yml index 3fdc95939e..f1c95d1f4f 100644 --- a/.github/workflows/publish-nym-wallet-ubuntu.yml +++ b/.github/workflows/publish-nym-wallet-ubuntu.yml @@ -26,12 +26,17 @@ jobs: libwebkit2gtk-4.1-dev build-essential curl wget libssl-dev jq \ libgtk-3-dev squashfs-tools libayatana-appindicator3-dev make libfuse2 unzip librsvg2-dev file \ libsoup-3.0-dev libjavascriptcoregtk-4.1-dev - + + - name: Setup pnpm + uses: pnpm/action-setup@v5.0.0 + with: + version: 11.1.2 + - name: Node uses: actions/setup-node@v4 with: - node-version: 22.13.0 - cache: 'yarn' + node-version: 24 + cache: 'pnpm' - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable @@ -40,10 +45,10 @@ jobs: - name: Install project dependencies shell: bash - run: cd .. && yarn --network-timeout 100000 + run: cd .. && pnpm i - name: Install app dependencies - run: yarn + run: pnpm - name: Create env file uses: timheuer/base64-to-file@v1.2 @@ -52,7 +57,7 @@ jobs: encodedString: ${{ secrets.WALLET_ADMIN_ADDRESS }} - name: Build app - run: yarn build + run: pnpm build env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} @@ -132,7 +137,7 @@ jobs: fi - name: Upload Artifact - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: nym-wallet-appimage.tar.gz path: | diff --git a/.github/workflows/publish-nym-wallet-win11.yml b/.github/workflows/publish-nym-wallet-win11.yml index e05940fd96..060a84ab18 100644 --- a/.github/workflows/publish-nym-wallet-win11.yml +++ b/.github/workflows/publish-nym-wallet-win11.yml @@ -38,18 +38,15 @@ jobs: toolchain: ${{ vars.REQUIRED_RUSTC_VERSION }} - name: Setup MSBuild.exe - uses: microsoft/setup-msbuild@v2 + uses: microsoft/setup-msbuild@v3 - # No cache:yarn here: setup-node needs yarn on PATH to populate the cache, but this runner - # only gets yarn from the step below. - - name: Node - uses: actions/setup-node@v4 + - name: Setup pnpm + uses: pnpm/action-setup@v5.0.0 with: - node-version: 22.13.0 - - - name: Install Yarn (classic) - shell: bash - run: npm install -g yarn@1.22.22 + version: 11.1.2 + - uses: actions/setup-node@v4 + with: + node-version: 24 - name: Strip Authenticode thumbprint (avoid signtool on runner) working-directory: nym-wallet/src-tauri @@ -118,11 +115,11 @@ jobs: ' tauri.conf.json - name: Install project dependencies shell: bash - run: cd .. && yarn --network-timeout 100000 + run: cd .. && pnpm i - name: Install app dependencies shell: bash - run: yarn --network-timeout 100000 + run: pnpm i - name: Build and sign it shell: bash @@ -136,7 +133,7 @@ jobs: SSL_COM_TOTP_SECRET: ${{ env.SIGN_WINDOWS == 'true' && secrets.SSL_COM_TOTP_SECRET }} run: | echo "Starting build process..." - yarn build + pnpm build - name: Check bundle directory shell: bash @@ -165,7 +162,7 @@ jobs: find . -name "*.msi" -type f - name: Upload Artifact - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: nym-wallet.msi path: | diff --git a/.github/workflows/publish-nyms5-android-apk.yml b/.github/workflows/publish-nyms5-android-apk.yml index 6241452068..f38ff56df3 100644 --- a/.github/workflows/publish-nyms5-android-apk.yml +++ b/.github/workflows/publish-nyms5-android-apk.yml @@ -76,7 +76,7 @@ jobs: apk/nyms5-arch64-release.apk - name: Upload APKs - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: nyms5-apk-arch64 path: | @@ -91,7 +91,7 @@ jobs: - name: Checkout uses: actions/checkout@v6 - name: Download binary artifact - uses: actions/download-artifact@v7 + uses: actions/download-artifact@v8 with: name: nyms5-apk-arch64 path: apk diff --git a/.github/workflows/publish-sdk-npm.yml b/.github/workflows/publish-sdk-npm.yml index 7eed254d1e..fa474ae048 100644 --- a/.github/workflows/publish-sdk-npm.yml +++ b/.github/workflows/publish-sdk-npm.yml @@ -8,15 +8,17 @@ jobs: steps: - uses: actions/checkout@v6 + - name: Setup pnpm + uses: pnpm/action-setup@v5.0.0 + with: + version: 11.1.2 + - name: Install Node uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 24 registry-url: "https://registry.npmjs.org" - - name: Setup yarn - run: npm install -g yarn - - name: Install rust toolchain uses: actions-rs/toolchain@v1 with: @@ -40,10 +42,10 @@ jobs: run: ./wasm/mix-fetch/go-mix-conn/scripts/update-root-certs.sh - name: Install dependencies - run: yarn + run: pnpm i - name: Build WASM and Typescript SDK - run: yarn sdk:build + run: pnpm sdk:build - name: Publish to NPM env: diff --git a/.github/workflows/push-credential-proxy.yaml b/.github/workflows/push-credential-proxy.yaml index a45e8be309..7c54250358 100644 --- a/.github/workflows/push-credential-proxy.yaml +++ b/.github/workflows/push-credential-proxy.yaml @@ -11,7 +11,7 @@ jobs: runs-on: arc-linux-latest-dind steps: - name: Login to Harbor - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: harbor.nymte.ch username: ${{ secrets.HARBOR_ROBOT_USERNAME }} diff --git a/.github/workflows/push-data-observatory.yaml b/.github/workflows/push-data-observatory.yaml index 4af505b9a5..9b6d4e1ae4 100644 --- a/.github/workflows/push-data-observatory.yaml +++ b/.github/workflows/push-data-observatory.yaml @@ -11,7 +11,7 @@ jobs: runs-on: arc-linux-latest-dind steps: - name: Login to Harbor - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: harbor.nymte.ch username: ${{ secrets.HARBOR_ROBOT_USERNAME }} diff --git a/.github/workflows/push-network-monitor.yaml b/.github/workflows/push-network-monitor.yaml index a277d6431e..14a90c4ea1 100644 --- a/.github/workflows/push-network-monitor.yaml +++ b/.github/workflows/push-network-monitor.yaml @@ -11,7 +11,7 @@ jobs: runs-on: arc-ubuntu-22.04-dind steps: - name: Login to Harbor - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: harbor.nymte.ch username: ${{ secrets.HARBOR_ROBOT_USERNAME }} diff --git a/.github/workflows/push-node-status-agent.yaml b/.github/workflows/push-node-status-agent.yaml index 7140d16253..a371d1009b 100644 --- a/.github/workflows/push-node-status-agent.yaml +++ b/.github/workflows/push-node-status-agent.yaml @@ -18,7 +18,7 @@ jobs: runs-on: arc-linux-latest-dind steps: - name: Login to Harbor - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: harbor.nymte.ch username: ${{ secrets.HARBOR_ROBOT_USERNAME }} diff --git a/.github/workflows/push-node-status-api.yaml b/.github/workflows/push-node-status-api.yaml index 380be82338..a966c3f292 100644 --- a/.github/workflows/push-node-status-api.yaml +++ b/.github/workflows/push-node-status-api.yaml @@ -17,7 +17,7 @@ jobs: runs-on: arc-linux-latest-dind steps: - name: Login to Harbor - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: harbor.nymte.ch username: ${{ secrets.HARBOR_ROBOT_USERNAME }} diff --git a/.github/workflows/push-nym-api.yaml b/.github/workflows/push-nym-api.yaml index 86477f6da0..25863f34e4 100644 --- a/.github/workflows/push-nym-api.yaml +++ b/.github/workflows/push-nym-api.yaml @@ -11,7 +11,7 @@ jobs: runs-on: arc-ubuntu-22.04-dind steps: - name: Login to Harbor - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: harbor.nymte.ch username: ${{ secrets.HARBOR_ROBOT_USERNAME }} diff --git a/.github/workflows/push-nym-node.yaml b/.github/workflows/push-nym-node.yaml index 9081795a5a..a349c95e05 100644 --- a/.github/workflows/push-nym-node.yaml +++ b/.github/workflows/push-nym-node.yaml @@ -11,7 +11,7 @@ jobs: runs-on: arc-ubuntu-22.04-dind steps: - name: Login to Harbor - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: harbor.nymte.ch username: ${{ secrets.HARBOR_ROBOT_USERNAME }} diff --git a/.github/workflows/push-nym-statistics-api.yaml b/.github/workflows/push-nym-statistics-api.yaml index 505fc87cd0..fa75cbe470 100644 --- a/.github/workflows/push-nym-statistics-api.yaml +++ b/.github/workflows/push-nym-statistics-api.yaml @@ -11,7 +11,7 @@ jobs: runs-on: arc-linux-latest-dind steps: - name: Login to Harbor - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: harbor.nymte.ch username: ${{ secrets.HARBOR_ROBOT_USERNAME }} diff --git a/.github/workflows/push-nyx-chain-watcher.yaml b/.github/workflows/push-nyx-chain-watcher.yaml index 7db8161813..f73f14adb4 100644 --- a/.github/workflows/push-nyx-chain-watcher.yaml +++ b/.github/workflows/push-nyx-chain-watcher.yaml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Login to Harbor - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: harbor.nymte.ch username: ${{ secrets.HARBOR_ROBOT_USERNAME }} diff --git a/.github/workflows/push-validator-rewarder.yaml b/.github/workflows/push-validator-rewarder.yaml index c8ba6415a8..5be692caaa 100644 --- a/.github/workflows/push-validator-rewarder.yaml +++ b/.github/workflows/push-validator-rewarder.yaml @@ -11,7 +11,7 @@ jobs: runs-on: arc-ubuntu-22.04-dind steps: - name: Login to Harbor - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: harbor.nymte.ch username: ${{ secrets.HARBOR_ROBOT_USERNAME }} diff --git a/.github/workflows/release-calculate-hash.yml b/.github/workflows/release-calculate-hash.yml index 23b772bb24..1384d223c5 100644 --- a/.github/workflows/release-calculate-hash.yml +++ b/.github/workflows/release-calculate-hash.yml @@ -23,14 +23,14 @@ jobs: uses: actions/checkout@v6 - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 24 - uses: nymtech/nym/.github/actions/nym-hash-releases@develop env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: release-tag-or-name-or-id: ${{ inputs.release_tag }} - - uses: actions/upload-artifact@v6 + - uses: actions/upload-artifact@v7 with: name: Asset Hashes path: hashes.json diff --git a/.gitignore b/.gitignore index 1178ece5d5..c1b82af03c 100644 --- a/.gitignore +++ b/.gitignore @@ -78,3 +78,8 @@ CLAUDE.md /notes /target-otel +test-tutorials/ + +# pnpm +.pnpm-store/ + diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000000..7d2410d2ff --- /dev/null +++ b/.npmrc @@ -0,0 +1,9 @@ +shamefully-hoist=false +prefer-workspace-packages=true +hoist-pattern[]=*eslint* +hoist-pattern[]=*prettier* +hoist-pattern[]=*typescript* +hoist-pattern[]=*@types* + +auto-install-peers=true +strict-peer-dependencies=false diff --git a/Cargo.lock b/Cargo.lock index c71fe45850..0ae68ee118 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,7 +11,7 @@ dependencies = [ "macroific", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -24,15 +24,6 @@ dependencies = [ "psl-types", ] -[[package]] -name = "addr2line" -version = "0.24.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" -dependencies = [ - "gimli", -] - [[package]] name = "adler2" version = "2.0.1" @@ -45,7 +36,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ - "crypto-common", + "crypto-common 0.1.6", "generic-array 0.14.7", ] @@ -56,10 +47,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", - "cipher", + "cipher 0.4.4", "cpufeatures 0.2.17", ] +[[package]] +name = "aes" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66bd29a732b644c0431c6140f370d097879203d79b80c94a6747ba0872adaef8" +dependencies = [ + "cipher 0.5.2", + "cpubits", + "cpufeatures 0.3.0", +] + [[package]] name = "aes-gcm" version = "0.10.3" @@ -67,8 +69,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" dependencies = [ "aead", - "aes", - "cipher", + "aes 0.8.4", + "cipher 0.4.4", "ctr", "ghash", "subtle 2.6.1", @@ -81,14 +83,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae0784134ba9375416d469ec31e7c5f9fa94405049cf08c5ce5b4698be673e0d" dependencies = [ "aead", - "aes", - "cipher", + "aes 0.8.4", + "cipher 0.4.4", "ctr", "polyval", "subtle 2.6.1", "zeroize", ] +[[package]] +name = "aes-keywrap" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10b6f24a1f796bc46415a1d0d18dc0a8203ccba088acf5def3291c4f61225522" +dependencies = [ + "aes 0.9.0", + "byteorder", +] + [[package]] name = "ahash" version = "0.8.12" @@ -144,12 +156,6 @@ dependencies = [ "url", ] -[[package]] -name = "android-tzdata" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" - [[package]] name = "android_system_properties" version = "0.1.5" @@ -226,9 +232,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.98" +version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "arbitrary" @@ -374,7 +380,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94893f1e0c6eeab764ade8dc4c0db24caf4fe7cbbaafc0eba0a9030f447b5185" dependencies = [ "num-traits", - "rand 0.8.5", + "rand 0.8.6", "rayon", ] @@ -430,7 +436,7 @@ dependencies = [ "rustc-hash", "serde", "serde_derive", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -447,7 +453,7 @@ dependencies = [ "rustc-hash", "serde", "serde_derive", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -474,16 +480,6 @@ dependencies = [ "winnow", ] -[[package]] -name = "assert-json-diff" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" -dependencies = [ - "serde", - "serde_json", -] - [[package]] name = "async-compression" version = "0.4.27" @@ -530,7 +526,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -541,7 +537,7 @@ checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -587,12 +583,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "auto-future" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c1e7e457ea78e524f48639f551fd79703ac3f2237f5ecccdf4708f8a75ad373" - [[package]] name = "autocfg" version = "1.5.0" @@ -631,96 +621,49 @@ dependencies = [ [[package]] name = "axum" -version = "0.7.9" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ - "async-trait", - "axum-core 0.4.5", + "axum-core", "axum-macros", "bytes", + "form_urlencoded", "futures-util", - "http 1.3.1", + "http 1.4.0", "http-body 1.0.1", "http-body-util", - "hyper 1.6.0", + "hyper 1.9.0", "hyper-util", "itoa", - "matchit 0.7.3", - "memchr", - "mime", - "percent-encoding", - "pin-project-lite", - "rustversion", - "serde", - "serde_json", - "serde_path_to_error", - "serde_urlencoded", - "sync_wrapper 1.0.2", - "tokio", - "tower 0.5.2", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "axum" -version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" -dependencies = [ - "axum-core 0.5.6", - "bytes", - "futures-util", - "http 1.3.1", - "http-body 1.0.1", - "http-body-util", - "itoa", - "matchit 0.8.4", + "matchit", "memchr", "mime", "percent-encoding", "pin-project-lite", "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", "sync_wrapper 1.0.2", - "tower 0.5.2", + "tokio", + "tower", "tower-layer", "tower-service", + "tracing", ] [[package]] name = "axum-client-ip" -version = "0.6.1" +version = "1.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9eefda7e2b27e1bda4d6fa8a06b50803b8793769045918bc37ad062d48a6efac" +checksum = "a8ba1af5b620232acf37f2eb6d22151ea465491e0b4c25f552d1990f64ec5a67" dependencies = [ - "axum 0.7.9", - "forwarded-header-value", + "axum", + "client-ip", "serde", ] -[[package]] -name = "axum-core" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" -dependencies = [ - "async-trait", - "bytes", - "futures-util", - "http 1.3.1", - "http-body 1.0.1", - "http-body-util", - "mime", - "pin-project-lite", - "rustversion", - "sync_wrapper 1.0.2", - "tower-layer", - "tower-service", - "tracing", -] - [[package]] name = "axum-core" version = "0.5.6" @@ -729,7 +672,7 @@ checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ "bytes", "futures-core", - "http 1.3.1", + "http 1.4.0", "http-body 1.0.1", "http-body-util", "mime", @@ -737,59 +680,57 @@ dependencies = [ "sync_wrapper 1.0.2", "tower-layer", "tower-service", + "tracing", ] [[package]] name = "axum-extra" -version = "0.9.6" +version = "0.12.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c794b30c904f0a1c2fb7740f7df7f7972dfaa14ef6f57cb6178dc63e5dca2f04" +checksum = "be44683b41ccb9ab2d23a5230015c9c3c55be97a25e4428366de8873103f7970" dependencies = [ - "axum 0.7.9", - "axum-core 0.4.5", + "axum", + "axum-core", "bytes", - "fastrand", + "futures-core", "futures-util", "headers", - "http 1.3.1", + "http 1.4.0", "http-body 1.0.1", "http-body-util", "mime", - "multer", "pin-project-lite", - "serde", - "tower 0.5.2", "tower-layer", "tower-service", + "tracing", ] [[package]] name = "axum-macros" -version = "0.4.2" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57d123550fa8d071b7255cb0cc04dc302baa6c8c4a79f55701552684d8399bce" +checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] name = "axum-test" -version = "16.4.1" +version = "20.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e3a443d2608936a02a222da7b746eb412fede7225b3030b64fe9be99eab8dc" +checksum = "3a86bfe2ef15bee102ac34912f7f4542b0bb37dc464fa55461763999c4d625e7" dependencies = [ "anyhow", - "assert-json-diff", - "auto-future", - "axum 0.7.9", + "axum", "bytes", "bytesize", "cookie", - "http 1.3.1", + "expect-json", + "http 1.4.0", "http-body-util", - "hyper 1.6.0", + "hyper 1.9.0", "hyper-util", "mime", "pretty_assertions", @@ -798,27 +739,11 @@ dependencies = [ "serde", "serde_json", "serde_urlencoded", - "smallvec", "tokio", - "tower 0.5.2", + "tower", "url", ] -[[package]] -name = "backtrace" -version = "0.3.75" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" -dependencies = [ - "addr2line", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", - "windows-targets 0.52.6", -] - [[package]] name = "base16ct" version = "0.2.0" @@ -909,7 +834,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "43d193de1f7487df1914d3a568b772458861d33f9c54249612cc2893d6915054" dependencies = [ "bitcoin_hashes", - "rand 0.8.5", + "rand 0.8.6", "rand_core 0.6.4", "serde", "unicode-normalization", @@ -982,13 +907,13 @@ dependencies = [ [[package]] name = "blake2b_simd" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06e903a20b159e944f91ec8499fe1e55651480c541ea0a584f5d967c49ad9d99" +checksum = "b79834656f71332577234b50bfc009996f7449e0c056884e6a02492ded0ca2f3" dependencies = [ "arrayref", "arrayvec", - "constant_time_eq", + "constant_time_eq 0.4.2", ] [[package]] @@ -1001,7 +926,7 @@ dependencies = [ "arrayvec", "cc", "cfg-if", - "constant_time_eq", + "constant_time_eq 0.3.1", "digest 0.10.7", ] @@ -1023,6 +948,15 @@ dependencies = [ "generic-array 0.14.7", ] +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + [[package]] name = "block-padding" version = "0.3.3" @@ -1127,9 +1061,9 @@ dependencies = [ [[package]] name = "bytesize" -version = "1.3.3" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e93abca9e28e0a1b9877922aacb20576e05d4679ffa78c3d6dc22a26a216659" +checksum = "6bd91ee7b2422bcb158d90ef4d14f75ef67f340943fc4149891dcce8f8b972a3" [[package]] name = "camino" @@ -1196,7 +1130,7 @@ dependencies = [ "serde", "serde-untagged", "serde-value", - "thiserror 2.0.12", + "thiserror 2.0.18", "toml 0.8.23", "unicode-xid", "url", @@ -1227,7 +1161,7 @@ dependencies = [ "semver 1.0.27", "serde", "serde_json", - "thiserror 2.0.12", + "thiserror 2.0.18", ] [[package]] @@ -1242,7 +1176,7 @@ dependencies = [ "semver 1.0.27", "serde", "serde_json", - "thiserror 2.0.12", + "thiserror 2.0.18", ] [[package]] @@ -1308,7 +1242,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" dependencies = [ "cfg-if", - "cipher", + "cipher 0.4.4", "cpufeatures 0.2.17", ] @@ -1331,24 +1265,23 @@ checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" dependencies = [ "aead", "chacha20 0.9.1", - "cipher", + "cipher 0.4.4", "poly1305", "zeroize", ] [[package]] name = "chrono" -version = "0.4.41" +version = "0.4.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ - "android-tzdata", "iana-time-zone", "js-sys", "num-traits", "serde", "wasm-bindgen", - "windows-link 0.1.3", + "windows-link 0.2.1", ] [[package]] @@ -1384,11 +1317,21 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", - "inout", + "crypto-common 0.1.6", + "inout 0.1.4", "zeroize", ] +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "crypto-common 0.2.2", + "inout 0.2.2", +] + [[package]] name = "clap" version = "4.5.60" @@ -1460,7 +1403,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -1475,11 +1418,20 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "62a9b6d27e553269a76625911aa8cf6afaa8659f1b0c85b410cb5f51a87183d9" dependencies = [ - "rand 0.8.5", - "sha3", + "rand 0.8.6", + "sha3 0.10.8", "zeroize", ] +[[package]] +name = "client-ip" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39d2056bf065c8b4bce5a8898d40e175211ff4410add2a84d695845d3937c729" +dependencies = [ + "http 1.4.0", +] + [[package]] name = "cmake" version = "0.1.57" @@ -1490,10 +1442,16 @@ dependencies = [ ] [[package]] -name = "coarsetime" -version = "0.1.36" +name = "cmov" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91849686042de1b41cd81490edc83afbcb0abe5a9b6f2c4114f23ce8cca1bcf4" +checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746" + +[[package]] +name = "coarsetime" +version = "0.1.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e58eb270476aa4fc7843849f8a35063e8743b4dbcdf6dd0f8ea0886980c204c2" dependencies = [ "libc", "wasix", @@ -1578,22 +1536,23 @@ dependencies = [ [[package]] name = "console-api" -version = "0.8.1" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8030735ecb0d128428b64cd379809817e620a40e5001c54465b99ec5feec2857" +checksum = "e8599749b6667e2f0c910c1d0dff6901163ff698a52d5a39720f61b5be4b20d3" dependencies = [ "futures-core", - "prost 0.13.5", + "prost 0.14.3", "prost-types", - "tonic 0.12.3", + "tonic 0.14.4", + "tonic-prost", "tracing-core", ] [[package]] name = "console-subscriber" -version = "0.4.1" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6539aa9c6a4cd31f4b1c040f860a1eac9aa80e7df6b05d506a6e7179936d6a01" +checksum = "fb4915b7d8dd960457a1b6c380114c2944f728e7c65294ab247ae6b6f1f37592" dependencies = [ "console-api", "crossbeam-channel", @@ -1602,14 +1561,14 @@ dependencies = [ "hdrhistogram", "humantime", "hyper-util", - "prost 0.13.5", + "prost 0.14.3", "prost-types", "serde", "serde_json", "thread_local", "tokio", "tokio-stream", - "tonic 0.12.3", + "tonic 0.14.4", "tracing", "tracing-core", "tracing-subscriber", @@ -1631,6 +1590,12 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "const-str" version = "0.5.7" @@ -1663,6 +1628,12 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + [[package]] name = "cookie" version = "0.18.1" @@ -1737,7 +1708,7 @@ dependencies = [ "rand_core 0.6.4", "serde", "serde_json", - "signature", + "signature 2.2.0", "subtle-encoding", "tendermint", "tendermint-rpc", @@ -1782,7 +1753,7 @@ checksum = "a782b93fae93e57ca8ad3e9e994e784583f5933aeaaa5c80a545c4b437be2047" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -1806,7 +1777,7 @@ checksum = "e01c9214319017f6ebd8e299036e1f717fa9bb6724e758f7d6fb2477599d1a29" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -1833,6 +1804,12 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -2029,6 +2006,17 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "getrandom 0.4.1", + "hybrid-array", + "rand_core 0.10.1", +] + [[package]] name = "crypto-mac" version = "0.7.0" @@ -2059,7 +2047,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" dependencies = [ "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -2095,7 +2083,16 @@ version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" dependencies = [ - "cipher", + "cipher 0.4.4", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", ] [[package]] @@ -2133,7 +2130,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -2181,7 +2178,7 @@ dependencies = [ "schemars 0.8.22", "serde", "sha2 0.10.9", - "thiserror 2.0.12", + "thiserror 2.0.18", ] [[package]] @@ -2285,7 +2282,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -2296,7 +2293,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -2338,7 +2335,7 @@ dependencies = [ "parking_lot", "ring", "socket2 0.6.0", - "thiserror 2.0.12", + "thiserror 2.0.18", "tracing", "uniffi 0.31.0", "untrusted", @@ -2365,7 +2362,7 @@ dependencies = [ "nix 0.30.1", "regex", "serde", - "thiserror 2.0.12", + "thiserror 2.0.18", "windows 0.62.2", "wireguard-nt", "x25519-dalek", @@ -2400,7 +2397,7 @@ dependencies = [ "proc-macro-error2", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -2409,7 +2406,7 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" dependencies = [ - "thiserror 2.0.12", + "thiserror 2.0.18", ] [[package]] @@ -2423,7 +2420,7 @@ dependencies = [ "macroific", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -2432,11 +2429,21 @@ version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "const-oid", + "const-oid 0.9.6", "pem-rfc7468", "zeroize", ] +[[package]] +name = "der" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b" +dependencies = [ + "const-oid 0.10.2", + "zeroize", +] + [[package]] name = "deranged" version = "0.4.0" @@ -2466,7 +2473,7 @@ checksum = "30542c1ad912e0e3d22a1935c290e12e8a29d704a420177a31faad4a601a0800" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -2487,7 +2494,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -2497,7 +2504,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -2526,7 +2533,7 @@ checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", "unicode-xid", ] @@ -2538,7 +2545,7 @@ checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", "unicode-xid", ] @@ -2573,11 +2580,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", - "const-oid", - "crypto-common", + "const-oid 0.9.6", + "crypto-common 0.1.6", "subtle 2.6.1", ] +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.0", + "crypto-common 0.2.2", +] + [[package]] name = "dirs" version = "6.0.0" @@ -2607,7 +2624,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -2662,11 +2679,11 @@ checksum = "1c7a8fb8a9fbf66c1f703fe16184d10ca0ee9d23be5b4436400408ba54a95005" [[package]] name = "easy-addr" -version = "1.20.4" +version = "1.21.0" dependencies = [ "cosmwasm-std", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -2675,13 +2692,13 @@ version = "0.16.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ - "der", + "der 0.7.10", "digest 0.10.7", "elliptic-curve", "rfc6979", "serdect 0.2.0", - "signature", - "spki", + "signature 2.2.0", + "spki 0.7.3", ] [[package]] @@ -2715,19 +2732,19 @@ version = "2.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" dependencies = [ - "pkcs8", + "pkcs8 0.10.2", "serde", - "signature", + "signature 2.2.0", ] [[package]] name = "ed25519-compact" -version = "2.1.1" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9b3460f44bea8cd47f45a0c70892f1eff856d97cd55358b2f73f663789f6190" +checksum = "f5c0284a5d4b1a2fae017a9fe55fd7d01699711f1b572493f16593e173ea2801" dependencies = [ "ct-codecs", - "getrandom 0.2.16", + "getrandom 0.4.1", ] [[package]] @@ -2796,7 +2813,7 @@ dependencies = [ "group", "hkdf", "pem-rfc7468", - "pkcs8", + "pkcs8 0.10.2", "rand_core 0.6.4", "sec1", "serdect 0.2.0", @@ -2804,6 +2821,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + [[package]] name = "encode_unicode" version = "1.0.0" @@ -2911,19 +2937,32 @@ dependencies = [ ] [[package]] -name = "extension-storage" -version = "1.4.1" +name = "expect-json" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "869f97f4abe8e78fc812a94ad6b721d72c4fb5532877c79610f2c238d7ccf6c4" dependencies = [ - "bip39", - "console_error_panic_hook", - "js-sys", - "nym-wasm-storage", - "nym-wasm-utils", - "serde-wasm-bindgen 0.6.5", - "thiserror 2.0.12", - "wasm-bindgen", - "wasm-bindgen-futures", - "zeroize", + "chrono", + "email_address", + "expect-json-macros", + "num", + "regex", + "serde", + "serde_json", + "thiserror 2.0.18", + "typetag", + "uuid", +] + +[[package]] +name = "expect-json-macros" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e6fdf550180a6c29a28cb9aac262dc0064c25735641d2317f670075e9a469d9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -2951,7 +2990,7 @@ dependencies = [ "macroific", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -3007,6 +3046,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a3d7db9596fecd151c5f638c0ee5d5bd487b6e0ea232e5dc96d5250f6f94b1d" dependencies = [ "crc32fast", + "libz-rs-sys", "miniz_oxide", ] @@ -3052,16 +3092,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "forwarded-header-value" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8835f84f38484cc86f110a805655697908257fb9a7af005234060891557198e9" -dependencies = [ - "nonempty", - "thiserror 1.0.69", -] - [[package]] name = "fs-err" version = "2.11.0" @@ -3119,9 +3149,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", "futures-sink", @@ -3129,9 +3159,9 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-executor" @@ -3157,38 +3187,38 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] name = "futures-macro" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] name = "futures-sink" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" [[package]] name = "futures-task" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-util" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-channel", "futures-core", @@ -3198,7 +3228,6 @@ dependencies = [ "futures-task", "memchr", "pin-project-lite", - "pin-utils", "slab", ] @@ -3277,11 +3306,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", "rand_core 0.10.1", "wasip2", "wasip3", + "wasm-bindgen", ] [[package]] @@ -3294,12 +3325,6 @@ dependencies = [ "polyval", ] -[[package]] -name = "gimli" -version = "0.31.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" - [[package]] name = "glob" version = "0.3.2" @@ -3316,7 +3341,7 @@ dependencies = [ "futures-core", "futures-sink", "gloo-utils 0.2.0", - "http 1.3.1", + "http 1.4.0", "js-sys", "pin-project", "serde", @@ -3417,7 +3442,7 @@ dependencies = [ "fnv", "futures-core", "futures-sink", - "http 1.3.1", + "http 1.4.0", "indexmap 2.13.0", "slab", "tokio", @@ -3530,7 +3555,7 @@ dependencies = [ "proc-macro-error2", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -3568,7 +3593,7 @@ dependencies = [ "base64 0.22.1", "bytes", "headers-core", - "http 1.3.1", + "http 1.4.0", "httpdate", "mime", "sha1", @@ -3580,7 +3605,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" dependencies = [ - "http 1.3.1", + "http 1.4.0", ] [[package]] @@ -3653,13 +3678,13 @@ dependencies = [ "futures-util", "h2 0.4.11", "hickory-proto", - "http 1.3.1", + "http 1.4.0", "idna", "ipnet", "jni 0.22.4", "rand 0.10.1", "rustls 0.23.37", - "thiserror 2.0.12", + "thiserror 2.0.18", "tinyvec", "tokio", "tokio-rustls 0.26.2", @@ -3682,7 +3707,7 @@ dependencies = [ "prefix-trie", "rand 0.10.1", "ring", - "thiserror 2.0.12", + "thiserror 2.0.18", "tinyvec", "tracing", "url", @@ -3710,7 +3735,7 @@ dependencies = [ "rustls 0.23.37", "smallvec", "system-configuration 0.7.0", - "thiserror 2.0.12", + "thiserror 2.0.18", "tokio", "tokio-rustls 0.26.2", "tracing", @@ -3749,24 +3774,24 @@ dependencies = [ [[package]] name = "hmac-sha1-compact" -version = "1.1.5" +version = "1.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18492c9f6f9a560e0d346369b665ad2bdbc89fa9bceca75796584e79042694c3" +checksum = "b0b3ba31f6dc772cc8221ce81dbbbd64fa1e668255a6737d95eeace59b5a8823" [[package]] name = "hmac-sha256" -version = "1.1.12" +version = "1.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad6880c8d4a9ebf39c6e8b77007ce223f646a4d21ce29d99f70cb16420545425" +checksum = "ec9d92d097f4749b64e8cc33d924d9f40a2d4eb91402b458014b781f5733d60f" dependencies = [ "digest 0.10.7", ] [[package]] name = "hmac-sha512" -version = "1.1.7" +version = "1.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e89e8d20b3799fa526152a5301a771eaaad80857f83e01b23216ceaafb2d9280" +checksum = "019ece39bbefc17f13f677a690328cb978dbf6790e141a3c24e66372cb38588b" dependencies = [ "digest 0.10.7", ] @@ -3804,12 +3829,11 @@ dependencies = [ [[package]] name = "http" -version = "1.3.1" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" dependencies = [ "bytes", - "fnv", "itoa", ] @@ -3831,7 +3855,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http 1.3.1", + "http 1.4.0", ] [[package]] @@ -3842,7 +3866,7 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http 1.3.1", + "http 1.4.0", "http-body 1.0.1", "pin-project-lite", ] @@ -3897,6 +3921,16 @@ dependencies = [ "serde", ] +[[package]] +name = "hybrid-array" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +dependencies = [ + "ctutils", + "typenum", +] + [[package]] name = "hyper" version = "0.14.32" @@ -3923,15 +3957,16 @@ dependencies = [ [[package]] name = "hyper" -version = "1.6.0" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" dependencies = [ + "atomic-waker", "bytes", "futures-channel", - "futures-util", + "futures-core", "h2 0.4.11", - "http 1.3.1", + "http 1.4.0", "http-body 1.0.1", "httparse", "httpdate", @@ -3962,8 +3997,8 @@ version = "0.27.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" dependencies = [ - "http 1.3.1", - "hyper 1.6.0", + "http 1.4.0", + "hyper 1.9.0", "hyper-util", "rustls 0.23.37", "rustls-native-certs 0.8.3", @@ -3980,7 +4015,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" dependencies = [ - "hyper 1.6.0", + "hyper 1.9.0", "hyper-util", "pin-project-lite", "tokio", @@ -3989,23 +4024,22 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.15" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f66d5bd4c6f02bf0542fad85d626775bab9258cf795a4256dcaf3161114d1df" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ "base64 0.22.1", "bytes", "futures-channel", - "futures-core", "futures-util", - "http 1.3.1", + "http 1.4.0", "http-body 1.0.1", - "hyper 1.6.0", + "hyper 1.9.0", "ipnet", "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.0", "tokio", "tower-service", "tracing", @@ -4195,7 +4229,7 @@ checksum = "0ab604ee7085efba6efc65e4ebca0e9533e3aff6cb501d7d77b211e3a781c6d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -4248,7 +4282,7 @@ dependencies = [ "js-sys", "sealed", "smallvec", - "thiserror 2.0.12", + "thiserror 2.0.18", "tokio", "wasm-bindgen", "wasm-bindgen-futures", @@ -4264,7 +4298,7 @@ dependencies = [ "macroific", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -4344,6 +4378,15 @@ dependencies = [ "generic-array 0.14.7", ] +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "hybrid-array", +] + [[package]] name = "inquire" version = "0.6.2" @@ -4392,17 +4435,6 @@ dependencies = [ "rustversion", ] -[[package]] -name = "io-uring" -version = "0.7.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b86e202f00093dcba4275d4636b93ef9dd75d025ae560d2521b45ea28ab49013" -dependencies = [ - "bitflags 2.9.1", - "cfg-if", - "libc", -] - [[package]] name = "ip_network" version = "0.4.1" @@ -4527,7 +4559,7 @@ checksum = "03343451ff899767262ec32146f6d559dd759fdadf42ff0e227c7c48f72594b4" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -4558,7 +4590,7 @@ dependencies = [ "jni-sys 0.4.1", "log", "simd_cesu8", - "thiserror 2.0.12", + "thiserror 2.0.18", "walkdir", "windows-link 0.2.1", ] @@ -4573,7 +4605,7 @@ dependencies = [ "quote", "rustc_version 0.4.1", "simd_cesu8", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -4598,7 +4630,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -4623,9 +4655,9 @@ dependencies = [ [[package]] name = "jwt-simple" -version = "0.12.12" +version = "0.12.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "731011e9647a71ff4f8474176ff6ce6e0d2de87a0173f15613af3a84c3e3401a" +checksum = "f45048cd18221c81d27dd4621d51df25b33f11b68655d79f67a9f9d3eb48fecc" dependencies = [ "anyhow", "binstring", @@ -4639,11 +4671,11 @@ dependencies = [ "k256", "p256", "p384", - "rand 0.8.5", + "rand 0.8.6", "serde", "serde_json", "superboring", - "thiserror 2.0.12", + "thiserror 2.0.18", "zeroize", ] @@ -4658,7 +4690,7 @@ dependencies = [ "elliptic-curve", "once_cell", "sha2 0.10.9", - "signature", + "signature 2.2.0", ] [[package]] @@ -4670,6 +4702,16 @@ dependencies = [ "cpufeatures 0.2.17", ] +[[package]] +name = "keccak" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e24a010dd405bd7ed803e5253182815b41bf2e6a80cc3bfc066658e03a198aa" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", +] + [[package]] name = "keystream" version = "1.0.0" @@ -4880,7 +4922,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ffd6aa2dcd5be681662001b81d493f1569c6d49a32361f470b0c955465cd0338" dependencies = [ "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -4964,7 +5006,7 @@ dependencies = [ "libcrux-ml-kem", "libcrux-sha2", "libcrux-traits", - "rand 0.8.5", + "rand 0.8.6", "rand 0.9.2", "tls_codec", ] @@ -5049,6 +5091,15 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "libz-rs-sys" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c10501e7805cee23da17c7790e59df2870c0d4043ec6d03f67d31e2b53e77415" +dependencies = [ + "zlib-rs", +] + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -5110,7 +5161,7 @@ dependencies = [ "nym-performance-contract-common", "nym-validator-client", "nym-vesting-contract-common", - "rand 0.8.5", + "rand 0.8.6", "reqwest 0.13.1", "serde", "serde_json", @@ -5186,7 +5237,7 @@ dependencies = [ "proc-macro2", "quote", "sealed", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -5198,7 +5249,7 @@ dependencies = [ "proc-macro2", "quote", "sealed", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -5211,7 +5262,7 @@ dependencies = [ "macroific_core", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -5245,7 +5296,7 @@ checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -5257,12 +5308,6 @@ dependencies = [ "regex-automata", ] -[[package]] -name = "matchit" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" - [[package]] name = "matchit" version = "0.8.4" @@ -5373,10 +5418,10 @@ dependencies = [ "nym-validator-client", "nym-wasm-client-core", "nym-wasm-utils", - "rand 0.8.5", + "rand 0.8.6", "serde", "serde-wasm-bindgen 0.6.5", - "thiserror 2.0.12", + "thiserror 2.0.18", "tokio", "tsify", "url", @@ -5399,12 +5444,39 @@ dependencies = [ "tracing", ] +[[package]] +name = "ml-dsa" +version = "0.1.0-rc.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "163f15320f3fba11760c373af52d7f69d638482c2c350d877fb06513b1c3137c" +dependencies = [ + "const-oid 0.10.2", + "crypto-common 0.2.2", + "ctutils", + "hybrid-array", + "module-lattice", + "pkcs8 0.11.0", + "sha3 0.11.0", + "signature 3.0.0", +] + [[package]] name = "mock_instant" version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dce6dd36094cac388f119d2e9dc82dc730ef91c32a6222170d630e5414b956e6" +[[package]] +name = "module-lattice" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c61b87c9683ab7cb1c6871d261ad5479b6b10ceb52c4352aaca3b5d35a8febe" +dependencies = [ + "ctutils", + "hybrid-array", + "num-traits", +] + [[package]] name = "moka" version = "0.12.10" @@ -5427,23 +5499,6 @@ dependencies = [ "uuid", ] -[[package]] -name = "multer" -version = "3.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" -dependencies = [ - "bytes", - "encoding_rs", - "futures-util", - "http 1.3.1", - "httparse", - "memchr", - "mime", - "spin", - "version_check", -] - [[package]] name = "ndk-context" version = "0.1.1" @@ -5488,7 +5543,7 @@ checksum = "3176f18d11a1ae46053e59ec89d46ba318ae1343615bd3f8c908bfc84edae35c" dependencies = [ "byteorder", "pastey 0.1.1", - "thiserror 2.0.12", + "thiserror 2.0.18", ] [[package]] @@ -5588,12 +5643,6 @@ dependencies = [ "minimal-lexical", ] -[[package]] -name = "nonempty" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9e591e719385e6ebaeb5ce5d3887f7d5676fceca6411d1925ccc95745f3d6f7" - [[package]] name = "notify" version = "5.2.0" @@ -5630,6 +5679,20 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -5642,21 +5705,29 @@ dependencies = [ [[package]] name = "num-bigint-dig" -version = "0.8.4" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc84195820f291c7697304f3cbdadd1cb7199c0efc917ff5eafd71225c136151" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" dependencies = [ - "byteorder", "lazy_static", "libm", "num-integer", "num-iter", "num-traits", - "rand 0.8.5", + "rand 0.8.6", "smallvec", "zeroize", ] +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.1.0" @@ -5683,6 +5754,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -5712,7 +5794,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -5730,7 +5812,7 @@ version = "1.1.80" dependencies = [ "anyhow", "async-trait", - "axum 0.7.9", + "axum", "axum-test", "bincode", "bip39", @@ -5768,6 +5850,7 @@ dependencies = [ "nym-http-api-client", "nym-http-api-common", "nym-mixnet-contract-common", + "nym-node-families-contract-common", "nym-node-requests", "nym-node-tester-utils", "nym-pemstore", @@ -5780,7 +5863,7 @@ dependencies = [ "nym-types", "nym-validator-client", "pin-project", - "rand 0.8.5", + "rand 0.8.6", "rand_chacha 0.3.1", "reqwest 0.13.1", "schemars 0.8.22", @@ -5792,7 +5875,7 @@ dependencies = [ "tempfile", "tendermint", "test-with", - "thiserror 2.0.12", + "thiserror 2.0.18", "time", "tokio", "tokio-stream", @@ -5809,7 +5892,7 @@ dependencies = [ [[package]] name = "nym-api-requests" -version = "1.20.4" +version = "1.21.0" dependencies = [ "bs58", "celes", @@ -5842,7 +5925,7 @@ dependencies = [ "strum_macros 0.28.0", "tendermint", "tendermint-rpc", - "thiserror 2.0.12", + "thiserror 2.0.18", "time", "tracing", "ts-rs", @@ -5851,7 +5934,7 @@ dependencies = [ [[package]] name = "nym-async-file-watcher" -version = "1.20.4" +version = "1.21.0" dependencies = [ "futures", "log", @@ -5861,7 +5944,7 @@ dependencies = [ [[package]] name = "nym-authenticator-client" -version = "1.20.4" +version = "1.21.0" dependencies = [ "bincode", "futures", @@ -5875,7 +5958,7 @@ dependencies = [ "nym-validator-client", "nym-wireguard-types", "semver 1.0.27", - "thiserror 2.0.12", + "thiserror 2.0.18", "tokio", "tokio-util", "tracing", @@ -5883,7 +5966,7 @@ dependencies = [ [[package]] name = "nym-authenticator-requests" -version = "1.20.4" +version = "1.21.0" dependencies = [ "base64 0.22.1", "bincode", @@ -5895,19 +5978,19 @@ dependencies = [ "nym-sphinx", "nym-test-utils", "nym-wireguard-types", - "rand 0.8.5", + "rand 0.8.6", "semver 1.0.27", "serde", "sha2 0.10.9", "strum_macros 0.28.0", - "thiserror 2.0.12", + "thiserror 2.0.18", "tracing", "x25519-dalek", ] [[package]] name = "nym-bandwidth-controller" -version = "1.20.4" +version = "1.21.0" dependencies = [ "async-trait", "log", @@ -5918,13 +6001,13 @@ dependencies = [ "nym-ecash-time", "nym-task", "nym-validator-client", - "rand 0.8.5", - "thiserror 2.0.12", + "rand 0.8.6", + "thiserror 2.0.18", ] [[package]] name = "nym-bin-common" -version = "1.20.4" +version = "1.21.0" dependencies = [ "clap", "clap_complete", @@ -5964,7 +6047,7 @@ dependencies = [ [[package]] name = "nym-cache" -version = "1.20.4" +version = "1.21.0" dependencies = [ "tokio", ] @@ -5995,7 +6078,7 @@ dependencies = [ [[package]] name = "nym-cli-commands" -version = "1.20.4" +version = "1.21.0" dependencies = [ "anyhow", "base64 0.22.1", @@ -6039,12 +6122,12 @@ dependencies = [ "nym-types", "nym-validator-client", "nym-vesting-contract-common", - "rand 0.8.5", + "rand 0.8.6", "serde", "serde_json", "tap", "tempfile", - "thiserror 2.0.12", + "thiserror 2.0.18", "time", "tokio", "toml 0.8.23", @@ -6077,11 +6160,11 @@ dependencies = [ "nym-task", "nym-topology", "nym-validator-client", - "rand 0.8.5", + "rand 0.8.6", "serde", "serde_json", "tap", - "thiserror 2.0.12", + "thiserror 2.0.18", "time", "tokio", "tokio-tungstenite", @@ -6091,7 +6174,7 @@ dependencies = [ [[package]] name = "nym-client-core" -version = "1.20.4" +version = "1.21.0" dependencies = [ "async-trait", "base64 0.22.1", @@ -6104,7 +6187,7 @@ dependencies = [ "gloo-timers", "http-body-util", "humantime", - "hyper 1.6.0", + "hyper 1.9.0", "hyper-util", "nym-bandwidth-controller", "nym-client-core-config-types", @@ -6129,14 +6212,14 @@ dependencies = [ "nym-topology", "nym-validator-client", "nym-wasm-utils", - "rand 0.8.5", + "rand 0.8.6", "rand_chacha 0.3.1", "serde", "serde_json", "sha2 0.10.9", "si-scale", "tempfile", - "thiserror 2.0.12", + "thiserror 2.0.18", "time", "tokio", "tokio-stream", @@ -6153,7 +6236,7 @@ dependencies = [ [[package]] name = "nym-client-core-config-types" -version = "1.20.4" +version = "1.21.0" dependencies = [ "humantime-serde", "nym-config", @@ -6162,13 +6245,13 @@ dependencies = [ "nym-sphinx-params", "nym-statistics-common", "serde", - "thiserror 2.0.12", + "thiserror 2.0.18", "url", ] [[package]] name = "nym-client-core-gateways-storage" -version = "1.20.4" +version = "1.21.0" dependencies = [ "anyhow", "async-trait", @@ -6177,7 +6260,7 @@ dependencies = [ "nym-gateway-requests", "serde", "sqlx", - "thiserror 2.0.12", + "thiserror 2.0.18", "time", "tokio", "tracing", @@ -6187,7 +6270,7 @@ dependencies = [ [[package]] name = "nym-client-core-surb-storage" -version = "1.20.4" +version = "1.21.0" dependencies = [ "anyhow", "async-trait", @@ -6197,7 +6280,7 @@ dependencies = [ "nym-sqlx-pool-guard", "nym-task", "sqlx", - "thiserror 2.0.12", + "thiserror 2.0.18", "time", "tokio", "tracing", @@ -6213,16 +6296,14 @@ dependencies = [ "js-sys", "nym-bin-common", "nym-gateway-requests", - "nym-node-tester-utils", - "nym-node-tester-wasm", "nym-wasm-client-core", "nym-wasm-utils", "once_cell", - "rand 0.8.5", + "rand 0.8.6", "serde", "serde-wasm-bindgen 0.6.5", "serde_json", - "thiserror 2.0.12", + "thiserror 2.0.18", "tokio_with_wasm", "tsify", "wasm-bindgen", @@ -6233,7 +6314,7 @@ dependencies = [ [[package]] name = "nym-client-websocket-requests" -version = "1.20.4" +version = "1.21.0" dependencies = [ "nym-sphinx", "serde", @@ -6242,7 +6323,7 @@ dependencies = [ [[package]] name = "nym-coconut-dkg-common" -version = "1.20.4" +version = "1.21.0" dependencies = [ "cosmwasm-schema", "cosmwasm-std", @@ -6255,7 +6336,7 @@ dependencies = [ [[package]] name = "nym-common" -version = "1.20.4" +version = "1.21.0" dependencies = [ "tracing", "tracing-test", @@ -6263,7 +6344,7 @@ dependencies = [ [[package]] name = "nym-compact-ecash" -version = "1.20.4" +version = "1.21.0" dependencies = [ "bincode", "bs58", @@ -6276,25 +6357,25 @@ dependencies = [ "nym-bls12_381-fork", "nym-network-defaults", "nym-pemstore", - "rand 0.8.5", + "rand 0.8.6", "rayon", "serde", "sha2 0.10.9", "subtle 2.6.1", - "thiserror 2.0.12", + "thiserror 2.0.18", "zeroize", ] [[package]] name = "nym-config" -version = "1.20.4" +version = "1.21.0" dependencies = [ "dirs", "handlebars", "log", "nym-network-defaults", "serde", - "thiserror 2.0.12", + "thiserror 2.0.18", "toml 0.8.23", "url", ] @@ -6311,7 +6392,7 @@ dependencies = [ "nym-ip-packet-requests", "nym-sdk", "pnet_packet", - "thiserror 2.0.12", + "thiserror 2.0.18", "tokio", "tokio-util", "tracing", @@ -6319,7 +6400,7 @@ dependencies = [ [[package]] name = "nym-contracts-common" -version = "1.20.4" +version = "1.21.0" dependencies = [ "anyhow", "bs58", @@ -6329,28 +6410,28 @@ dependencies = [ "schemars 0.8.22", "serde", "serde_json", - "thiserror 2.0.12", + "thiserror 2.0.18", "utoipa", "vergen 8.3.1", ] [[package]] name = "nym-contracts-common-testing" -version = "1.20.4" +version = "1.21.0" dependencies = [ "anyhow", "cosmwasm-std", "cw-multi-test", "cw-storage-plus", "nym-contracts-common", - "rand 0.8.5", + "rand 0.8.6", "rand_chacha 0.3.1", "serde", ] [[package]] name = "nym-cpp-ffi" -version = "1.20.4" +version = "1.21.0" dependencies = [ "anyhow", "bs58", @@ -6367,7 +6448,7 @@ name = "nym-credential-proxy" version = "0.3.2-rc" dependencies = [ "anyhow", - "axum 0.7.9", + "axum", "bip39", "bs58", "cfg-if", @@ -6390,7 +6471,7 @@ dependencies = [ "nym-pemstore", "nym-upgrade-mode-check", "nym-validator-client", - "rand 0.8.5", + "rand 0.8.6", "reqwest 0.13.1", "serde", "serde_json", @@ -6398,7 +6479,7 @@ dependencies = [ "strum 0.28.0", "strum_macros 0.28.0", "tempfile", - "thiserror 2.0.12", + "thiserror 2.0.18", "time", "tokio", "tokio-util", @@ -6413,10 +6494,10 @@ dependencies = [ [[package]] name = "nym-credential-proxy-lib" -version = "1.20.4" +version = "1.21.0" dependencies = [ "anyhow", - "axum 0.7.9", + "axum", "bip39", "bs58", "futures", @@ -6431,7 +6512,7 @@ dependencies = [ "nym-ecash-signer-check", "nym-network-defaults", "nym-validator-client", - "rand 0.8.5", + "rand 0.8.6", "reqwest 0.13.1", "serde", "serde_json", @@ -6439,7 +6520,7 @@ dependencies = [ "strum 0.28.0", "strum_macros 0.28.0", "tempfile", - "thiserror 2.0.12", + "thiserror 2.0.18", "time", "tokio", "tokio-util", @@ -6451,7 +6532,7 @@ dependencies = [ [[package]] name = "nym-credential-proxy-requests" -version = "1.20.4" +version = "1.21.0" dependencies = [ "async-trait", "nym-credentials", @@ -6475,7 +6556,7 @@ dependencies = [ [[package]] name = "nym-credential-storage" -version = "1.20.4" +version = "1.21.0" dependencies = [ "anyhow", "async-trait", @@ -6490,7 +6571,7 @@ dependencies = [ "nym-test-utils", "serde", "sqlx", - "thiserror 2.0.12", + "thiserror 2.0.18", "time", "tokio", "zeroize", @@ -6498,7 +6579,7 @@ dependencies = [ [[package]] name = "nym-credential-utils" -version = "1.20.4" +version = "1.21.0" dependencies = [ "log", "nym-bandwidth-controller", @@ -6509,14 +6590,14 @@ dependencies = [ "nym-credentials-interface", "nym-ecash-time", "nym-validator-client", - "thiserror 2.0.12", + "thiserror 2.0.18", "time", "tokio", ] [[package]] name = "nym-credential-verification" -version = "1.20.4" +version = "1.21.0" dependencies = [ "async-trait", "bs58", @@ -6536,7 +6617,7 @@ dependencies = [ "nym-upgrade-mode-check", "nym-validator-client", "si-scale", - "thiserror 2.0.12", + "thiserror 2.0.18", "time", "tokio", "tracing", @@ -6544,7 +6625,7 @@ dependencies = [ [[package]] name = "nym-credentials" -version = "1.20.4" +version = "1.21.0" dependencies = [ "bincode", "cosmrs", @@ -6559,43 +6640,43 @@ dependencies = [ "nym-network-defaults", "nym-serde-helpers", "nym-validator-client", - "rand 0.8.5", + "rand 0.8.6", "serde", - "thiserror 2.0.12", + "thiserror 2.0.18", "time", "zeroize", ] [[package]] name = "nym-credentials-interface" -version = "1.20.4" +version = "1.21.0" dependencies = [ "nym-bls12_381-fork", "nym-compact-ecash", "nym-ecash-time", "nym-network-defaults", "nym-upgrade-mode-check", - "rand 0.8.5", + "rand 0.8.6", "serde", "strum 0.28.0", "strum_macros 0.28.0", - "thiserror 2.0.12", + "thiserror 2.0.18", "time", "utoipa", ] [[package]] name = "nym-crypto" -version = "1.20.4" +version = "1.21.0" dependencies = [ "aead", - "aes", + "aes 0.8.4", "aes-gcm-siv", "anyhow", "base64 0.22.1", "blake3", "bs58", - "cipher", + "cipher 0.4.4", "ctr", "curve25519-dalek", "digest 0.10.7", @@ -6609,7 +6690,7 @@ dependencies = [ "nym-pemstore", "nym-sphinx-types", "nym-test-utils", - "rand 0.8.5", + "rand 0.8.6", "rand 0.9.2", "rand_chacha 0.3.1", "serde", @@ -6617,7 +6698,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "subtle-encoding", - "thiserror 2.0.12", + "thiserror 2.0.18", "x25519-dalek", "zeroize", ] @@ -6628,7 +6709,7 @@ version = "1.0.1" dependencies = [ "anyhow", "async-trait", - "axum 0.7.9", + "axum", "blake3", "chrono", "clap", @@ -6646,7 +6727,7 @@ dependencies = [ "serde", "serde_json", "sqlx", - "thiserror 2.0.12", + "thiserror 2.0.18", "time", "tokio", "tokio-util", @@ -6661,7 +6742,7 @@ dependencies = [ [[package]] name = "nym-dkg" -version = "1.20.4" +version = "1.21.0" dependencies = [ "bitvec", "bs58", @@ -6671,19 +6752,19 @@ dependencies = [ "nym-bls12_381-fork", "nym-contracts-common", "nym-pemstore", - "rand 0.8.5", + "rand 0.8.6", "rand_chacha 0.3.1", "rand_core 0.6.4", "serde", "serde_derive", "sha2 0.10.9", - "thiserror 2.0.12", + "thiserror 2.0.18", "zeroize", ] [[package]] name = "nym-ecash-contract-common" -version = "1.20.4" +version = "1.21.0" dependencies = [ "bs58", "cosmwasm-schema", @@ -6692,12 +6773,12 @@ dependencies = [ "cw-utils", "cw2", "nym-multisig-contract-common", - "thiserror 2.0.12", + "thiserror 2.0.18", ] [[package]] name = "nym-ecash-signer-check" -version = "1.20.4" +version = "1.21.0" dependencies = [ "futures", "nym-ecash-signer-check-types", @@ -6706,7 +6787,7 @@ dependencies = [ "nym-validator-client", "semver 1.0.27", "serde", - "thiserror 2.0.12", + "thiserror 2.0.18", "tokio", "tracing", "url", @@ -6714,13 +6795,13 @@ dependencies = [ [[package]] name = "nym-ecash-signer-check-types" -version = "1.20.4" +version = "1.21.0" dependencies = [ "nym-coconut-dkg-common", "nym-crypto", "semver 1.0.27", "serde", - "thiserror 2.0.12", + "thiserror 2.0.18", "time", "tracing", "url", @@ -6729,7 +6810,7 @@ dependencies = [ [[package]] name = "nym-ecash-time" -version = "1.20.4" +version = "1.21.0" dependencies = [ "nym-compact-ecash", "time", @@ -6737,19 +6818,19 @@ dependencies = [ [[package]] name = "nym-exit-policy" -version = "1.20.4" +version = "1.21.0" dependencies = [ "reqwest 0.13.1", "serde", "serde_json", - "thiserror 2.0.12", + "thiserror 2.0.18", "tracing", "utoipa", ] [[package]] name = "nym-ffi-shared" -version = "1.20.4" +version = "1.21.0" dependencies = [ "anyhow", "bs58", @@ -6792,6 +6873,7 @@ dependencies = [ "nym-id", "nym-ip-packet-router", "nym-lp", + "nym-lp-data", "nym-metrics", "nym-mixnet-client", "nym-network-defaults", @@ -6810,9 +6892,9 @@ dependencies = [ "nym-wireguard", "nym-wireguard-private-metadata-server", "nym-wireguard-types", - "rand 0.8.5", + "rand 0.8.6", "serde", - "thiserror 2.0.12", + "thiserror 2.0.18", "time", "tokio", "tokio-stream", @@ -6824,7 +6906,7 @@ dependencies = [ [[package]] name = "nym-gateway-client" -version = "1.20.4" +version = "1.21.0" dependencies = [ "futures", "getrandom 0.2.16", @@ -6843,10 +6925,10 @@ dependencies = [ "nym-task", "nym-validator-client", "nym-wasm-utils", - "rand 0.8.5", + "rand 0.8.6", "serde", "si-scale", - "thiserror 2.0.12", + "thiserror 2.0.18", "time", "tokio", "tokio-stream", @@ -6862,7 +6944,7 @@ dependencies = [ [[package]] name = "nym-gateway-probe" -version = "1.20.4" +version = "1.21.0" dependencies = [ "anyhow", "base64 0.22.1", @@ -6889,6 +6971,7 @@ dependencies = [ "nym-ip-packet-requests", "nym-kkt-ciphersuite", "nym-lp", + "nym-lp-data", "nym-network-defaults", "nym-node-requests", "nym-registration-client", @@ -6897,7 +6980,7 @@ dependencies = [ "nym-topology", "nym-validator-client", "pnet_packet", - "rand 0.8.5", + "rand 0.8.6", "rand 0.9.2", "reqwest 0.13.1", "semver 1.0.27", @@ -6915,7 +6998,7 @@ dependencies = [ [[package]] name = "nym-gateway-requests" -version = "1.20.4" +version = "1.21.0" dependencies = [ "anyhow", "bs58", @@ -6931,12 +7014,12 @@ dependencies = [ "nym-statistics-common", "nym-task", "nym-test-utils", - "rand 0.8.5", + "rand 0.8.6", "serde", "serde_json", "strum 0.28.0", "subtle 2.6.1", - "thiserror 2.0.12", + "thiserror 2.0.18", "time", "tokio", "tracing", @@ -6947,7 +7030,7 @@ dependencies = [ [[package]] name = "nym-gateway-stats-storage" -version = "1.20.4" +version = "1.21.0" dependencies = [ "anyhow", "nym-node-metrics", @@ -6955,7 +7038,7 @@ dependencies = [ "nym-statistics-common", "sqlx", "strum 0.28.0", - "thiserror 2.0.12", + "thiserror 2.0.18", "time", "tokio", "tracing", @@ -6963,7 +7046,7 @@ dependencies = [ [[package]] name = "nym-gateway-storage" -version = "1.20.4" +version = "1.21.0" dependencies = [ "anyhow", "async-trait", @@ -6974,7 +7057,7 @@ dependencies = [ "nym-gateway-requests", "nym-sphinx", "sqlx", - "thiserror 2.0.12", + "thiserror 2.0.18", "time", "tokio", "tracing", @@ -6982,7 +7065,7 @@ dependencies = [ [[package]] name = "nym-go-ffi" -version = "1.20.4" +version = "1.21.0" dependencies = [ "anyhow", "lazy_static", @@ -6991,7 +7074,7 @@ dependencies = [ "nym-ffi-shared", "nym-sdk", "nym-sphinx-anonymous-replies", - "thiserror 2.0.12", + "thiserror 2.0.18", "tokio", "uniffi 0.29.3", "uniffi_build 0.29.3", @@ -6999,7 +7082,7 @@ dependencies = [ [[package]] name = "nym-group-contract-common" -version = "1.20.4" +version = "1.21.0" dependencies = [ "cosmwasm-schema", "cw-controllers", @@ -7010,7 +7093,7 @@ dependencies = [ [[package]] name = "nym-http-api-client" -version = "1.20.4" +version = "1.21.0" dependencies = [ "async-trait", "bincode", @@ -7019,7 +7102,7 @@ dependencies = [ "encoding_rs", "fastrand", "hickory-resolver", - "http 1.3.1", + "http 1.4.0", "inventory", "itertools 0.14.0", "mime", @@ -7034,7 +7117,7 @@ dependencies = [ "serde_json", "serde_plain", "serde_yaml", - "thiserror 2.0.12", + "thiserror 2.0.18", "tokio", "tracing", "tracing-subscriber", @@ -7044,22 +7127,22 @@ dependencies = [ [[package]] name = "nym-http-api-client-macro" -version = "1.20.4" +version = "1.21.0" dependencies = [ "nym-http-api-client", "proc-macro-crate", "proc-macro2", "quote", "reqwest 0.13.1", - "syn 2.0.106", + "syn 2.0.117", "uuid", ] [[package]] name = "nym-http-api-common" -version = "1.20.4" +version = "1.21.0" dependencies = [ - "axum 0.7.9", + "axum", "axum-client-ip", "bincode", "bytes", @@ -7071,7 +7154,7 @@ dependencies = [ "serde_yaml", "subtle 2.6.1", "time", - "tower 0.5.2", + "tower", "tracing", "utoipa", "zeroize", @@ -7079,11 +7162,11 @@ dependencies = [ [[package]] name = "nym-id" -version = "1.20.4" +version = "1.21.0" dependencies = [ "nym-credential-storage", "nym-credentials", - "thiserror 2.0.12", + "thiserror 2.0.18", "time", "tracing", "zeroize", @@ -7105,24 +7188,24 @@ dependencies = [ [[package]] name = "nym-inclusion-probability" -version = "1.20.4" +version = "1.21.0" dependencies = [ "log", - "rand 0.8.5", - "thiserror 2.0.12", + "rand 0.8.6", + "thiserror 2.0.18", ] [[package]] name = "nym-ip-packet-client" -version = "1.20.4" +version = "1.21.0" dependencies = [ "bincode", "bytes", "futures", "nym-ip-packet-requests", - "nym-lp", + "nym-lp-data", "nym-sdk", - "thiserror 2.0.12", + "thiserror 2.0.18", "tokio", "tokio-util", "tracing", @@ -7130,7 +7213,7 @@ dependencies = [ [[package]] name = "nym-ip-packet-requests" -version = "1.20.4" +version = "1.21.0" dependencies = [ "bincode", "bytes", @@ -7139,10 +7222,10 @@ dependencies = [ "nym-service-provider-requests-common", "nym-sphinx", "pnet_packet", - "rand 0.8.5", + "rand 0.8.6", "semver 1.0.27", "serde", - "thiserror 2.0.12", + "thiserror 2.0.18", "time", "tokio", "tokio-util", @@ -7151,7 +7234,7 @@ dependencies = [ [[package]] name = "nym-ip-packet-router" -version = "1.20.4" +version = "1.21.0" dependencies = [ "anyhow", "async-trait", @@ -7169,7 +7252,7 @@ dependencies = [ "nym-exit-policy", "nym-id", "nym-ip-packet-requests", - "nym-lp", + "nym-lp-data", "nym-network-defaults", "nym-network-requester", "nym-sdk", @@ -7181,11 +7264,11 @@ dependencies = [ "nym-types", "nym-wireguard", "nym-wireguard-types", - "rand 0.8.5", + "rand 0.8.6", "reqwest 0.13.1", "serde", "serde_json", - "thiserror 2.0.12", + "thiserror 2.0.18", "time", "tokio", "tokio-tun", @@ -7201,13 +7284,13 @@ dependencies = [ "bytes", "env_logger", "log", - "thiserror 2.0.12", + "thiserror 2.0.18", "tokio-util", ] [[package]] name = "nym-kkt" -version = "0.1.0" +version = "1.21.0" dependencies = [ "anyhow", "libcrux-chacha20poly1305", @@ -7224,13 +7307,13 @@ dependencies = [ "rand 0.9.2", "rand_chacha 0.9.0", "strum 0.28.0", - "thiserror 2.0.12", + "thiserror 2.0.18", "zeroize", ] [[package]] name = "nym-kkt-ciphersuite" -version = "1.20.4" +version = "1.21.0" dependencies = [ "blake3", "libcrux-sha3", @@ -7238,46 +7321,46 @@ dependencies = [ "semver 1.0.27", "strum 0.28.0", "strum_macros 0.28.0", - "thiserror 2.0.12", + "thiserror 2.0.18", ] [[package]] name = "nym-kkt-context" -version = "1.20.4" +version = "1.21.0" dependencies = [ "num_enum", "nym-kkt-ciphersuite", - "thiserror 2.0.12", + "thiserror 2.0.18", ] [[package]] name = "nym-ledger" -version = "1.20.4" +version = "1.21.0" dependencies = [ "bip32", "k256", "ledger-transport", "ledger-transport-hid", - "thiserror 2.0.12", + "thiserror 2.0.18", ] [[package]] name = "nym-lp" -version = "1.20.4" +version = "1.21.0" dependencies = [ "anyhow", "bs58", "bytes", "criterion", "libcrux-psq", - "num_enum", "nym-common", "nym-crypto", "nym-kkt", "nym-kkt-ciphersuite", + "nym-lp-data", "nym-test-utils", "rand 0.9.2", - "thiserror 2.0.12", + "thiserror 2.0.18", "tls_codec", "tokio", "tracing", @@ -7298,6 +7381,7 @@ dependencies = [ "nym-kcp", "nym-kkt-ciphersuite", "nym-lp", + "nym-lp-data", "nym-registration-client", "nym-sphinx", "nym-sphinx-addressing", @@ -7307,7 +7391,7 @@ dependencies = [ "nym-sphinx-types", "nym-topology", "nym-validator-client", - "rand 0.8.5", + "rand 0.8.6", "rand 0.9.2", "rand_chacha 0.3.1", "serde", @@ -7320,9 +7404,20 @@ dependencies = [ "url", ] +[[package]] +name = "nym-lp-data" +version = "1.21.0" +dependencies = [ + "bytes", + "num_enum", + "nym-common", + "thiserror 2.0.18", + "tracing", +] + [[package]] name = "nym-metrics" -version = "1.20.4" +version = "1.21.0" dependencies = [ "dashmap", "lazy_static", @@ -7332,7 +7427,7 @@ dependencies = [ [[package]] name = "nym-mixnet-client" -version = "1.20.4" +version = "1.21.0" dependencies = [ "dashmap", "futures", @@ -7340,7 +7435,7 @@ dependencies = [ "nym-noise", "nym-sphinx", "nym-task", - "rand 0.8.5", + "rand 0.8.6", "tokio", "tokio-stream", "tokio-util", @@ -7349,7 +7444,7 @@ dependencies = [ [[package]] name = "nym-mixnet-contract-common" -version = "1.20.4" +version = "1.21.0" dependencies = [ "bs58", "cosmwasm-schema", @@ -7364,7 +7459,7 @@ dependencies = [ "semver 1.0.27", "serde", "serde_repr", - "thiserror 2.0.12", + "thiserror 2.0.18", "time", "ts-rs", "utoipa", @@ -7372,7 +7467,7 @@ dependencies = [ [[package]] name = "nym-mixnode-common" -version = "1.20.4" +version = "1.21.0" dependencies = [ "bytes", "futures", @@ -7388,9 +7483,9 @@ dependencies = [ "nym-sphinx-params", "nym-sphinx-types", "nym-task", - "rand 0.8.5", + "rand 0.8.6", "serde", - "thiserror 2.0.12", + "thiserror 2.0.18", "time", "tokio", "tokio-util", @@ -7399,7 +7494,7 @@ dependencies = [ [[package]] name = "nym-multisig-contract-common" -version = "1.20.4" +version = "1.21.0" dependencies = [ "cosmwasm-schema", "cosmwasm-std", @@ -7409,12 +7504,12 @@ dependencies = [ "cw4", "schemars 0.8.22", "serde", - "thiserror 2.0.12", + "thiserror 2.0.18", ] [[package]] name = "nym-network-defaults" -version = "1.20.4" +version = "1.21.0" dependencies = [ "cargo_metadata 0.19.2", "dotenvy", @@ -7432,7 +7527,7 @@ name = "nym-network-monitor" version = "1.0.2" dependencies = [ "anyhow", - "axum 0.7.9", + "axum", "clap", "dashmap", "futures", @@ -7450,7 +7545,7 @@ dependencies = [ "nym-types", "nym-validator-client", "petgraph", - "rand 0.8.5", + "rand 0.8.6", "rand_chacha 0.3.1", "reqwest 0.13.1", "serde", @@ -7484,7 +7579,7 @@ dependencies = [ "nym-sphinx-types", "nym-task", "nym-test-utils", - "rand 0.8.5", + "rand 0.8.6", "sha2 0.10.9", "time", "tokio", @@ -7500,7 +7595,7 @@ name = "nym-network-monitor-orchestrator" version = "1.0.2" dependencies = [ "anyhow", - "axum 0.7.9", + "axum", "clap", "futures", "humantime", @@ -7515,10 +7610,10 @@ dependencies = [ "nym-task", "nym-test-utils", "nym-validator-client", - "rand 0.8.5", + "rand 0.8.6", "sqlx", "strum 0.28.0", - "thiserror 2.0.12", + "thiserror 2.0.18", "time", "tokio", "tracing", @@ -7531,7 +7626,7 @@ dependencies = [ [[package]] name = "nym-network-monitor-orchestrator-requests" -version = "1.20.4" +version = "1.21.0" dependencies = [ "anyhow", "humantime-serde", @@ -7546,14 +7641,14 @@ dependencies = [ [[package]] name = "nym-network-monitors-contract-common" -version = "1.20.4" +version = "1.21.0" dependencies = [ "cosmwasm-schema", "cosmwasm-std", "cw-controllers", "schemars 0.8.22", "serde", - "thiserror 2.0.12", + "thiserror 2.0.18", ] [[package]] @@ -7590,7 +7685,7 @@ dependencies = [ "nym-task", "nym-types", "publicsuffix", - "rand 0.8.5", + "rand 0.8.6", "regex", "reqwest 0.13.1", "serde", @@ -7598,7 +7693,7 @@ dependencies = [ "sqlx", "tap", "tempfile", - "thiserror 2.0.12", + "thiserror 2.0.18", "time", "tokio", "tokio-tungstenite", @@ -7614,7 +7709,7 @@ dependencies = [ "arc-swap", "arrayref", "async-trait", - "axum 0.7.9", + "axum", "bincode", "bip39", "blake2 0.8.1", @@ -7651,6 +7746,7 @@ dependencies = [ "nym-ip-packet-router", "nym-kkt", "nym-lp", + "nym-lp-data", "nym-metrics", "nym-mixnet-client", "nym-network-requester", @@ -7680,13 +7776,13 @@ dependencies = [ "nyxd-scraper-shared", "opentelemetry", "opentelemetry_sdk", - "rand 0.8.5", + "rand 0.8.6", "rand 0.9.2", "serde", "serde_json", "sha2 0.10.9", "sysinfo", - "thiserror 2.0.12", + "thiserror 2.0.18", "time", "tokio", "tokio-stream", @@ -7702,9 +7798,24 @@ dependencies = [ "zeroize", ] +[[package]] +name = "nym-node-families-contract-common" +version = "1.21.0" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-controllers", + "cw-utils", + "nym-contracts-common", + "nym-mixnet-contract-common", + "schemars 0.8.22", + "serde", + "thiserror 2.0.18", +] + [[package]] name = "nym-node-metrics" -version = "1.20.4" +version = "1.21.0" dependencies = [ "dashmap", "futures", @@ -7718,7 +7829,7 @@ dependencies = [ [[package]] name = "nym-node-requests" -version = "1.20.4" +version = "1.21.0" dependencies = [ "async-trait", "celes", @@ -7738,7 +7849,7 @@ dependencies = [ "serde_json", "strum 0.28.0", "strum_macros 0.28.0", - "thiserror 2.0.12", + "thiserror 2.0.18", "time", "tokio", "url", @@ -7757,7 +7868,7 @@ dependencies = [ "nym-gateway-probe", "nym-node-status-client", "nym-sdk", - "rand 0.8.5", + "rand 0.8.6", "regex", "serde_json", "tempfile", @@ -7772,8 +7883,7 @@ version = "4.6.2-rc5" dependencies = [ "ammonia", "anyhow", - "axum 0.7.9", - "axum-test", + "axum", "bip39", "bs58", "celes", @@ -7783,6 +7893,7 @@ dependencies = [ "humantime", "itertools 0.14.0", "moka", + "nym-api-requests", "nym-bin-common", "nym-contracts-common", "nym-credential-proxy-lib", @@ -7801,7 +7912,7 @@ dependencies = [ "nym-statistics-common", "nym-task", "nym-validator-client", - "rand 0.8.5", + "rand 0.8.6", "rand_chacha 0.3.1", "regex", "reqwest 0.13.1", @@ -7812,7 +7923,7 @@ dependencies = [ "sqlx", "strum 0.28.0", "strum_macros 0.28.0", - "thiserror 2.0.12", + "thiserror 2.0.18", "time", "tokio", "tokio-stream", @@ -7845,7 +7956,7 @@ dependencies = [ [[package]] name = "nym-node-tester-utils" -version = "1.20.4" +version = "1.21.0" dependencies = [ "futures", "log", @@ -7855,37 +7966,17 @@ dependencies = [ "nym-task", "nym-topology", "nym-wasm-utils", - "rand 0.8.5", + "rand 0.8.6", "rand_chacha 0.3.1", "serde", "serde_json", - "thiserror 2.0.12", + "thiserror 2.0.18", "tokio", ] -[[package]] -name = "nym-node-tester-wasm" -version = "1.3.1" -dependencies = [ - "futures", - "js-sys", - "nym-node-tester-utils", - "nym-wasm-client-core", - "nym-wasm-utils", - "rand 0.8.5", - "serde", - "serde-wasm-bindgen 0.6.5", - "thiserror 2.0.12", - "tokio", - "tsify", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasmtimer", -] - [[package]] name = "nym-noise" -version = "1.20.4" +version = "1.21.0" dependencies = [ "anyhow", "arc-swap", @@ -7900,7 +7991,7 @@ dependencies = [ "snow", "strum 0.28.0", "strum_macros 0.28.0", - "thiserror 2.0.12", + "thiserror 2.0.18", "tokio", "tokio-util", "tracing", @@ -7908,7 +7999,7 @@ dependencies = [ [[package]] name = "nym-noise-keys" -version = "1.20.4" +version = "1.21.0" dependencies = [ "nym-crypto", "schemars 0.8.22", @@ -7918,7 +8009,7 @@ dependencies = [ [[package]] name = "nym-nonexhaustive-delayqueue" -version = "1.20.4" +version = "1.21.0" dependencies = [ "tokio", "tokio-stream", @@ -7944,29 +8035,29 @@ dependencies = [ [[package]] name = "nym-ordered-buffer" -version = "1.20.4" +version = "1.21.0" dependencies = [ "log", - "thiserror 2.0.12", + "thiserror 2.0.18", ] [[package]] name = "nym-outfox" -version = "1.20.4" +version = "1.21.0" dependencies = [ "blake3", "chacha20 0.9.1", "chacha20poly1305", "fastrand", "sphinx-packet", - "thiserror 2.0.12", + "thiserror 2.0.18", "x25519-dalek", "zeroize", ] [[package]] name = "nym-pemstore" -version = "1.20.4" +version = "1.21.0" dependencies = [ "pem", "tracing", @@ -7975,7 +8066,7 @@ dependencies = [ [[package]] name = "nym-performance-contract-common" -version = "1.20.4" +version = "1.21.0" dependencies = [ "cosmwasm-schema", "cosmwasm-std", @@ -7983,25 +8074,25 @@ dependencies = [ "nym-contracts-common", "schemars 0.8.22", "serde", - "thiserror 2.0.12", + "thiserror 2.0.18", ] [[package]] name = "nym-pool-contract-common" -version = "1.20.4" +version = "1.21.0" dependencies = [ "cosmwasm-schema", "cosmwasm-std", "cw-controllers", "schemars 0.8.22", "serde", - "thiserror 2.0.12", + "thiserror 2.0.18", "time", ] [[package]] name = "nym-registration-client" -version = "1.20.4" +version = "1.21.0" dependencies = [ "bincode", "bytes", @@ -8014,13 +8105,14 @@ dependencies = [ "nym-ip-packet-client", "nym-kkt", "nym-lp", + "nym-lp-data", "nym-registration-common", "nym-sdk", "nym-test-utils", "nym-validator-client", "nym-wireguard-types", "rand 0.9.2", - "thiserror 2.0.12", + "thiserror 2.0.18", "tokio", "tokio-util", "tracing", @@ -8029,7 +8121,7 @@ dependencies = [ [[package]] name = "nym-registration-common" -version = "1.20.4" +version = "1.21.0" dependencies = [ "bincode", "nym-authenticator-requests", @@ -8046,7 +8138,7 @@ dependencies = [ [[package]] name = "nym-sdk" -version = "1.20.4" +version = "1.21.0" dependencies = [ "anyhow", "async-trait", @@ -8060,7 +8152,7 @@ dependencies = [ "dotenvy", "futures", "hex", - "http 1.3.1", + "http 1.4.0", "httpcodec", "log", "nym-bandwidth-controller", @@ -8074,7 +8166,7 @@ dependencies = [ "nym-gateway-requests", "nym-http-api-client", "nym-ip-packet-requests", - "nym-lp", + "nym-lp-data", "nym-network-defaults", "nym-ordered-buffer", "nym-service-providers-common", @@ -8088,13 +8180,13 @@ dependencies = [ "nym-validator-client", "parking_lot", "pnet_packet", - "rand 0.8.5", + "rand 0.8.6", "reqwest 0.13.1", "semver 1.0.27", "serde", "tap", "tempfile", - "thiserror 2.0.12", + "thiserror 2.0.18", "time", "tokio", "tokio-stream", @@ -8109,7 +8201,7 @@ dependencies = [ [[package]] name = "nym-serde-helpers" -version = "1.20.4" +version = "1.21.0" dependencies = [ "base64 0.22.1", "bs58", @@ -8120,16 +8212,16 @@ dependencies = [ [[package]] name = "nym-service-provider-requests-common" -version = "1.20.4" +version = "1.21.0" dependencies = [ "bincode", "serde", - "thiserror 2.0.12", + "thiserror 2.0.18", ] [[package]] name = "nym-service-providers-common" -version = "1.20.4" +version = "1.21.0" dependencies = [ "async-trait", "log", @@ -8137,7 +8229,7 @@ dependencies = [ "nym-sphinx-anonymous-replies", "serde", "serde_json", - "thiserror 2.0.12", + "thiserror 2.0.18", ] [[package]] @@ -8182,11 +8274,11 @@ dependencies = [ "nym-sphinx", "nym-topology", "nym-validator-client", - "rand 0.8.5", + "rand 0.8.6", "serde", "serde_json", "tap", - "thiserror 2.0.12", + "thiserror 2.0.18", "time", "tokio", "url", @@ -8195,7 +8287,7 @@ dependencies = [ [[package]] name = "nym-socks5-client-core" -version = "1.20.4" +version = "1.21.0" dependencies = [ "anyhow", "dirs", @@ -8215,19 +8307,19 @@ dependencies = [ "nym-task", "nym-validator-client", "pin-project", - "rand 0.8.5", + "rand 0.8.6", "reqwest 0.13.1", "schemars 0.8.22", "serde", "tap", - "thiserror 2.0.12", + "thiserror 2.0.18", "tokio", "url", ] [[package]] name = "nym-socks5-proxy-helpers" -version = "1.20.4" +version = "1.21.0" dependencies = [ "bytes", "futures", @@ -8242,7 +8334,7 @@ dependencies = [ [[package]] name = "nym-socks5-requests" -version = "1.20.4" +version = "1.21.0" dependencies = [ "bincode", "log", @@ -8252,12 +8344,12 @@ dependencies = [ "serde", "serde_json", "tap", - "thiserror 2.0.12", + "thiserror 2.0.18", ] [[package]] name = "nym-sphinx" -version = "1.20.4" +version = "1.21.0" dependencies = [ "nym-crypto", "nym-metrics", @@ -8273,17 +8365,17 @@ dependencies = [ "nym-sphinx-routing", "nym-sphinx-types", "nym-topology", - "rand 0.8.5", + "rand 0.8.6", "rand_chacha 0.3.1", "rand_distr", - "thiserror 2.0.12", + "thiserror 2.0.18", "tokio", "tracing", ] [[package]] name = "nym-sphinx-acknowledgements" -version = "1.20.4" +version = "1.21.0" dependencies = [ "generic-array 0.14.7", "nym-crypto", @@ -8293,28 +8385,28 @@ dependencies = [ "nym-sphinx-routing", "nym-sphinx-types", "nym-topology", - "rand 0.8.5", + "rand 0.8.6", "serde", - "thiserror 2.0.12", + "thiserror 2.0.18", "zeroize", ] [[package]] name = "nym-sphinx-addressing" -version = "1.20.4" +version = "1.21.0" dependencies = [ "bincode", "nym-crypto", "nym-sphinx-types", - "rand 0.8.5", + "rand 0.8.6", "serde", "serde_json", - "thiserror 2.0.12", + "thiserror 2.0.18", ] [[package]] name = "nym-sphinx-anonymous-replies" -version = "1.20.4" +version = "1.21.0" dependencies = [ "bs58", "nym-crypto", @@ -8323,16 +8415,16 @@ dependencies = [ "nym-sphinx-routing", "nym-sphinx-types", "nym-topology", - "rand 0.8.5", + "rand 0.8.6", "rand_chacha 0.3.1", - "thiserror 2.0.12", + "thiserror 2.0.18", "tracing", "wasm-bindgen", ] [[package]] name = "nym-sphinx-chunking" -version = "1.20.4" +version = "1.21.0" dependencies = [ "dashmap", "log", @@ -8341,16 +8433,16 @@ dependencies = [ "nym-sphinx-addressing", "nym-sphinx-params", "nym-sphinx-types", - "rand 0.8.5", + "rand 0.8.6", "serde", - "thiserror 2.0.12", + "thiserror 2.0.18", "utoipa", "wasmtimer", ] [[package]] name = "nym-sphinx-cover" -version = "1.20.4" +version = "1.21.0" dependencies = [ "nym-crypto", "nym-sphinx-acknowledgements", @@ -8361,24 +8453,24 @@ dependencies = [ "nym-sphinx-routing", "nym-sphinx-types", "nym-topology", - "rand 0.8.5", - "thiserror 2.0.12", + "rand 0.8.6", + "thiserror 2.0.18", ] [[package]] name = "nym-sphinx-forwarding" -version = "1.20.4" +version = "1.21.0" dependencies = [ "nym-sphinx-addressing", "nym-sphinx-anonymous-replies", "nym-sphinx-params", "nym-sphinx-types", - "thiserror 2.0.12", + "thiserror 2.0.18", ] [[package]] name = "nym-sphinx-framing" -version = "1.20.4" +version = "1.21.0" dependencies = [ "bytes", "cfg-if", @@ -8387,7 +8479,7 @@ dependencies = [ "nym-sphinx-forwarding", "nym-sphinx-params", "nym-sphinx-types", - "thiserror 2.0.12", + "thiserror 2.0.18", "tokio", "tokio-util", "tracing", @@ -8395,42 +8487,42 @@ dependencies = [ [[package]] name = "nym-sphinx-params" -version = "1.20.4" +version = "1.21.0" dependencies = [ "nym-crypto", "nym-sphinx-types", "serde", - "thiserror 2.0.12", + "thiserror 2.0.18", ] [[package]] name = "nym-sphinx-routing" -version = "1.20.4" +version = "1.21.0" dependencies = [ "nym-sphinx-addressing", "nym-sphinx-types", - "thiserror 2.0.12", + "thiserror 2.0.18", ] [[package]] name = "nym-sphinx-types" -version = "1.20.4" +version = "1.21.0" dependencies = [ "nym-outfox", "sphinx-packet", - "thiserror 2.0.12", + "thiserror 2.0.18", ] [[package]] name = "nym-sqlx-pool-guard" -version = "1.20.4" +version = "1.21.0" dependencies = [ "proc_pidinfo", "sqlx", "tempfile", "tokio", "tracing", - "tracing-subscriber", + "tracing-test", "windows 0.61.3", ] @@ -8439,7 +8531,7 @@ name = "nym-statistics-api" version = "0.3.1" dependencies = [ "anyhow", - "axum 0.7.9", + "axum", "axum-client-ip", "axum-extra", "celes", @@ -8466,7 +8558,7 @@ dependencies = [ [[package]] name = "nym-statistics-common" -version = "1.20.4" +version = "1.21.0" dependencies = [ "futures", "log", @@ -8482,7 +8574,7 @@ dependencies = [ "strum 0.28.0", "strum_macros 0.28.0", "sysinfo", - "thiserror 2.0.12", + "thiserror 2.0.18", "time", "tokio", "utoipa", @@ -8491,29 +8583,29 @@ dependencies = [ [[package]] name = "nym-store-cipher" -version = "1.20.4" +version = "1.21.0" dependencies = [ "aes-gcm", "argon2", "generic-array 0.14.7", "getrandom 0.2.16", - "rand 0.8.5", + "rand 0.8.6", "serde", "serde_json", - "thiserror 2.0.12", + "thiserror 2.0.18", "zeroize", ] [[package]] name = "nym-task" -version = "1.20.4" +version = "1.21.0" dependencies = [ "anyhow", "cfg-if", "futures", "log", "nym-test-utils", - "thiserror 2.0.12", + "thiserror 2.0.18", "tokio", "tokio-util", "tracing", @@ -8524,7 +8616,7 @@ dependencies = [ [[package]] name = "nym-test-utils" -version = "1.20.4" +version = "1.21.0" dependencies = [ "anyhow", "futures", @@ -8537,11 +8629,11 @@ dependencies = [ [[package]] name = "nym-ticketbooks-merkle" -version = "1.20.4" +version = "1.21.0" dependencies = [ "nym-credentials-interface", "nym-serde-helpers", - "rand 0.8.5", + "rand 0.8.6", "rand_chacha 0.3.1", "rs_merkle", "schemars 0.8.22", @@ -8554,7 +8646,7 @@ dependencies = [ [[package]] name = "nym-topology" -version = "1.20.4" +version = "1.21.0" dependencies = [ "async-trait", "nym-api-requests", @@ -8563,11 +8655,11 @@ dependencies = [ "nym-sphinx-addressing", "nym-sphinx-types", "nym-wasm-utils", - "rand 0.8.5", + "rand 0.8.6", "reqwest 0.13.1", "serde", "serde_json", - "thiserror 2.0.12", + "thiserror 2.0.18", "time", "tracing", "tsify", @@ -8576,19 +8668,19 @@ dependencies = [ [[package]] name = "nym-tun" -version = "1.20.4" +version = "1.21.0" dependencies = [ "etherparse", "log", "nym-wireguard-types", - "thiserror 2.0.12", + "thiserror 2.0.18", "tokio", "tokio-tun", ] [[package]] name = "nym-types" -version = "1.20.4" +version = "1.21.0" dependencies = [ "base64 0.22.1", "cosmrs", @@ -8610,7 +8702,7 @@ dependencies = [ "strum 0.28.0", "strum_macros 0.28.0", "tempfile", - "thiserror 2.0.12", + "thiserror 2.0.18", "ts-rs", "url", "utoipa", @@ -8619,7 +8711,7 @@ dependencies = [ [[package]] name = "nym-upgrade-mode-check" -version = "1.20.4" +version = "1.21.0" dependencies = [ "anyhow", "jwt-simple", @@ -8629,7 +8721,7 @@ dependencies = [ "reqwest 0.13.1", "serde", "serde_json", - "thiserror 2.0.12", + "thiserror 2.0.18", "time", "tracing", "utoipa", @@ -8637,7 +8729,7 @@ dependencies = [ [[package]] name = "nym-validator-client" -version = "1.20.4" +version = "1.21.0" dependencies = [ "anyhow", "async-trait", @@ -8668,6 +8760,7 @@ dependencies = [ "nym-multisig-contract-common", "nym-network-defaults", "nym-network-monitors-contract-common", + "nym-node-families-contract-common", "nym-performance-contract-common", "nym-serde-helpers", "nym-vesting-contract-common", @@ -8677,7 +8770,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "tendermint-rpc", - "thiserror 2.0.12", + "thiserror 2.0.18", "time", "tokio", "tracing", @@ -8715,14 +8808,14 @@ dependencies = [ "nym-ticketbooks-merkle", "nym-validator-client", "nyxd-scraper-sqlite", - "rand 0.8.5", + "rand 0.8.6", "rand_chacha 0.3.1", "serde", "serde_json", "serde_with", "sha2 0.10.9", "sqlx", - "thiserror 2.0.12", + "thiserror 2.0.18", "time", "tokio", "tracing", @@ -8732,7 +8825,7 @@ dependencies = [ [[package]] name = "nym-verloc" -version = "1.20.4" +version = "1.21.0" dependencies = [ "bytes", "futures", @@ -8742,8 +8835,8 @@ dependencies = [ "nym-http-api-client", "nym-task", "nym-validator-client", - "rand 0.8.5", - "thiserror 2.0.12", + "rand 0.8.6", + "thiserror 2.0.18", "time", "tokio", "tokio-util", @@ -8753,7 +8846,7 @@ dependencies = [ [[package]] name = "nym-vesting-contract-common" -version = "1.20.4" +version = "1.21.0" dependencies = [ "cosmwasm-schema", "cosmwasm-std", @@ -8761,7 +8854,7 @@ dependencies = [ "nym-contracts-common", "nym-mixnet-contract-common", "serde", - "thiserror 2.0.12", + "thiserror 2.0.18", "ts-rs", ] @@ -8787,7 +8880,7 @@ dependencies = [ [[package]] name = "nym-wasm-client-core" -version = "1.20.4" +version = "1.21.0" dependencies = [ "async-trait", "console_error_panic_hook", @@ -8807,10 +8900,10 @@ dependencies = [ "nym-validator-client", "nym-wasm-storage", "nym-wasm-utils", - "rand 0.8.5", + "rand 0.8.6", "serde", "serde-wasm-bindgen 0.6.5", - "thiserror 2.0.12", + "thiserror 2.0.18", "time", "tsify", "url", @@ -8821,7 +8914,7 @@ dependencies = [ [[package]] name = "nym-wasm-storage" -version = "1.20.4" +version = "1.21.0" dependencies = [ "async-trait", "indexed_db_futures", @@ -8829,13 +8922,13 @@ dependencies = [ "nym-wasm-utils", "serde", "serde-wasm-bindgen 0.6.5", - "thiserror 2.0.12", + "thiserror 2.0.18", "wasm-bindgen", ] [[package]] name = "nym-wasm-utils" -version = "1.20.4" +version = "1.21.0" dependencies = [ "console_error_panic_hook", "futures", @@ -8851,7 +8944,7 @@ dependencies = [ [[package]] name = "nym-wireguard" -version = "1.20.4" +version = "1.21.0" dependencies = [ "anyhow", "base64 0.22.1", @@ -8872,8 +8965,8 @@ dependencies = [ "nym-task", "nym-test-utils", "nym-wireguard-types", - "rand 0.8.5", - "thiserror 2.0.12", + "rand 0.8.6", + "thiserror 2.0.18", "tokio", "tokio-stream", "tracing", @@ -8881,7 +8974,7 @@ dependencies = [ [[package]] name = "nym-wireguard-private-metadata-client" -version = "1.20.4" +version = "1.21.0" dependencies = [ "async-trait", "nym-http-api-client", @@ -8891,11 +8984,11 @@ dependencies = [ [[package]] name = "nym-wireguard-private-metadata-server" -version = "1.20.4" +version = "1.21.0" dependencies = [ "anyhow", "async-trait", - "axum 0.7.9", + "axum", "futures", "nym-credential-verification", "nym-credentials-interface", @@ -8911,23 +9004,23 @@ dependencies = [ [[package]] name = "nym-wireguard-private-metadata-shared" -version = "1.20.4" +version = "1.21.0" dependencies = [ - "axum 0.7.9", + "axum", "bincode", "nym-credentials-interface", "schemars 0.8.22", "serde", - "thiserror 2.0.12", + "thiserror 2.0.18", "utoipa", ] [[package]] name = "nym-wireguard-private-metadata-tests" -version = "1.20.4" +version = "1.21.0" dependencies = [ "async-trait", - "axum 0.7.9", + "axum", "futures", "nym-credential-verification", "nym-credentials-interface", @@ -8941,20 +9034,20 @@ dependencies = [ "nym-wireguard-private-metadata-shared", "time", "tokio", - "tower 0.5.2", + "tower", "tower-http", "utoipa", ] [[package]] name = "nym-wireguard-types" -version = "1.20.4" +version = "1.21.0" dependencies = [ "base64 0.22.1", "nym-crypto", - "rand 0.8.5", + "rand 0.8.6", "serde", - "thiserror 2.0.12", + "thiserror 2.0.18", "x25519-dalek", "zeroize", ] @@ -8982,7 +9075,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "tar", - "thiserror 2.0.12", + "thiserror 2.0.18", "time", "tokio", "tracing", @@ -8995,7 +9088,7 @@ version = "0.1.15" dependencies = [ "anyhow", "async-trait", - "axum 0.7.9", + "axum", "chrono", "clap", "nym-bin-common", @@ -9008,7 +9101,7 @@ dependencies = [ "schemars 0.8.22", "serde", "sqlx", - "thiserror 2.0.12", + "thiserror 2.0.18", "time", "tokio", "tokio-util", @@ -9032,14 +9125,14 @@ dependencies = [ "serde", "serde_json", "sqlx", - "thiserror 2.0.12", + "thiserror 2.0.18", "tokio", "tracing", ] [[package]] name = "nyxd-scraper-shared" -version = "1.20.4" +version = "1.21.0" dependencies = [ "async-trait", "base64 0.22.1", @@ -9056,7 +9149,7 @@ dependencies = [ "sha2 0.10.9", "tendermint", "tendermint-rpc", - "thiserror 2.0.12", + "thiserror 2.0.18", "time", "tokio", "tokio-stream", @@ -9073,7 +9166,7 @@ dependencies = [ "async-trait", "nyxd-scraper-shared", "sqlx", - "thiserror 2.0.12", + "thiserror 2.0.18", "tokio", "tracing", ] @@ -9097,15 +9190,6 @@ dependencies = [ "objc2-core-foundation", ] -[[package]] -name = "object" -version = "0.36.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" -dependencies = [ - "memchr", -] - [[package]] name = "once_cell" version = "1.21.3" @@ -9162,7 +9246,7 @@ dependencies = [ "futures-sink", "js-sys", "pin-project-lite", - "thiserror 2.0.12", + "thiserror 2.0.18", "tracing", ] @@ -9174,7 +9258,7 @@ checksum = "d7a6d09a73194e6b66df7c8f1b680f156d916a1a942abf2de06823dd02b7855d" dependencies = [ "async-trait", "bytes", - "http 1.3.1", + "http 1.4.0", "opentelemetry", "reqwest 0.12.22", ] @@ -9185,14 +9269,14 @@ version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2366db2dca4d2ad033cad11e6ee42844fd727007af5ad04a1730f4cb8163bf" dependencies = [ - "http 1.3.1", + "http 1.4.0", "opentelemetry", "opentelemetry-http", "opentelemetry-proto", "opentelemetry_sdk", "prost 0.14.3", "reqwest 0.12.22", - "thiserror 2.0.12", + "thiserror 2.0.18", "tokio", "tonic 0.14.4", "tracing", @@ -9223,7 +9307,7 @@ dependencies = [ "opentelemetry", "percent-encoding", "rand 0.9.2", - "thiserror 2.0.12", + "thiserror 2.0.18", ] [[package]] @@ -9398,7 +9482,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1db05f56d34358a8b1066f67cbb203ee3e7ed2ba674a6263a1d5ec6db2204323" dependencies = [ "memchr", - "thiserror 2.0.12", + "thiserror 2.0.18", "ucd-trie", ] @@ -9422,7 +9506,7 @@ dependencies = [ "pest_meta", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -9472,7 +9556,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared", - "rand 0.8.5", + "rand 0.8.6", ] [[package]] @@ -9485,7 +9569,7 @@ dependencies = [ "phf_shared", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -9514,7 +9598,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -9535,9 +9619,9 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" dependencies = [ - "der", - "pkcs8", - "spki", + "der 0.7.10", + "pkcs8 0.10.2", + "spki 0.7.3", ] [[package]] @@ -9546,8 +9630,18 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" dependencies = [ - "der", - "spki", + "der 0.7.10", + "spki 0.7.3", +] + +[[package]] +name = "pkcs8" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" +dependencies = [ + "der 0.8.0", + "spki 0.8.0", ] [[package]] @@ -9608,7 +9702,7 @@ dependencies = [ "proc-macro2", "quote", "regex", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -9757,7 +9851,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -9797,14 +9891,14 @@ dependencies = [ "proc-macro-error-attr2", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] name = "proc-macro2" -version = "1.0.95" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] @@ -9830,7 +9924,7 @@ dependencies = [ "memchr", "parking_lot", "protobuf", - "thiserror 2.0.12", + "thiserror 2.0.18", ] [[package]] @@ -9863,7 +9957,7 @@ dependencies = [ "itertools 0.14.0", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -9876,16 +9970,16 @@ dependencies = [ "itertools 0.14.0", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] name = "prost-types" -version = "0.13.5" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16" +checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7" dependencies = [ - "prost 0.13.5", + "prost 0.14.3", ] [[package]] @@ -9953,7 +10047,7 @@ dependencies = [ "rustc-hash", "rustls 0.23.37", "socket2 0.5.10", - "thiserror 2.0.12", + "thiserror 2.0.18", "tokio", "tracing", "web-time", @@ -9961,9 +10055,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.12" +version = "0.11.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49df843a9161c85bb8aae55f101bc0bac8bcafd637a620d9122fd7e0b2f7422e" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" dependencies = [ "aws-lc-rs", "bytes", @@ -9975,7 +10069,7 @@ dependencies = [ "rustls 0.23.37", "rustls-pki-types", "slab", - "thiserror 2.0.12", + "thiserror 2.0.18", "tinyvec", "tracing", "web-time", @@ -9997,9 +10091,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.40" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] @@ -10018,9 +10112,9 @@ checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" [[package]] name = "rand" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -10099,7 +10193,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31" dependencies = [ "num-traits", - "rand 0.8.5", + "rand 0.8.6", ] [[package]] @@ -10139,7 +10233,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.16", "libredox", - "thiserror 2.0.12", + "thiserror 2.0.18", ] [[package]] @@ -10159,7 +10253,7 @@ checksum = "1165225c21bff1f3bbce98f5a1f889949bc902d3575308cc7b0de30b4f6d27c7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -10245,10 +10339,10 @@ dependencies = [ "futures-core", "futures-util", "h2 0.4.11", - "http 1.3.1", + "http 1.4.0", "http-body 1.0.1", "http-body-util", - "hyper 1.6.0", + "hyper 1.9.0", "hyper-rustls 0.27.7", "hyper-util", "js-sys", @@ -10266,7 +10360,7 @@ dependencies = [ "tokio", "tokio-rustls 0.26.2", "tokio-util", - "tower 0.5.2", + "tower", "tower-http", "tower-service", "url", @@ -10286,10 +10380,10 @@ dependencies = [ "bytes", "futures-core", "futures-util", - "http 1.3.1", + "http 1.4.0", "http-body 1.0.1", "http-body-util", - "hyper 1.6.0", + "hyper 1.9.0", "hyper-rustls 0.27.7", "hyper-util", "js-sys", @@ -10307,7 +10401,7 @@ dependencies = [ "tokio", "tokio-rustls 0.26.2", "tokio-util", - "tower 0.5.2", + "tower", "tower-http", "tower-service", "url", @@ -10319,11 +10413,11 @@ dependencies = [ [[package]] name = "reserve-port" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21918d6644020c6f6ef1993242989bf6d4952d2e025617744f184c02df51c356" +checksum = "94070964579245eb2f76e62a7668fe87bd9969ed6c41256f3bf614e3323dd3cc" dependencies = [ - "thiserror 2.0.12", + "thiserror 2.0.18", ] [[package]] @@ -10398,21 +10492,21 @@ dependencies = [ [[package]] name = "rsa" -version = "0.9.8" +version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78928ac1ed176a5ca1d17e578a1825f3d81ca54cf41053a592584b020cfd691b" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" dependencies = [ - "const-oid", + "const-oid 0.9.6", "digest 0.10.7", "num-bigint-dig", "num-integer", "num-traits", "pkcs1", - "pkcs8", + "pkcs8 0.10.2", "rand_core 0.6.4", "sha2 0.10.9", - "signature", - "spki", + "signature 2.2.0", + "spki 0.7.3", "subtle 2.6.1", "zeroize", ] @@ -10437,7 +10531,7 @@ dependencies = [ "proc-macro2", "quote", "rust-embed-utils", - "syn 2.0.106", + "syn 2.0.117", "walkdir", ] @@ -10453,26 +10547,19 @@ dependencies = [ [[package]] name = "rust-multipart-rfc7578_2" -version = "0.6.1" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03b748410c0afdef2ebbe3685a6a862e2ee937127cdaae623336a459451c8d57" +checksum = "00bdaa068902270ca7fa8619775e1838e23a63620abac0947ce0f715819b8cec" dependencies = [ "bytes", "futures-core", "futures-util", - "http 0.2.12", + "http 1.4.0", "mime", - "mime_guess", - "rand 0.8.5", - "thiserror 1.0.69", + "rand 0.10.1", + "thiserror 2.0.18", ] -[[package]] -name = "rustc-demangle" -version = "0.1.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "989e6739f80c4ad5b13e0fd7fe89531180375b18520cc8c82080e4dc4035b84f" - [[package]] name = "rustc-hash" version = "2.1.1" @@ -10773,7 +10860,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals 0.29.1", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -10805,7 +10892,7 @@ checksum = "1783eabc414609e28a5ba76aee5ddd52199f7107a0b24c2e9746a1ecc34a683d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -10826,7 +10913,7 @@ checksum = "22f968c5ea23d555e670b449c1c5e7b2fc399fdaec1d304a17cd48e288abc107" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -10836,9 +10923,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" dependencies = [ "base16ct", - "der", + "der 0.7.10", "generic-array 0.14.7", - "pkcs8", + "pkcs8 0.10.2", "serdect 0.2.0", "subtle 2.6.1", "zeroize", @@ -11012,7 +11099,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -11023,7 +11110,7 @@ checksum = "e578a843d40b4189a4d66bba51d7684f57da5bd7c304c64e14bd63efbef49509" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -11034,19 +11121,20 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] name = "serde_json" -version = "1.0.141" +version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30b9eff21ebe718216c6ec64e1d9ac57087aad11efc64e32002bce4a0d4c03d3" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ "itoa", "memchr", - "ryu", "serde", + "serde_core", + "zmij", ] [[package]] @@ -11062,7 +11150,7 @@ dependencies = [ "serde_json", "serde_json_path_core", "serde_json_path_macros", - "thiserror 2.0.12", + "thiserror 2.0.18", ] [[package]] @@ -11074,7 +11162,7 @@ dependencies = [ "inventory", "serde", "serde_json", - "thiserror 2.0.12", + "thiserror 2.0.18", ] [[package]] @@ -11096,7 +11184,7 @@ checksum = "aafbefbe175fa9bf03ca83ef89beecff7d2a95aaacd5732325b90ac8c3bd7b90" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -11126,7 +11214,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -11188,7 +11276,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -11266,7 +11354,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60" dependencies = [ "digest 0.10.7", - "keccak", + "keccak 0.1.6", +] + +[[package]] +name = "sha3" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be176f1a57ce4e3d31c1a166222d9768de5954f811601fb7ca06fc8203905ce1" +dependencies = [ + "digest 0.11.3", + "keccak 0.2.0", ] [[package]] @@ -11330,6 +11428,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" +dependencies = [ + "digest 0.11.3", + "rand_core 0.10.1", +] + [[package]] name = "simd-adler32" version = "0.3.7" @@ -11397,13 +11505,13 @@ dependencies = [ [[package]] name = "smolmix" -version = "0.0.1" +version = "1.21.0" dependencies = [ "futures", "hickory-proto", "hickory-resolver", "http-body-util", - "hyper 1.6.0", + "hyper 1.9.0", "hyper-util", "nym-bin-common", "nym-ip-packet-requests", @@ -11411,7 +11519,7 @@ dependencies = [ "reqwest 0.13.1", "rustls 0.23.37", "smoltcp", - "thiserror 2.0.12", + "thiserror 2.0.18", "tokio", "tokio-rustls 0.26.2", "tokio-smoltcp", @@ -11500,7 +11608,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c26f0c20d909fdda1c5d0ece3973127ca421984d55b000215df365e93722fc6e" dependencies = [ - "aes", + "aes 0.8.4", "arrayref", "blake2 0.8.1", "bs58", @@ -11512,7 +11620,7 @@ dependencies = [ "hkdf", "hmac", "lioness", - "rand 0.8.5", + "rand 0.8.6", "rand_distr", "sha2 0.10.9", "subtle 2.6.1", @@ -11536,7 +11644,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" dependencies = [ "base64ct", - "der", + "der 0.7.10", +] + +[[package]] +name = "spki" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" +dependencies = [ + "base64ct", + "der 0.8.0", ] [[package]] @@ -11581,7 +11699,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "smallvec", - "thiserror 2.0.12", + "thiserror 2.0.18", "time", "tokio", "tokio-stream", @@ -11600,7 +11718,7 @@ dependencies = [ "quote", "sqlx-core", "sqlx-macros-core", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -11623,7 +11741,7 @@ dependencies = [ "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", - "syn 2.0.106", + "syn 2.0.117", "tokio", "url", ] @@ -11658,7 +11776,7 @@ dependencies = [ "memchr", "once_cell", "percent-encoding", - "rand 0.8.5", + "rand 0.8.6", "rsa", "serde", "sha1", @@ -11666,7 +11784,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror 2.0.12", + "thiserror 2.0.18", "time", "tracing", "whoami", @@ -11698,14 +11816,14 @@ dependencies = [ "md-5", "memchr", "once_cell", - "rand 0.8.5", + "rand 0.8.6", "serde", "serde_json", "sha2 0.10.9", "smallvec", "sqlx-core", "stringprep", - "thiserror 2.0.12", + "thiserror 2.0.18", "time", "tracing", "whoami", @@ -11731,7 +11849,7 @@ dependencies = [ "serde", "serde_urlencoded", "sqlx-core", - "thiserror 2.0.12", + "thiserror 2.0.18", "time", "tracing", "url", @@ -11828,7 +11946,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -11840,7 +11958,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -11872,14 +11990,17 @@ checksum = "734676eb262c623cec13c3155096e08d1f8f29adce39ba17948b18dad1e54142" [[package]] name = "superboring" -version = "0.1.4" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "515cce34a781d7250b8a65706e0f2a5b99236ea605cb235d4baed6685820478f" +checksum = "efc6310a69b44420f3bf53d518077615b7d466cc57df7a80e404e7feb8c510f7" dependencies = [ + "aes-gcm", + "aes-keywrap", "getrandom 0.2.16", "hmac-sha256", "hmac-sha512", - "rand 0.8.5", + "ml-dsa", + "rand 0.8.6", "rsa", ] @@ -11896,9 +12017,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.106" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -11928,7 +12049,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -12003,7 +12124,7 @@ dependencies = [ "camino", "crossbeam-channel", "home", - "http 1.3.1", + "http 1.4.0", "libc", "memchr", "rayon", @@ -12014,7 +12135,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "smol_str", - "thiserror 2.0.12", + "thiserror 2.0.18", "tokio", "toml-span", "twox-hash", @@ -12028,9 +12149,9 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" [[package]] name = "tar" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a" +checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" dependencies = [ "filetime", "libc", @@ -12072,7 +12193,7 @@ dependencies = [ "serde_json", "serde_repr", "sha2 0.10.9", - "signature", + "signature 2.2.0", "subtle 2.6.1", "subtle-encoding", "tendermint-proto", @@ -12123,7 +12244,7 @@ dependencies = [ "getrandom 0.2.16", "peg", "pin-project", - "rand 0.8.5", + "rand 0.8.6", "reqwest 0.11.27", "semver 1.0.27", "serde", @@ -12183,7 +12304,7 @@ dependencies = [ "proc-macro2", "quote", "regex", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -12206,11 +12327,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.12" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.12", + "thiserror-impl 2.0.18", ] [[package]] @@ -12221,18 +12342,18 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] name = "thiserror-impl" -version = "2.0.12" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -12331,39 +12452,36 @@ checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] name = "tokio" -version = "1.47.1" +version = "1.50.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" dependencies = [ - "backtrace", "bytes", - "io-uring", "libc", "mio 1.0.4", "parking_lot", "pin-project-lite", "signal-hook-registry", - "slab", "socket2 0.6.0", "tokio-macros", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" -version = "2.5.0" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -12526,7 +12644,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37e04c1865c281139e5ccf633cb9f76ffdaabeebfe53b703984cf82878e2aabb" dependencies = [ "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -12640,36 +12758,6 @@ version = "1.0.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" -[[package]] -name = "tonic" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877c5b330756d856ffcc4553ab34a5684481ade925ecc54bcd1bf02b1d0d4d52" -dependencies = [ - "async-stream", - "async-trait", - "axum 0.7.9", - "base64 0.22.1", - "bytes", - "h2 0.4.11", - "http 1.3.1", - "http-body 1.0.1", - "http-body-util", - "hyper 1.6.0", - "hyper-timeout", - "hyper-util", - "percent-encoding", - "pin-project", - "prost 0.13.5", - "socket2 0.5.10", - "tokio", - "tokio-stream", - "tower 0.4.13", - "tower-layer", - "tower-service", - "tracing", -] - [[package]] name = "tonic" version = "0.13.1" @@ -12680,10 +12768,10 @@ dependencies = [ "base64 0.22.1", "bytes", "h2 0.4.11", - "http 1.3.1", + "http 1.4.0", "http-body 1.0.1", "http-body-util", - "hyper 1.6.0", + "hyper 1.9.0", "hyper-timeout", "hyper-util", "percent-encoding", @@ -12692,7 +12780,7 @@ dependencies = [ "socket2 0.5.10", "tokio", "tokio-stream", - "tower 0.5.2", + "tower", "tower-layer", "tower-service", "tracing", @@ -12705,14 +12793,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f32a6f80051a4111560201420c7885d0082ba9efe2ab61875c587bb6b18b9a0" dependencies = [ "async-trait", - "axum 0.8.8", + "axum", "base64 0.22.1", "bytes", "h2 0.4.11", - "http 1.3.1", + "http 1.4.0", "http-body 1.0.1", "http-body-util", - "hyper 1.6.0", + "hyper 1.9.0", "hyper-timeout", "hyper-util", "percent-encoding", @@ -12723,7 +12811,7 @@ dependencies = [ "tokio", "tokio-rustls 0.26.2", "tokio-stream", - "tower 0.5.2", + "tower", "tower-layer", "tower-service", "tracing", @@ -12742,29 +12830,9 @@ dependencies = [ [[package]] name = "tower" -version = "0.4.13" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" -dependencies = [ - "futures-core", - "futures-util", - "indexmap 1.9.3", - "pin-project", - "pin-project-lite", - "rand 0.8.5", - "slab", - "tokio", - "tokio-util", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "tower" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", @@ -12790,7 +12858,7 @@ dependencies = [ "bytes", "futures-core", "futures-util", - "http 1.3.1", + "http 1.4.0", "http-body 1.0.1", "http-body-util", "http-range-header", @@ -12802,7 +12870,7 @@ dependencies = [ "pin-project-lite", "tokio", "tokio-util", - "tower 0.5.2", + "tower", "tower-layer", "tower-service", "tracing", @@ -12840,7 +12908,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -12928,7 +12996,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04659ddb06c87d233c566112c1c9c5b9e98256d9af50ec3bc9c8327f873a7568" dependencies = [ "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -12972,7 +13040,7 @@ version = "12.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "756050066659291d47a554a9f558125db17428b073c5ffce1daf5dcb0f7231d8" dependencies = [ - "thiserror 2.0.12", + "thiserror 2.0.18", "ts-rs-macros", ] @@ -12999,7 +13067,7 @@ checksum = "38d90eea51bc7988ef9e674bf80a85ba6804739e535e9cab48e4bb34a8b652aa" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", "termcolor", ] @@ -13026,7 +13094,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals 0.28.0", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -13041,7 +13109,7 @@ dependencies = [ "http 0.2.12", "httparse", "log", - "rand 0.8.5", + "rand 0.8.6", "rustls 0.21.12", "sha1", "thiserror 1.0.69", @@ -13059,10 +13127,10 @@ dependencies = [ "byteorder", "bytes", "data-encoding", - "http 1.3.1", + "http 1.4.0", "httparse", "log", - "rand 0.8.5", + "rand 0.8.6", "rustls 0.22.4", "rustls-pki-types", "sha1", @@ -13094,7 +13162,7 @@ checksum = "016c26257f448222014296978b2c8456e2cad4de308c35bdb1e383acd569ef5b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -13105,9 +13173,33 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" [[package]] name = "typenum" -version = "1.18.0" +version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "typetag" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be2212c8a9b9bcfca32024de14998494cf9a5dfa59ea1b829de98bac374b86bf" +dependencies = [ + "erased-serde", + "inventory", + "once_cell", + "serde", + "typetag-impl", +] + +[[package]] +name = "typetag-impl" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27a7a9b72ba121f6f1f6c3632b85604cac41aedb5ddc70accbebb6cac83de846" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] [[package]] name = "ucd-trie" @@ -13314,7 +13406,7 @@ dependencies = [ "indexmap 2.13.0", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -13327,7 +13419,7 @@ dependencies = [ "indexmap 2.13.0", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -13342,7 +13434,7 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.106", + "syn 2.0.117", "toml 0.5.11", "uniffi_meta 0.29.3", ] @@ -13359,7 +13451,7 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.106", + "syn 2.0.117", "toml 0.9.12+spec-1.1.0", "uniffi_meta 0.31.0", ] @@ -13450,7 +13542,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" dependencies = [ - "crypto-common", + "crypto-common 0.1.6", "subtle 2.6.1", ] @@ -13518,17 +13610,17 @@ dependencies = [ "proc-macro2", "quote", "regex", - "syn 2.0.106", + "syn 2.0.117", "uuid", ] [[package]] name = "utoipa-swagger-ui" -version = "8.1.0" +version = "9.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db4b5ac679cc6dfc5ea3f2823b0291c777750ffd5e13b21137e0f7ac0e8f9617" +checksum = "d047458f1b5b65237c2f6dc6db136945667f40a7668627b3490b9513a3d43a55" dependencies = [ - "axum 0.7.9", + "axum", "base64 0.22.1", "mime_guess", "regex", @@ -13557,7 +13649,7 @@ checksum = "268d76aaebb80eba79240b805972e52d7d410d4bcc52321b951318b0f440cd60" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -13568,7 +13660,7 @@ checksum = "382673bda1d05c85b4550d32fd4192ccd4cffe9a908543a0795d1e7682b36246" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", "utoipauto-core", ] @@ -13770,9 +13862,9 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" [[package]] name = "wasix" -version = "0.12.21" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1fbb4ef9bbca0c1170e0b00dd28abc9e3b68669821600cad1caaed606583c6d" +checksum = "1757e0d1f8456693c7e5c6c629bdb54884e032aa0bb53c155f6a39f94440d332" dependencies = [ "wasi 0.11.1+wasi-snapshot-preview1", ] @@ -13799,7 +13891,7 @@ dependencies = [ "log", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -13834,7 +13926,7 @@ checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -13869,7 +13961,7 @@ checksum = "17d5042cc5fa009658f9a7333ef24291b1291a25b6382dd68862a7f3b969f69b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -14169,7 +14261,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -14180,7 +14272,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -14639,7 +14731,7 @@ dependencies = [ "heck 0.5.0", "indexmap 2.13.0", "prettyplease", - "syn 2.0.106", + "syn 2.0.117", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -14655,7 +14747,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -14760,7 +14852,7 @@ checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", "synstructure", ] @@ -14781,7 +14873,7 @@ checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -14801,15 +14893,15 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", "synstructure", ] [[package]] name = "zeroize" -version = "1.8.1" +version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" dependencies = [ "zeroize_derive", ] @@ -14822,7 +14914,7 @@ checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -14855,29 +14947,26 @@ checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] name = "zip" -version = "2.4.2" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +checksum = "12598812502ed0105f607f941c386f43d441e00148fce9dec3ca5ffb0bde9308" dependencies = [ "arbitrary", "crc32fast", - "crossbeam-utils", - "displaydoc", "flate2", "indexmap 2.13.0", "memchr", - "thiserror 2.0.12", "zopfli", ] [[package]] name = "zknym-lib" -version = "1.20.4" +version = "1.21.0" dependencies = [ "async-trait", "js-sys", @@ -14886,7 +14975,7 @@ dependencies = [ "nym-http-api-client", "nym-wasm-utils", "serde", - "thiserror 2.0.12", + "thiserror 2.0.18", "tsify", "uuid", "wasm-bindgen", @@ -14894,6 +14983,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "zlib-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40990edd51aae2c2b6907af74ffb635029d5788228222c4bb811e9351c0caad3" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + [[package]] name = "zopfli" version = "0.8.2" @@ -14936,7 +15037,7 @@ dependencies = [ [[package]] name = "zulip-client" -version = "1.20.4" +version = "1.21.0" dependencies = [ "itertools 0.14.0", "nym-bin-common", @@ -14944,7 +15045,7 @@ dependencies = [ "reqwest 0.13.1", "serde", "serde_json", - "thiserror 2.0.12", + "thiserror 2.0.18", "tokio", "tracing", "url", diff --git a/Cargo.toml b/Cargo.toml index 7025c29062..e17ffb790a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,7 +31,6 @@ members = [ "common/client-libs/mixnet-client", "common/client-libs/validator-client", "common/commands", - "common/nym-common", "common/config", "common/cosmwasm-smart-contracts/coconut-dkg", "common/cosmwasm-smart-contracts/contracts-common", @@ -41,6 +40,7 @@ members = [ "common/cosmwasm-smart-contracts/group-contract", "common/cosmwasm-smart-contracts/mixnet-contract", "common/cosmwasm-smart-contracts/multisig-contract", + "common/cosmwasm-smart-contracts/node-families-contract", "common/cosmwasm-smart-contracts/nym-performance-contract", "common/cosmwasm-smart-contracts/nym-pool-contract", "common/cosmwasm-smart-contracts/vesting-contract", @@ -71,11 +71,15 @@ members = [ "common/node-tester-utils", "common/nonexhaustive-delayqueue", "common/nym-cache", + "common/nym-common", "common/nym-connection-monitor", "common/nym-id", "common/nym-kcp", - "common/nym-lp", "common/nym-kkt", + "common/nym-kkt-ciphersuite", + "common/nym-kkt-context", + "common/nym-lp", + "common/nym-lp-data", "common/nym-metrics", "common/nym_offline_compact_ecash", "common/nymnoise", @@ -91,9 +95,9 @@ members = [ "common/nymsphinx/params", "common/nymsphinx/routing", "common/nymsphinx/types", - "common/nyxd-scraper-sqlite", "common/nyxd-scraper-psql", "common/nyxd-scraper-shared", + "common/nyxd-scraper-sqlite", "common/pemstore", "common/registration", "common/serde-helpers", @@ -123,13 +127,14 @@ members = [ "common/zulip-client", "documentation/autodoc", "gateway", + "integration-tests", "nym-api", "nym-api/nym-api-requests", "nym-authenticator-client", - "nym-browser-extension/storage", "nym-credential-proxy/nym-credential-proxy", "nym-credential-proxy/nym-credential-proxy-requests", "nym-data-observatory", + "nym-gateway-probe", "nym-ip-packet-client", "nym-network-monitor", "nym-node", @@ -141,6 +146,7 @@ members = [ "nym-outfox", "nym-registration-client", "nym-signers-monitor", + "nym-sqlx-pool-guard", "nym-statistics-api", "nym-validator-rewarder", "nyx-chain-watcher", @@ -148,19 +154,18 @@ members = [ "sdk/ffi/go", "sdk/ffi/shared", "sdk/rust/nym-sdk", - "smolmix/core", "service-providers/common", "service-providers/ip-packet-router", "service-providers/network-requester", - "nym-sqlx-pool-guard", + "smolmix/core", "tools/echo-server", "tools/internal/contract-state-importer/importer-cli", "tools/internal/contract-state-importer/importer-contract", + "tools/internal/localnet-orchestrator", + "tools/internal/localnet-orchestrator/dkg-bypass-contract", "tools/internal/mixnet-connectivity-check", # "tools/internal/sdk-version-bump", "tools/internal/ssl-inject", - "tools/internal/localnet-orchestrator", - "tools/internal/localnet-orchestrator/dkg-bypass-contract", "tools/internal/validator-status-check", "tools/nym-cli", "tools/nym-id-cli", @@ -169,35 +174,30 @@ members = [ "tools/nymvisor", "tools/ts-rs-cli", "wasm/client", - # "wasm/full-nym-wasm", # If we uncomment this again, remember to also uncomment the profile settings below "wasm/mix-fetch", - "wasm/node-tester", "wasm/zknym-lib", - "nym-gateway-probe", - "integration-tests", - "common/nym-kkt-ciphersuite", - "common/nym-kkt-context", "nym-network-monitor-v3/nym-network-monitor-orchestrator", - "nym-network-monitor-v3/nym-network-monitor-agent", "nym-network-monitor-v3/nym-network-monitor-orchestrator-requests", + "nym-network-monitor-v3/nym-network-monitor-agent", + "nym-network-monitor-v3/nym-network-monitor-orchestrator-requests", ] default-members = [ "clients/native", "clients/socks5", - "nym-authenticator-client", "nym-api", + "nym-authenticator-client", "nym-credential-proxy/nym-credential-proxy", "nym-node", + "nym-registration-client", "nym-statistics-api", "nym-validator-rewarder", "nyx-chain-watcher", "service-providers/ip-packet-router", "service-providers/network-requester", + "tools/internal/localnet-orchestrator", "tools/nymvisor", - "nym-registration-client", "nym-network-monitor-v3/nym-network-monitor-orchestrator", "nym-network-monitor-v3/nym-network-monitor-agent", - "tools/internal/localnet-orchestrator" ] exclude = ["contracts", "nym-wallet", "cpu-cycles"] @@ -211,7 +211,7 @@ edition = "2024" license = "Apache-2.0" rust-version = "1.87.0" readme = "README.md" -version = "1.20.4" +version = "1.21.0" [workspace.dependencies] addr = "0.15.6" @@ -225,16 +225,16 @@ anyhow = "1.0.98" arc-swap = "1.7.1" argon2 = "0.5.0" async-trait = "0.1.88" -axum = "0.7.5" -axum-client-ip = "0.6.1" -axum-extra = "0.9.4" -axum-test = "16.2.0" +axum = "0.8.9" +axum-client-ip = "1.3.1" +axum-extra = "0.12.6" +axum-test = "20.0.0" base64 = "0.22.1" base85rs = "0.1.3" bincode = "1.3.3" bip39 = { version = "2.0.0", features = ["zeroize"] } bitvec = "1.0.0" -blake3 = "1.7.0" +blake3 = ">=1.7, <1.8.4" # blake3 1.8.4+ requires digest 0.11; workspace is on 0.10 bloomfilter = "3.0.1" bs58 = "0.5.1" bytecodec = "0.4.15" @@ -253,7 +253,7 @@ clap_complete_fig = "4.5" colored = "2.2" comfy-table = "7.1.4" console = "0.16.0" -console-subscriber = "0.4.1" +console-subscriber = "0.5.0" console_error_panic_hook = "0.1" const-str = "0.5.6" const_format = "0.2.34" @@ -330,7 +330,7 @@ pnet_packet = "0.35.0" publicsuffix = "2.3.0" proc_pidinfo = "0.1.3" quote = "1" -rand = "0.8.5" +rand = "0.8.6" rand09 = { package = "rand", version = "=0.9.2" } rand_chacha = "0.3" rand_chacha09 = { package = "rand_chacha", version = "=0.9.0" } @@ -354,7 +354,6 @@ serde_yaml = "0.9.25" serde_plain = "1.0.2" sha2 = "0.10.3" si-scale = "0.2.3" -smolmix = { version = "0.0.1", path = "smolmix/core" } smoltcp = "0.12" snow = "0.9.6" sphinx-packet = "=0.6.0" @@ -365,7 +364,7 @@ subtle-encoding = "0.5" syn = "2" sysinfo = "0.37.0" tap = "1.0.1" -tar = "0.4.44" +tar = "0.4.45" test-with = { version = "0.15.4", default-features = false } tempfile = "3.20" thiserror = "2.0" @@ -396,7 +395,7 @@ uniffi = "0.29.2" uniffi_build = "0.29.0" url = "2.5" utoipa = "5.2" -utoipa-swagger-ui = "8.1" +utoipa-swagger-ui = "9.0.2" utoipauto = "0.2" uuid = "1.19.0" vergen = { version = "=8.3.1", default-features = false } @@ -423,111 +422,115 @@ libcrux-sha3 = "0.0.8" libcrux-traits = "0.0.6" # Workspace dep definitions required by crates.io publication - we need a workspace version since `cargo workspaces` doesn't work with path imports from crate manifests -nym-api-requests = { version = "1.20.4", path = "nym-api/nym-api-requests" } -nym-authenticator-requests = { version = "1.20.4", path = "common/authenticator-requests" } -nym-async-file-watcher = { version = "1.20.4", path = "common/async-file-watcher" } -nym-authenticator-client = { version = "1.20.4", path = "nym-authenticator-client" } -nym-bandwidth-controller = { version = "1.20.4", path = "common/bandwidth-controller" } -nym-bin-common = { version = "1.20.4", path = "common/bin-common" } -nym-cache = { version = "1.20.4", path = "common/nym-cache" } -nym-client-core = { version = "1.20.4", path = "common/client-core", default-features = false } -nym-client-core-config-types = { version = "1.20.4", path = "common/client-core/config-types" } -nym-client-core-gateways-storage = { version = "1.20.4", path = "common/client-core/gateways-storage" } -nym-client-core-surb-storage = { version = "1.20.4", path = "common/client-core/surb-storage" } -nym-client-websocket-requests = { version = "1.20.4", path = "clients/native/websocket-requests" } -nym-common = { version = "1.20.4", path = "common/nym-common" } -nym-compact-ecash = { version = "1.20.4", path = "common/nym_offline_compact_ecash" } -nym-config = { version = "1.20.4", path = "common/config" } -nym-contracts-common = { version = "1.20.4", path = "common/cosmwasm-smart-contracts/contracts-common" } -nym-coconut-dkg-common = { version = "1.20.4", path = "common/cosmwasm-smart-contracts/coconut-dkg" } -nym-credential-storage = { version = "1.20.4", path = "common/credential-storage" } -nym-credential-utils = { version = "1.20.4", path = "common/credential-utils" } -nym-credential-proxy-lib = { version = "1.20.4", path = "common/credential-proxy" } -nym-credentials = { version = "1.20.4", path = "common/credentials", default-features = false } -nym-credentials-interface = { version = "1.20.4", path = "common/credentials-interface" } -nym-credential-proxy-requests = { version = "1.20.4", path = "nym-credential-proxy/nym-credential-proxy-requests", default-features = false } -nym-credential-verification = { version = "1.20.4", path = "common/credential-verification" } -nym-crypto = { version = "1.20.4", path = "common/crypto", default-features = false } -nym-dkg = { version = "1.20.4", path = "common/dkg" } -nym-ecash-contract-common = { version = "1.20.4", path = "common/cosmwasm-smart-contracts/ecash-contract" } -nym-ecash-signer-check = { version = "1.20.4", path = "common/ecash-signer-check" } -nym-ecash-signer-check-types = { version = "1.20.4", path = "common/ecash-signer-check-types" } -nym-ecash-time = { version = "1.20.4", path = "common/ecash-time" } -nym-exit-policy = { version = "1.20.4", path = "common/exit-policy" } -nym-ffi-shared = { version = "1.20.4", path = "sdk/ffi/shared" } -nym-gateway-client = { version = "1.20.4", path = "common/client-libs/gateway-client", default-features = false } +nym-api-requests = { version = "1.21.0", path = "nym-api/nym-api-requests" } +nym-authenticator-requests = { version = "1.21.0", path = "common/authenticator-requests" } +nym-async-file-watcher = { version = "1.21.0", path = "common/async-file-watcher" } +nym-authenticator-client = { version = "1.21.0", path = "nym-authenticator-client" } +nym-bandwidth-controller = { version = "1.21.0", path = "common/bandwidth-controller" } +nym-bin-common = { version = "1.21.0", path = "common/bin-common" } +nym-cache = { version = "1.21.0", path = "common/nym-cache" } +nym-client-core = { version = "1.21.0", path = "common/client-core", default-features = false } +nym-client-core-config-types = { version = "1.21.0", path = "common/client-core/config-types" } +nym-client-core-gateways-storage = { version = "1.21.0", path = "common/client-core/gateways-storage" } +nym-client-core-surb-storage = { version = "1.21.0", path = "common/client-core/surb-storage" } +nym-client-websocket-requests = { version = "1.21.0", path = "clients/native/websocket-requests" } +nym-common = { version = "1.21.0", path = "common/nym-common" } +nym-compact-ecash = { version = "1.21.0", path = "common/nym_offline_compact_ecash" } +nym-config = { version = "1.21.0", path = "common/config" } +nym-contracts-common = { version = "1.21.0", path = "common/cosmwasm-smart-contracts/contracts-common" } +nym-coconut-dkg-common = { version = "1.21.0", path = "common/cosmwasm-smart-contracts/coconut-dkg" } +nym-credential-storage = { version = "1.21.0", path = "common/credential-storage" } +nym-credential-utils = { version = "1.21.0", path = "common/credential-utils" } +nym-credential-proxy-lib = { version = "1.21.0", path = "common/credential-proxy" } +nym-credentials = { version = "1.21.0", path = "common/credentials", default-features = false } +nym-credentials-interface = { version = "1.21.0", path = "common/credentials-interface" } +nym-credential-proxy-requests = { version = "1.21.0", path = "nym-credential-proxy/nym-credential-proxy-requests", default-features = false } +nym-credential-verification = { version = "1.21.0", path = "common/credential-verification" } +nym-crypto = { version = "1.21.0", path = "common/crypto", default-features = false } +nym-dkg = { version = "1.21.0", path = "common/dkg" } +nym-ecash-contract-common = { version = "1.21.0", path = "common/cosmwasm-smart-contracts/ecash-contract" } +nym-ecash-signer-check = { version = "1.21.0", path = "common/ecash-signer-check" } +nym-ecash-signer-check-types = { version = "1.21.0", path = "common/ecash-signer-check-types" } +nym-ecash-time = { version = "1.21.0", path = "common/ecash-time" } +nym-exit-policy = { version = "1.21.0", path = "common/exit-policy" } +nym-ffi-shared = { version = "1.21.0", path = "sdk/ffi/shared" } +nym-gateway-client = { version = "1.21.0", path = "common/client-libs/gateway-client", default-features = false } nym-gateway-probe = { version = "1.18.0", path = "nym-gateway-probe" } -nym-gateway-requests = { version = "1.20.4", path = "common/gateway-requests" } -nym-gateway-storage = { version = "1.20.4", path = "common/gateway-storage" } -nym-gateway-stats-storage = { version = "1.20.4", path = "common/gateway-stats-storage" } -nym-group-contract-common = { version = "1.20.4", path = "common/cosmwasm-smart-contracts/group-contract" } -nym-http-api-client = { version = "1.20.4", path = "common/http-api-client" } -nym-http-api-client-macro = { version = "1.20.4", path = "common/http-api-client-macro" } -nym-http-api-common = { version = "1.20.4", path = "common/http-api-common", default-features = false } -nym-id = { version = "1.20.4", path = "common/nym-id" } -nym-ip-packet-client = { version = "1.20.4", path = "nym-ip-packet-client" } -nym-ip-packet-requests = { version = "1.20.4", path = "common/ip-packet-requests" } -nym-lp = { version = "1.20.4", path = "common/nym-lp" } -nym-kkt = { version = "0.1.0", path = "common/nym-kkt" } -nym-kkt-ciphersuite = { version = "1.20.4", path = "common/nym-kkt-ciphersuite" } -nym-kkt-context = { version = "1.20.4", path = "common/nym-kkt-context" } -nym-metrics = { version = "1.20.4", path = "common/nym-metrics" } -nym-mixnet-client = { version = "1.20.4", path = "common/client-libs/mixnet-client" } -nym-mixnet-contract-common = { version = "1.20.4", path = "common/cosmwasm-smart-contracts/mixnet-contract" } -nym-multisig-contract-common = { version = "1.20.4", path = "common/cosmwasm-smart-contracts/multisig-contract" } -nym-network-defaults = { version = "1.20.4", path = "common/network-defaults" } -nym-node-tester-utils = { version = "1.20.4", path = "common/node-tester-utils" } -nym-noise = { version = "1.20.4", path = "common/nymnoise" } -nym-noise-keys = { version = "1.20.4", path = "common/nymnoise/keys" } -nym-nonexhaustive-delayqueue = { version = "1.20.4", path = "common/nonexhaustive-delayqueue" } -nym-node-requests = { version = "1.20.4", path = "nym-node/nym-node-requests", default-features = false } -nym-node-metrics = { version = "1.20.4", path = "nym-node/nym-node-metrics" } -nym-ordered-buffer = { version = "1.20.4", path = "common/socks5/ordered-buffer" } -nym-outfox = { version = "1.20.4", path = "nym-outfox" } -nym-registration-common = { version = "1.20.4", path = "common/registration" } -nym-pemstore = { version = "1.20.4", path = "common/pemstore" } -nym-performance-contract-common = { version = "1.20.4", path = "common/cosmwasm-smart-contracts/nym-performance-contract" } -nym-sdk = { version = "1.20.4", path = "sdk/rust/nym-sdk" } -nym-serde-helpers = { version = "1.20.4", path = "common/serde-helpers" } -nym-service-providers-common = { version = "1.20.4", path = "service-providers/common" } -nym-service-provider-requests-common = { version = "1.20.4", path = "common/service-provider-requests-common" } -nym-socks5-client-core = { version = "1.20.4", path = "common/socks5-client-core" } -nym-socks5-proxy-helpers = { version = "1.20.4", path = "common/socks5/proxy-helpers" } -nym-socks5-requests = { version = "1.20.4", path = "common/socks5/requests" } -nym-sphinx = { version = "1.20.4", path = "common/nymsphinx" } -nym-sphinx-acknowledgements = { version = "1.20.4", path = "common/nymsphinx/acknowledgements" } -nym-sphinx-addressing = { version = "1.20.4", path = "common/nymsphinx/addressing" } -nym-sphinx-anonymous-replies = { version = "1.20.4", path = "common/nymsphinx/anonymous-replies" } -nym-sphinx-chunking = { version = "1.20.4", path = "common/nymsphinx/chunking" } -nym-sphinx-cover = { version = "1.20.4", path = "common/nymsphinx/cover" } -nym-sphinx-forwarding = { version = "1.20.4", path = "common/nymsphinx/forwarding" } -nym-sphinx-framing = { version = "1.20.4", path = "common/nymsphinx/framing" } -nym-sphinx-params = { version = "1.20.4", path = "common/nymsphinx/params" } -nym-sphinx-routing = { version = "1.20.4", path = "common/nymsphinx/routing" } -nym-sphinx-types = { version = "1.20.4", path = "common/nymsphinx/types" } -nym-statistics-common = { version = "1.20.4", path = "common/statistics" } -nym-store-cipher = { version = "1.20.4", path = "common/store-cipher" } -nym-task = { version = "1.20.4", path = "common/task" } -nym-tun = { version = "1.20.4", path = "common/tun" } -nym-test-utils = { version = "1.20.4", path = "common/test-utils" } -nym-ticketbooks-merkle = { version = "1.20.4", path = "common/ticketbooks-merkle" } -nym-topology = { version = "1.20.4", path = "common/topology" } -nym-types = { version = "1.20.4", path = "common/types" } -nym-upgrade-mode-check = { version = "1.20.4", path = "common/upgrade-mode-check" } -nym-validator-client = { version = "1.20.4", path = "common/client-libs/validator-client", default-features = false } -nym-vesting-contract-common = { version = "1.20.4", path = "common/cosmwasm-smart-contracts/vesting-contract" } -nym-network-monitors-contract-common = { version = "1.20.4", path = "common/cosmwasm-smart-contracts/network-monitors-contract" } -nym-verloc = { version = "1.20.4", path = "common/verloc" } -nym-wireguard = { version = "1.20.4", path = "common/wireguard" } -nym-wireguard-types = { version = "1.20.4", path = "common/wireguard-types" } -nym-wireguard-private-metadata-shared = { version = "1.20.4", path = "common/wireguard-private-metadata/shared" } -nym-wireguard-private-metadata-client = { version = "1.20.4", path = "common/wireguard-private-metadata/client" } -nym-wireguard-private-metadata-server = { version = "1.20.4", path = "common/wireguard-private-metadata/server" } +nym-gateway-requests = { version = "1.21.0", path = "common/gateway-requests" } +nym-gateway-storage = { version = "1.21.0", path = "common/gateway-storage" } +nym-gateway-stats-storage = { version = "1.21.0", path = "common/gateway-stats-storage" } +nym-group-contract-common = { version = "1.21.0", path = "common/cosmwasm-smart-contracts/group-contract" } +nym-http-api-client = { version = "1.21.0", path = "common/http-api-client" } +nym-http-api-client-macro = { version = "1.21.0", path = "common/http-api-client-macro" } +nym-http-api-common = { version = "1.21.0", path = "common/http-api-common", default-features = false } +nym-id = { version = "1.21.0", path = "common/nym-id" } +nym-ip-packet-client = { version = "1.21.0", path = "nym-ip-packet-client" } +nym-ip-packet-requests = { version = "1.21.0", path = "common/ip-packet-requests" } +nym-lp = { version = "1.21.0", path = "common/nym-lp" } +nym-lp-data = { version = "1.21.0", path = "common/nym-lp-data" } +nym-kkt = { version = "1.21.0", path = "common/nym-kkt" } +nym-kkt-ciphersuite = { version = "1.21.0", path = "common/nym-kkt-ciphersuite" } +nym-kkt-context = { version = "1.21.0", path = "common/nym-kkt-context" } +nym-metrics = { version = "1.21.0", path = "common/nym-metrics" } +nym-mixnet-client = { version = "1.21.0", path = "common/client-libs/mixnet-client" } +nym-mixnet-contract-common = { version = "1.21.0", path = "common/cosmwasm-smart-contracts/mixnet-contract" } +nym-multisig-contract-common = { version = "1.21.0", path = "common/cosmwasm-smart-contracts/multisig-contract" } +nym-network-defaults = { version = "1.21.0", path = "common/network-defaults" } +nym-node-tester-utils = { version = "1.21.0", path = "common/node-tester-utils" } +nym-noise = { version = "1.21.0", path = "common/nymnoise" } +nym-noise-keys = { version = "1.21.0", path = "common/nymnoise/keys" } +nym-nonexhaustive-delayqueue = { version = "1.21.0", path = "common/nonexhaustive-delayqueue" } +nym-node-requests = { version = "1.21.0", path = "nym-node/nym-node-requests", default-features = false } +nym-node-metrics = { version = "1.21.0", path = "nym-node/nym-node-metrics" } +nym-node-families-contract-common = { version = "1.21.0", path = "common/cosmwasm-smart-contracts/node-families-contract" } +nym-ordered-buffer = { version = "1.21.0", path = "common/socks5/ordered-buffer" } +nym-outfox = { version = "1.21.0", path = "nym-outfox" } +nym-registration-common = { version = "1.21.0", path = "common/registration" } +nym-pemstore = { version = "1.21.0", path = "common/pemstore" } +nym-performance-contract-common = { version = "1.21.0", path = "common/cosmwasm-smart-contracts/nym-performance-contract" } +nym-sdk = { version = "1.21.0", path = "sdk/rust/nym-sdk" } +nym-serde-helpers = { version = "1.21.0", path = "common/serde-helpers" } +nym-service-providers-common = { version = "1.21.0", path = "service-providers/common" } +nym-service-provider-requests-common = { version = "1.21.0", path = "common/service-provider-requests-common" } +nym-socks5-client-core = { version = "1.21.0", path = "common/socks5-client-core" } +nym-socks5-proxy-helpers = { version = "1.21.0", path = "common/socks5/proxy-helpers" } +nym-socks5-requests = { version = "1.21.0", path = "common/socks5/requests" } +nym-sphinx = { version = "1.21.0", path = "common/nymsphinx" } +nym-sphinx-acknowledgements = { version = "1.21.0", path = "common/nymsphinx/acknowledgements" } +nym-sphinx-addressing = { version = "1.21.0", path = "common/nymsphinx/addressing" } +nym-sphinx-anonymous-replies = { version = "1.21.0", path = "common/nymsphinx/anonymous-replies" } +nym-sphinx-chunking = { version = "1.21.0", path = "common/nymsphinx/chunking" } +nym-sphinx-cover = { version = "1.21.0", path = "common/nymsphinx/cover" } +nym-sphinx-forwarding = { version = "1.21.0", path = "common/nymsphinx/forwarding" } +nym-sphinx-framing = { version = "1.21.0", path = "common/nymsphinx/framing" } +nym-sphinx-params = { version = "1.21.0", path = "common/nymsphinx/params" } +nym-sphinx-routing = { version = "1.21.0", path = "common/nymsphinx/routing" } +nym-sphinx-types = { version = "1.21.0", path = "common/nymsphinx/types" } +nym-statistics-common = { version = "1.21.0", path = "common/statistics" } +nym-store-cipher = { version = "1.21.0", path = "common/store-cipher" } +nym-task = { version = "1.21.0", path = "common/task" } +nym-tun = { version = "1.21.0", path = "common/tun" } +nym-test-utils = { version = "1.21.0", path = "common/test-utils" } +nym-ticketbooks-merkle = { version = "1.21.0", path = "common/ticketbooks-merkle" } +nym-topology = { version = "1.21.0", path = "common/topology" } +nym-types = { version = "1.21.0", path = "common/types" } +nym-upgrade-mode-check = { version = "1.21.0", path = "common/upgrade-mode-check" } +nym-validator-client = { version = "1.21.0", path = "common/client-libs/validator-client", default-features = false } +nym-vesting-contract-common = { version = "1.21.0", path = "common/cosmwasm-smart-contracts/vesting-contract" } +nym-network-monitors-contract-common = { version = "1.21.0", path = "common/cosmwasm-smart-contracts/network-monitors-contract" } +nym-verloc = { version = "1.21.0", path = "common/verloc" } +nym-wireguard = { version = "1.21.0", path = "common/wireguard" } +nym-wireguard-types = { version = "1.21.0", path = "common/wireguard-types" } +nym-wireguard-private-metadata-shared = { version = "1.21.0", path = "common/wireguard-private-metadata/shared" } +nym-wireguard-private-metadata-client = { version = "1.21.0", path = "common/wireguard-private-metadata/client" } +nym-wireguard-private-metadata-server = { version = "1.21.0", path = "common/wireguard-private-metadata/server" } nym-sqlx-pool-guard = { version = "1.2.0", path = "nym-sqlx-pool-guard" } -nym-wasm-client-core = { version = "1.20.4", path = "common/wasm/client-core" } -nym-wasm-storage = { version = "1.20.4", path = "common/wasm/storage" } -nym-wasm-utils = { version = "1.20.4", path = "common/wasm/utils", default-features = false } -nyxd-scraper-shared = { version = "1.20.4", path = "common/nyxd-scraper-shared" } +nym-wasm-client-core = { version = "1.21.0", path = "common/wasm/client-core" } +nym-wasm-storage = { version = "1.21.0", path = "common/wasm/storage" } +nym-wasm-utils = { version = "1.21.0", path = "common/wasm/utils", default-features = false } +nyxd-scraper-shared = { version = "1.21.0", path = "common/nyxd-scraper-shared" } + +smolmix = { version = "1.21.0", path = "smolmix/core" } # coconut/DKG related # unfortunately until https://github.com/zkcrypto/nym-bls12_381-fork/issues/10 is resolved, we have to rely on the fork @@ -594,16 +597,7 @@ opt-level = 3 # lto = true opt-level = 'z' -[profile.release.package.nym-node-tester-wasm] -# lto = true -opt-level = 'z' -# Commented out since the crate is also commented out from the inclusion in the -# workspace above. We should uncomment this if we re-include it in the -# workspace -#[profile.release.package.nym-wasm-sdk] -## lto = true -#opt-level = 'z' [profile.release.package.mix-fetch-wasm] # lto = true @@ -626,3 +620,4 @@ exit = "deny" panic = "deny" unimplemented = "deny" unreachable = "deny" + diff --git a/Makefile b/Makefile index b88a99d79f..3ed8a5babc 100644 --- a/Makefile +++ b/Makefile @@ -104,23 +104,19 @@ $(eval $(call add_cargo_workspace,wallet,nym-wallet)) sdk-wasm: sdk-wasm-build sdk-wasm-test sdk-wasm-lint sdk-wasm-build: -# $(MAKE) -C nym-browser-extension/storage wasm-pack $(MAKE) -C wasm/client - $(MAKE) -C wasm/node-tester $(MAKE) -C wasm/mix-fetch # $(MAKE) -C wasm/zknym-lib - # $(MAKE) -C wasm/full-nym-wasm # run this from npm/yarn to ensure tools are in the path, e.g. yarn build:sdk from root of repo sdk-typescript-build: npx lerna run --scope @nymproject/sdk build --stream npx lerna run --scope @nymproject/mix-fetch build --stream - npx lerna run --scope @nymproject/node-tester build --stream - yarn --cwd sdk/typescript/codegen/contract-clients build + pnpm --pwd sdk/typescript/codegen/contract-clients build # NOTE: These targets are part of the main workspace (but not as wasm32-unknown-unknown) -# WASM_CRATES = extension-storage nym-client-wasm nym-node-tester-wasm zknym-lib -WASM_CRATES = nym-client-wasm nym-node-tester-wasm + +WASM_CRATES = nym-client-wasm sdk-wasm-test: #cargo test $(addprefix -p , $(WASM_CRATES)) --target wasm32-unknown-unknown -- -Dwarnings @@ -223,7 +219,7 @@ build-nym-cli: generate-typescript: cd tools/ts-rs-cli && cargo run && cd ../.. - yarn types:lint:fix + pnpm types:lint:fix # Run the integration tests for public nym-api endpoints run-api-tests: diff --git a/README.md b/README.md index 198b4d90aa..2e7610ed7f 100644 --- a/README.md +++ b/README.md @@ -74,9 +74,9 @@ Nym Node Operators and Validators Terms and Conditions can be found [here](https ## Getting Started ```bash -yarn install +pnpm install ``` ```bash -yarn build +pnpm build ``` diff --git a/ansible/nym-node/playbooks/mitigate_kernel_CVE.yml b/ansible/nym-node/playbooks/mitigate_kernel_CVE.yml new file mode 100644 index 0000000000..228f538dc6 --- /dev/null +++ b/ansible/nym-node/playbooks/mitigate_kernel_CVE.yml @@ -0,0 +1,42 @@ +# Mitigation playbook for CopyFail (CVE-2026-31431) and DirtyFrag (CVE-2026-43284 / CVE-2026-43500) +# This playbook applies interim module blacklists only +# Kernel patches are not yet available (May 2026) +# Once patched kernels ship, use remove_kernel_CVE_mitigations.yml to reverse everything +# This playbook is idempotent - safe to re-run if mitigations were already applied + +- name: Mitigate Copy Fail + Dirty Frag + hosts: all + become: true + tasks: + - name: Blacklist algif_aead (Copy Fail) + copy: + dest: /etc/modprobe.d/disable-algif_aead.conf + content: "install algif_aead /bin/false\n" + owner: root + group: root + mode: "0644" + + - name: Blacklist esp4, esp6, rxrpc (Dirty Frag) + copy: + dest: /etc/modprobe.d/dirtyfrag.conf + content: | + install esp4 /bin/false + install esp6 /bin/false + install rxrpc /bin/false + owner: root + group: root + mode: "0644" + + - name: Unload all affected modules + modprobe: + name: "{{ item }}" + state: absent + loop: + - algif_aead + - esp4 + - esp6 + - rxrpc + ignore_errors: true + + - name: Drop page cache to clear any contamination + shell: echo 3 > /proc/sys/vm/drop_caches \ No newline at end of file diff --git a/ansible/nym-node/playbooks/remove_kernel_CVE_mitigations.yml b/ansible/nym-node/playbooks/remove_kernel_CVE_mitigations.yml new file mode 100644 index 0000000000..d966d75444 --- /dev/null +++ b/ansible/nym-node/playbooks/remove_kernel_CVE_mitigations.yml @@ -0,0 +1,111 @@ +############################################################################################ +############################################################################################ +############################################################################################ +#### THIS PLAYBOOK IS NOT MEANT TO BE RUN YET, IT IS NOT REFERRED IN ANY DOCUMENTATION! #### +############################################################################################ +############################################################################################ +############################################################################################ +# +# Reversal playbook for mitigate_kernel_CVE.yml (CopyFail CVE-2026-31431 / DirtyFrag CVE-2026-43284 / CVE-2026-43500). +# +# Run this AFTER your distro has shipped the patched kernel. +# This playbook: +# 1. Updates the kernel via apt +# 2. Reboots and waits for reconnect +# 3. Verifies the running kernel is newer than the pre-patch version +# 4. Removes the interim module blacklists +# 5. Re-enables the affected modules live (no second reboot needed) +# +# Debian family only (Debian, Ubuntu). Tested on Debian 11, Debian 12, Ubuntu 20.04, 22.04, 24.04. +# +# For exit-gateway nodes with --wireguard-enabled true: +# After this playbook completes, run the networking restore step on each node via: +# ansible-playbook deploy.yml -t ntm +# See the CVE patch documentation for details. + +- name: Remove CVE mitigations and apply patched kernel + hosts: all + become: true + + tasks: + - name: Verify OS is Debian family + assert: + that: + - ansible_os_family == "Debian" + fail_msg: "This playbook supports Debian-family distros only (Debian, Ubuntu). For other distros, apply the kernel update and mitigation removal manually." + + - name: Update apt cache + apt: + update_cache: true + cache_valid_time: 0 + + - name: Upgrade kernel packages + apt: + upgrade: full + only_upgrade: false + register: apt_upgrade_result + + - name: Record pre-reboot kernel version + command: uname -r + register: kernel_before + changed_when: false + + - name: Reboot to load patched kernel + reboot: + msg: "Rebooting to apply patched kernel (CVE-2026-31431 / CVE-2026-43284 / CVE-2026-43500)" + reboot_timeout: 300 + pre_reboot_delay: 5 + post_reboot_delay: 15 + + - name: Record post-reboot kernel version + command: uname -r + register: kernel_after + changed_when: false + + - name: Show kernel versions before and after reboot + debug: + msg: + - "Kernel before reboot: {{ kernel_before.stdout }}" + - "Kernel after reboot: {{ kernel_after.stdout }}" + + - name: Warn if kernel did not change after reboot + debug: + msg: > + WARNING: kernel version did not change after reboot ({{ kernel_after.stdout }}). + The patched kernel may not have been selected by GRUB, or no kernel update was available. + Do NOT remove the interim mitigations until you have confirmed the running kernel is patched. + Check: apt-cache policy linux-image-amd64 # Debian + Check: apt-cache policy linux-image-generic # Ubuntu + when: kernel_before.stdout == kernel_after.stdout + + - name: Remove algif_aead blacklist + file: + path: /etc/modprobe.d/disable-algif_aead.conf + state: absent + + - name: Remove DirtyFrag blacklist (esp4, esp6, rxrpc) + file: + path: /etc/modprobe.d/dirtyfrag.conf + state: absent + + - name: Re-enable affected modules live + modprobe: + name: "{{ item }}" + state: present + loop: + - esp4 + - esp6 + - rxrpc + - algif_aead + ignore_errors: true + + - name: Confirm nym-node service is still running + systemd: + name: nym-node + state: started + register: nym_node_status + failed_when: false + + - name: Show nym-node status + debug: + msg: "nym-node service state: {{ nym_node_status.state | default('unknown - service may not exist on this node') }}" \ No newline at end of file diff --git a/clients/native/src/websocket/handler.rs b/clients/native/src/websocket/handler.rs index 2ea7cac609..e9ade8d046 100644 --- a/clients/native/src/websocket/handler.rs +++ b/clients/native/src/websocket/handler.rs @@ -472,6 +472,7 @@ impl Handler { fn prepare_reconstructed_binary( reconstructed_messages: Vec, ) -> Vec> { + #[allow(clippy::result_large_err)] // TODO : remove this once tungstenite is updated reconstructed_messages .into_iter() .map(ServerResponse::Received) @@ -484,6 +485,7 @@ fn prepare_reconstructed_binary( fn prepare_reconstructed_text( reconstructed_messages: Vec, ) -> Vec> { + #[allow(clippy::result_large_err)] // TODO : remove this once tungstenite is updated reconstructed_messages .into_iter() .map(ServerResponse::Received) diff --git a/common/bandwidth-controller/src/traits.rs b/common/bandwidth-controller/src/traits.rs index bb958f482b..1ff14027ef 100644 --- a/common/bandwidth-controller/src/traits.rs +++ b/common/bandwidth-controller/src/traits.rs @@ -25,6 +25,8 @@ pub trait BandwidthTicketProvider: Send + Sync { ) -> Result; async fn get_upgrade_mode_token(&self) -> Result, BandwidthControllerError>; + + async fn close(&self) {} } #[cfg_attr(target_arch = "wasm32", async_trait(?Send))] @@ -56,6 +58,10 @@ where .map_err(|_| BandwidthControllerError::MalformedUpgradeModeToken)?; Ok(Some(token)) } + + async fn close(&self) { + self.storage.close().await; + } } #[cfg_attr(target_arch = "wasm32", async_trait(?Send))] @@ -75,4 +81,8 @@ impl BandwidthTicketProvider for Box async fn get_upgrade_mode_token(&self) -> Result, BandwidthControllerError> { (**self).get_upgrade_mode_token().await } + + async fn close(&self) { + (**self).close().await; + } } diff --git a/common/client-core/src/client/base_client/mod.rs b/common/client-core/src/client/base_client/mod.rs index 7b27d7b1b1..253da7d6c6 100644 --- a/common/client-core/src/client/base_client/mod.rs +++ b/common/client-core/src/client/base_client/mod.rs @@ -1023,6 +1023,16 @@ where let encryption_keys = init_res.client_keys.encryption_keypair(); let identity_keys = init_res.client_keys.identity_keypair(); + let credential_store_for_close = credential_store.clone(); + let close_credential_token = shutdown_tracker.clone_shutdown_token(); + shutdown_tracker.try_spawn_named( + async move { + close_credential_token.cancelled().await; + credential_store_for_close.close().await; + }, + "CredentialStorage::close_on_shutdown", + ); + // the components are started in very specific order. Unless you know what you are doing, // do not change that. let bandwidth_controller = self diff --git a/common/client-core/src/client/base_client/non_wasm_helpers.rs b/common/client-core/src/client/base_client/non_wasm_helpers.rs index 335ca8c522..ab57caf0cf 100644 --- a/common/client-core/src/client/base_client/non_wasm_helpers.rs +++ b/common/client-core/src/client/base_client/non_wasm_helpers.rs @@ -11,11 +11,17 @@ use nym_bandwidth_controller::BandwidthController; use nym_client_core_gateways_storage::OnDiskGatewaysDetails; use nym_credential_storage::storage::Storage as CredentialStorage; use nym_validator_client::{QueryHttpRpcNyxdClient, nyxd}; -use std::{io, path::Path}; +use std::{io, path::Path, time::Duration}; use time::OffsetDateTime; use tracing::{error, info, trace}; use url::Url; +/// Maximum rename retry attempts when the database file is temporarily locked. +const ARCHIVE_MAX_RETRY_ATTEMPTS: u8 = 15; + +/// Delay between archive rename retry attempts. +const ARCHIVE_RETRY_DELAY: Duration = Duration::from_millis(200); + async fn setup_fresh_backend>( db_path: P, surb_config: &config::ReplySurbs, @@ -74,13 +80,58 @@ async fn archive_corrupted_database>(db_path: P) -> io::Result<() }; let renamed = db_path.with_extension(new_extension); - tokio::fs::rename(db_path, &renamed).await.inspect_err(|_| { - error!( - "Failed to rename corrupt database file: {} to {}", - db_path.display(), - renamed.display() - ); - }) + // On Windows, sqlx may release its OS file handles asynchronously after + // pool.close() returns, briefly keeping the file locked + // (ERROR_SHARING_VIOLATION, os error 32). Retry with a short delay to + // give the OS time to flush the remaining handles. + for attempt in 0..ARCHIVE_MAX_RETRY_ATTEMPTS { + match tokio::fs::rename(db_path, &renamed).await { + Ok(()) => return Ok(()), + Err(e) if is_file_locked_error(&e) && (attempt + 1) < ARCHIVE_MAX_RETRY_ATTEMPTS => { + trace!( + "Database file is temporarily locked, retrying archive \ + (attempt {}/{}): {e}", + attempt + 1, + ARCHIVE_MAX_RETRY_ATTEMPTS + ); + tokio::time::sleep(ARCHIVE_RETRY_DELAY).await; + } + Err(e) => { + error!( + "Failed to rename corrupt database file: {} to {}", + db_path.display(), + renamed.display() + ); + return Err(e); + } + } + } + + // Reached only when every attempt was blocked by a file lock. + error!( + "Failed to rename corrupt database file after {} attempts: {} to {}", + ARCHIVE_MAX_RETRY_ATTEMPTS, + db_path.display(), + renamed.display() + ); + Err(io::Error::other( + "corrupt database archive blocked by persistent file lock", + )) +} + +/// Returns `true` when the IO error indicates a temporary file lock held by another handle +/// within the same process. Only meaningful on Windows; always `false` elsewhere. +fn is_file_locked_error(e: &io::Error) -> bool { + #[cfg(windows)] + { + // ERROR_SHARING_VIOLATION = 32, ERROR_LOCK_VIOLATION = 33 + matches!(e.raw_os_error(), Some(32) | Some(33)) + } + #[cfg(not(windows))] + { + let _ = e; + false + } } pub async fn setup_fs_reply_surb_backend>( diff --git a/common/client-core/src/client/real_messages_control/message_handler.rs b/common/client-core/src/client/real_messages_control/message_handler.rs index 77677b749e..c4aca70c9b 100644 --- a/common/client-core/src/client/real_messages_control/message_handler.rs +++ b/common/client-core/src/client/real_messages_control/message_handler.rs @@ -439,7 +439,7 @@ where let mut pending_acks = Vec::with_capacity(fragments.len()); let mut to_forward: HashMap<_, Vec<_>> = HashMap::new(); - for (raw, prepared) in fragments.into_iter().zip(prepared_fragments.into_iter()) { + for (raw, prepared) in fragments.into_iter().zip(prepared_fragments) { let lane = raw.0; let FragmentWithMaxRetransmissions { fragment, @@ -670,7 +670,7 @@ where Ok(fragments .into_iter() - .zip(reply_surbs.into_iter()) + .zip(reply_surbs) .map(|(fragment, reply_surb)| { // unwrap here is fine as we know we have a valid topology #[allow(clippy::unwrap_used)] diff --git a/common/client-core/surb-storage/src/backend/fs_backend/mod.rs b/common/client-core/surb-storage/src/backend/fs_backend/mod.rs index 625913e174..9c0c465433 100644 --- a/common/client-core/surb-storage/src/backend/fs_backend/mod.rs +++ b/common/client-core/surb-storage/src/backend/fs_backend/mod.rs @@ -337,6 +337,8 @@ impl ReplyStorageBackend for Backend { } async fn stop_storage_session(self) -> Result<(), Self::StorageError> { - self.stop_client_use().await + let result = self.stop_client_use().await; + self.shutdown().await; + result } } diff --git a/common/client-core/surb-storage/src/lib.rs b/common/client-core/surb-storage/src/lib.rs index ffd7ad85f6..f79127b422 100644 --- a/common/client-core/surb-storage/src/lib.rs +++ b/common/client-core/surb-storage/src/lib.rs @@ -48,6 +48,7 @@ where debug!("Started PersistentReplyStorage"); if let Err(err) = self.backend.start_storage_session().await { error!("failed to start the storage session - {err}"); + self.backend.stop_storage_session().await.ok(); return; } @@ -55,10 +56,11 @@ where info!("PersistentReplyStorage is flushing all reply-related data to underlying storage"); if let Err(err) = self.backend.flush_surb_storage(&mem_state).await { - error!("failed to flush our reply-related data to the persistent storage: {err}") - } else { - info!("Data flush is complete") + error!("failed to flush our reply-related data to the persistent storage: {err}"); + self.backend.stop_storage_session().await.ok(); + return; } + info!("Data flush is complete"); if let Err(err) = self.backend.stop_storage_session().await { error!("failed to properly stop the storage session - {err}. We might not be able to smoothly restore it") diff --git a/common/client-libs/validator-client/Cargo.toml b/common/client-libs/validator-client/Cargo.toml index 61bfc64cf9..d18bedc727 100644 --- a/common/client-libs/validator-client/Cargo.toml +++ b/common/client-libs/validator-client/Cargo.toml @@ -27,6 +27,7 @@ nym-multisig-contract-common = { workspace = true } nym-group-contract-common = { workspace = true } nym-performance-contract-common = { workspace = true } nym-network-monitors-contract-common = { workspace = true } +nym-node-families-contract-common = { workspace = true } nym-serde-helpers = { workspace = true, features = ["hex", "base64"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } diff --git a/common/client-libs/validator-client/src/nym_api/mod.rs b/common/client-libs/validator-client/src/nym_api/mod.rs index dc2739ea48..f9d8167575 100644 --- a/common/client-libs/validator-client/src/nym_api/mod.rs +++ b/common/client-libs/validator-client/src/nym_api/mod.rs @@ -18,6 +18,7 @@ use nym_api_requests::ecash::VerificationKeyResponse; use nym_api_requests::models::network_monitor::{ KnownNetworkMonitorResponse, StressTestBatchSubmission, }; +use nym_api_requests::models::node_families::NodeFamily; use nym_api_requests::models::{ AnnotationResponseV1, ApiHealthResponse, BinaryBuildInformationOwned, ChainBlocksStatusResponse, ChainStatusResponse, KeyRotationInfoResponse, @@ -393,6 +394,45 @@ pub trait NymApiClientExt: ApiClient { Ok(bonds) } + #[tracing::instrument(level = "debug", skip_all)] + async fn get_node_families( + &self, + page: Option, + per_page: Option, + ) -> Result, NymAPIError> { + let mut params = Vec::new(); + if let Some(page) = page { + params.push(("page", page.to_string())) + } + if let Some(per_page) = per_page { + params.push(("per_page", per_page.to_string())) + } + self.get_json( + &[routes::V1_API_VERSION, routes::NODE_FAMILIES_ROUTES], + ¶ms, + ) + .await + } + + async fn get_all_node_families(&self) -> Result, NymAPIError> { + // TODO: deal with paging in macro or some helper function or something, because it's the same pattern everywhere + let mut page = 0; + let mut families = Vec::new(); + + loop { + let mut res = self.get_node_families(Some(page), None).await?; + + families.append(&mut res.data); + if families.len() < res.pagination.total { + page += 1 + } else { + break; + } + } + + Ok(families) + } + #[deprecated] #[tracing::instrument(level = "debug", skip_all)] async fn get_basic_mixnodes(&self) -> Result, NymAPIError> { diff --git a/common/client-libs/validator-client/src/nym_api/routes.rs b/common/client-libs/validator-client/src/nym_api/routes.rs index 8320944f2f..500a266927 100644 --- a/common/client-libs/validator-client/src/nym_api/routes.rs +++ b/common/client-libs/validator-client/src/nym_api/routes.rs @@ -38,6 +38,7 @@ pub mod ecash { } pub const NYM_NODES_ROUTES: &str = "nym-nodes"; +pub const NODE_FAMILIES_ROUTES: &str = "node-families"; pub use nym_nodes::*; pub mod nym_nodes { diff --git a/common/client-libs/validator-client/src/nyxd/contract_traits/mixnet_signing_client.rs b/common/client-libs/validator-client/src/nyxd/contract_traits/mixnet_signing_client.rs index 69aaac59d5..e18e387977 100644 --- a/common/client-libs/validator-client/src/nyxd/contract_traits/mixnet_signing_client.rs +++ b/common/client-libs/validator-client/src/nyxd/contract_traits/mixnet_signing_client.rs @@ -867,6 +867,10 @@ mod tests { MixnetExecuteMsg::TestingResolveAllPendingEvents { .. } => { client.testing_resolve_all_pending_events(None).ignore() } + // not expected to be exposed by the client + ExecuteMsg::AdminMigrateVestedMixNode { .. } + | ExecuteMsg::AdminMigrateVestedDelegation { .. } + | ExecuteMsg::AdminBatchMigrateVestedDelegations { .. } => ().ignore(), }; } } diff --git a/common/client-libs/validator-client/src/nyxd/contract_traits/mod.rs b/common/client-libs/validator-client/src/nyxd/contract_traits/mod.rs index 3dd4bb70fe..934954736d 100644 --- a/common/client-libs/validator-client/src/nyxd/contract_traits/mod.rs +++ b/common/client-libs/validator-client/src/nyxd/contract_traits/mod.rs @@ -14,6 +14,7 @@ pub mod group_query_client; pub mod mixnet_query_client; pub mod multisig_query_client; pub mod network_monitors_query_client; +pub mod node_families_query_client; pub mod performance_query_client; pub mod vesting_query_client; @@ -24,6 +25,7 @@ pub mod group_signing_client; pub mod mixnet_signing_client; pub mod multisig_signing_client; pub mod network_monitors_signing_client; +pub mod node_families_signing_client; pub mod performance_signing_client; pub mod vesting_signing_client; @@ -36,6 +38,7 @@ pub use multisig_query_client::{MultisigQueryClient, PagedMultisigQueryClient}; pub use network_monitors_query_client::{ NetworkMonitorsQueryClient, PagedNetworkMonitorsQueryClient, }; +pub use node_families_query_client::{NodeFamiliesQueryClient, PagedNodeFamiliesQueryClient}; pub use performance_query_client::{PagedPerformanceQueryClient, PerformanceQueryClient}; pub use vesting_query_client::{PagedVestingQueryClient, VestingQueryClient}; @@ -46,6 +49,7 @@ pub use group_signing_client::GroupSigningClient; pub use mixnet_signing_client::MixnetSigningClient; pub use multisig_signing_client::MultisigSigningClient; pub use network_monitors_signing_client::NetworkMonitorsSigningClient; +pub use node_families_signing_client::NodeFamiliesSigningClient; pub use performance_signing_client::PerformanceSigningClient; pub use vesting_signing_client::VestingSigningClient; @@ -56,6 +60,7 @@ pub trait NymContractsProvider { fn vesting_contract_address(&self) -> Option<&AccountId>; fn performance_contract_address(&self) -> Option<&AccountId>; fn network_monitors_contract_address(&self) -> Option<&AccountId>; + fn node_families_contract_address(&self) -> Option<&AccountId>; // coconut-related fn ecash_contract_address(&self) -> Option<&AccountId>; @@ -70,6 +75,7 @@ pub struct TypedNymContracts { pub vesting_contract_address: Option, pub performance_contract_address: Option, pub network_monitors_contract_address: Option, + pub node_families_contract_address: Option, pub ecash_contract_address: Option, pub group_contract_address: Option, @@ -98,6 +104,10 @@ impl TryFrom for TypedNymContracts { .network_monitors_contract_address .map(|addr| addr.parse()) .transpose()?, + node_families_contract_address: value + .node_families_contract_address + .map(|addr| addr.parse()) + .transpose()?, ecash_contract_address: value .ecash_contract_address .map(|addr| addr.parse()) diff --git a/common/client-libs/validator-client/src/nyxd/contract_traits/node_families_query_client.rs b/common/client-libs/validator-client/src/nyxd/contract_traits/node_families_query_client.rs new file mode 100644 index 0000000000..dd62bb7dc6 --- /dev/null +++ b/common/client-libs/validator-client/src/nyxd/contract_traits/node_families_query_client.rs @@ -0,0 +1,441 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::collect_paged; +use crate::nyxd::contract_traits::NymContractsProvider; +use crate::nyxd::error::NyxdError; +use crate::nyxd::CosmWasmClient; +use async_trait::async_trait; +use cosmrs::AccountId; +use serde::Deserialize; + +use nym_mixnet_contract_common::NodeId; +pub use nym_node_families_contract_common::{ + msg::QueryMsg as NodeFamiliesQueryMsg, AllFamilyMembersPagedResponse, + AllPastFamilyInvitationsPagedResponse, FamiliesPagedResponse, FamilyMemberRecord, + FamilyMembersPagedResponse, GlobalPastFamilyInvitationCursor, NodeFamily, + NodeFamilyByNameResponse, NodeFamilyByOwnerResponse, NodeFamilyId, + NodeFamilyMembershipResponse, NodeFamilyResponse, PastFamilyInvitation, + PastFamilyInvitationCursor, PastFamilyInvitationForNodeCursor, + PastFamilyInvitationsForNodePagedResponse, PastFamilyInvitationsPagedResponse, + PastFamilyMember, PastFamilyMemberCursor, PastFamilyMemberForNodeCursor, + PastFamilyMembersForNodePagedResponse, PastFamilyMembersPagedResponse, + PendingFamilyInvitationDetails, PendingFamilyInvitationResponse, + PendingFamilyInvitationsPagedResponse, PendingInvitationsForNodePagedResponse, + PendingInvitationsPagedResponse, +}; + +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +pub trait NodeFamiliesQueryClient { + async fn query_node_families_contract( + &self, + query: NodeFamiliesQueryMsg, + ) -> Result + where + for<'a> T: Deserialize<'a>; + + async fn get_family_by_id( + &self, + family_id: NodeFamilyId, + ) -> Result { + self.query_node_families_contract(NodeFamiliesQueryMsg::GetFamilyById { family_id }) + .await + } + + async fn get_family_by_owner( + &self, + owner: &AccountId, + ) -> Result { + self.query_node_families_contract(NodeFamiliesQueryMsg::GetFamilyByOwner { + owner: owner.to_string(), + }) + .await + } + + async fn get_family_by_name( + &self, + name: String, + ) -> Result { + self.query_node_families_contract(NodeFamiliesQueryMsg::GetFamilyByName { name }) + .await + } + + async fn get_families_paged( + &self, + start_after: Option, + limit: Option, + ) -> Result { + self.query_node_families_contract(NodeFamiliesQueryMsg::GetFamiliesPaged { + start_after, + limit, + }) + .await + } + + async fn get_family_membership( + &self, + node_id: NodeId, + ) -> Result { + self.query_node_families_contract(NodeFamiliesQueryMsg::GetFamilyMembership { node_id }) + .await + } + + async fn get_family_members_paged( + &self, + family_id: NodeFamilyId, + start_after: Option, + limit: Option, + ) -> Result { + self.query_node_families_contract(NodeFamiliesQueryMsg::GetFamilyMembersPaged { + family_id, + start_after, + limit, + }) + .await + } + + async fn get_all_family_members_paged( + &self, + start_after: Option, + limit: Option, + ) -> Result { + self.query_node_families_contract(NodeFamiliesQueryMsg::GetAllFamilyMembersPaged { + start_after, + limit, + }) + .await + } + + async fn get_pending_invitation( + &self, + family_id: NodeFamilyId, + node_id: NodeId, + ) -> Result { + self.query_node_families_contract(NodeFamiliesQueryMsg::GetPendingInvitation { + family_id, + node_id, + }) + .await + } + + async fn get_pending_invitations_for_family_paged( + &self, + family_id: NodeFamilyId, + start_after: Option, + limit: Option, + ) -> Result { + self.query_node_families_contract( + NodeFamiliesQueryMsg::GetPendingInvitationsForFamilyPaged { + family_id, + start_after, + limit, + }, + ) + .await + } + + async fn get_pending_invitations_for_node_paged( + &self, + node_id: NodeId, + start_after: Option, + limit: Option, + ) -> Result { + self.query_node_families_contract(NodeFamiliesQueryMsg::GetPendingInvitationsForNodePaged { + node_id, + start_after, + limit, + }) + .await + } + + async fn get_all_pending_invitations_paged( + &self, + start_after: Option<(NodeFamilyId, NodeId)>, + limit: Option, + ) -> Result { + self.query_node_families_contract(NodeFamiliesQueryMsg::GetAllPendingInvitationsPaged { + start_after, + limit, + }) + .await + } + + async fn get_past_invitations_for_family_paged( + &self, + family_id: NodeFamilyId, + start_after: Option, + limit: Option, + ) -> Result { + self.query_node_families_contract(NodeFamiliesQueryMsg::GetPastInvitationsForFamilyPaged { + family_id, + start_after, + limit, + }) + .await + } + + async fn get_past_invitations_for_node_paged( + &self, + node_id: NodeId, + start_after: Option, + limit: Option, + ) -> Result { + self.query_node_families_contract(NodeFamiliesQueryMsg::GetPastInvitationsForNodePaged { + node_id, + start_after, + limit, + }) + .await + } + + async fn get_all_past_invitations_paged( + &self, + start_after: Option, + limit: Option, + ) -> Result { + self.query_node_families_contract(NodeFamiliesQueryMsg::GetAllPastInvitationsPaged { + start_after, + limit, + }) + .await + } + + async fn get_past_members_for_family_paged( + &self, + family_id: NodeFamilyId, + start_after: Option, + limit: Option, + ) -> Result { + self.query_node_families_contract(NodeFamiliesQueryMsg::GetPastMembersForFamilyPaged { + family_id, + start_after, + limit, + }) + .await + } + + async fn get_past_members_for_node_paged( + &self, + node_id: NodeId, + start_after: Option, + limit: Option, + ) -> Result { + self.query_node_families_contract(NodeFamiliesQueryMsg::GetPastMembersForNodePaged { + node_id, + start_after, + limit, + }) + .await + } +} + +// extension trait to the query client to deal with the paged queries +// (it didn't feel appropriate to combine it with the existing trait) +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +pub trait PagedNodeFamiliesQueryClient: NodeFamiliesQueryClient { + async fn get_all_families(&self) -> Result, NyxdError> { + collect_paged!(self, get_families_paged, families) + } + + async fn get_all_family_members_for_family( + &self, + family_id: NodeFamilyId, + ) -> Result, NyxdError> { + collect_paged!(self, get_family_members_paged, members, family_id) + } + + async fn get_all_family_members(&self) -> Result, NyxdError> { + collect_paged!(self, get_all_family_members_paged, members) + } + + async fn get_all_pending_invitations_for_family( + &self, + family_id: NodeFamilyId, + ) -> Result, NyxdError> { + collect_paged!( + self, + get_pending_invitations_for_family_paged, + invitations, + family_id + ) + } + + async fn get_all_pending_invitations_for_node( + &self, + node_id: NodeId, + ) -> Result, NyxdError> { + collect_paged!( + self, + get_pending_invitations_for_node_paged, + invitations, + node_id + ) + } + + async fn get_all_pending_invitations( + &self, + ) -> Result, NyxdError> { + collect_paged!(self, get_all_pending_invitations_paged, invitations) + } + + async fn get_all_past_invitations_for_family( + &self, + family_id: NodeFamilyId, + ) -> Result, NyxdError> { + collect_paged!( + self, + get_past_invitations_for_family_paged, + invitations, + family_id + ) + } + + async fn get_all_past_invitations_for_node( + &self, + node_id: NodeId, + ) -> Result, NyxdError> { + collect_paged!( + self, + get_past_invitations_for_node_paged, + invitations, + node_id + ) + } + + async fn get_all_past_invitations(&self) -> Result, NyxdError> { + collect_paged!(self, get_all_past_invitations_paged, invitations) + } + + async fn get_all_past_members_for_family( + &self, + family_id: NodeFamilyId, + ) -> Result, NyxdError> { + collect_paged!(self, get_past_members_for_family_paged, members, family_id) + } + + async fn get_all_past_members_for_node( + &self, + node_id: NodeId, + ) -> Result, NyxdError> { + collect_paged!(self, get_past_members_for_node_paged, members, node_id) + } +} + +#[async_trait] +impl PagedNodeFamiliesQueryClient for T where T: NodeFamiliesQueryClient {} + +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +impl NodeFamiliesQueryClient for C +where + C: CosmWasmClient + NymContractsProvider + Send + Sync, +{ + async fn query_node_families_contract( + &self, + query: NodeFamiliesQueryMsg, + ) -> Result + where + for<'a> T: Deserialize<'a>, + { + let node_families_contract_address = &self + .node_families_contract_address() + .ok_or_else(|| NyxdError::unavailable_contract_address("node families contract"))?; + self.query_contract_smart(node_families_contract_address, &query) + .await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::nyxd::contract_traits::tests::IgnoreValue; + use nym_node_families_contract_common::QueryMsg; + + // it's enough that this compiles and clippy is happy about it + #[allow(dead_code)] + fn all_query_variants_are_covered( + client: C, + msg: NodeFamiliesQueryMsg, + ) { + match msg { + NodeFamiliesQueryMsg::GetFamilyById { family_id } => { + client.get_family_by_id(family_id).ignore() + } + NodeFamiliesQueryMsg::GetFamilyByOwner { owner } => { + client.get_family_by_owner(&owner.parse().unwrap()).ignore() + } + NodeFamiliesQueryMsg::GetFamilyByName { name } => { + client.get_family_by_name(name).ignore() + } + NodeFamiliesQueryMsg::GetFamiliesPaged { start_after, limit } => { + client.get_families_paged(start_after, limit).ignore() + } + NodeFamiliesQueryMsg::GetFamilyMembership { node_id } => { + client.get_family_membership(node_id).ignore() + } + NodeFamiliesQueryMsg::GetFamilyMembersPaged { + family_id, + start_after, + limit, + } => client + .get_family_members_paged(family_id, start_after, limit) + .ignore(), + NodeFamiliesQueryMsg::GetAllFamilyMembersPaged { start_after, limit } => client + .get_all_family_members_paged(start_after, limit) + .ignore(), + NodeFamiliesQueryMsg::GetPendingInvitation { family_id, node_id } => { + client.get_pending_invitation(family_id, node_id).ignore() + } + NodeFamiliesQueryMsg::GetPendingInvitationsForFamilyPaged { + family_id, + start_after, + limit, + } => client + .get_pending_invitations_for_family_paged(family_id, start_after, limit) + .ignore(), + NodeFamiliesQueryMsg::GetPendingInvitationsForNodePaged { + node_id, + start_after, + limit, + } => client + .get_pending_invitations_for_node_paged(node_id, start_after, limit) + .ignore(), + NodeFamiliesQueryMsg::GetAllPendingInvitationsPaged { start_after, limit } => client + .get_all_pending_invitations_paged(start_after, limit) + .ignore(), + NodeFamiliesQueryMsg::GetPastInvitationsForFamilyPaged { + family_id, + start_after, + limit, + } => client + .get_past_invitations_for_family_paged(family_id, start_after, limit) + .ignore(), + NodeFamiliesQueryMsg::GetPastInvitationsForNodePaged { + node_id, + start_after, + limit, + } => client + .get_past_invitations_for_node_paged(node_id, start_after, limit) + .ignore(), + NodeFamiliesQueryMsg::GetAllPastInvitationsPaged { start_after, limit } => client + .get_all_past_invitations_paged(start_after, limit) + .ignore(), + NodeFamiliesQueryMsg::GetPastMembersForFamilyPaged { + family_id, + start_after, + limit, + } => client + .get_past_members_for_family_paged(family_id, start_after, limit) + .ignore(), + QueryMsg::GetPastMembersForNodePaged { + node_id, + start_after, + limit, + } => client + .get_past_members_for_node_paged(node_id, start_after, limit) + .ignore(), + }; + } +} diff --git a/common/client-libs/validator-client/src/nyxd/contract_traits/node_families_signing_client.rs b/common/client-libs/validator-client/src/nyxd/contract_traits/node_families_signing_client.rs new file mode 100644 index 0000000000..34690c8be5 --- /dev/null +++ b/common/client-libs/validator-client/src/nyxd/contract_traits/node_families_signing_client.rs @@ -0,0 +1,254 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::nyxd::coin::Coin; +use crate::nyxd::contract_traits::NymContractsProvider; +use crate::nyxd::cosmwasm_client::types::ExecuteResult; +use crate::nyxd::error::NyxdError; +use crate::nyxd::{Fee, SigningCosmWasmClient}; +use crate::signing::signer::OfflineSigner; +use async_trait::async_trait; +use nym_mixnet_contract_common::NodeId; +use nym_node_families_contract_common::{ + Config, ExecuteMsg as NodeFamiliesExecuteMsg, NodeFamilyId, +}; + +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +pub trait NodeFamiliesSigningClient { + async fn execute_node_families_contract( + &self, + fee: Option, + msg: NodeFamiliesExecuteMsg, + memo: String, + funds: Vec, + ) -> Result; + + async fn update_node_families_config( + &self, + config: Config, + fee: Option, + ) -> Result { + self.execute_node_families_contract( + fee, + NodeFamiliesExecuteMsg::UpdateConfig { config }, + "NodeFamiliesContract::UpdateConfig".to_string(), + vec![], + ) + .await + } + + async fn create_family( + &self, + name: String, + description: String, + fee: Option, + creation_fee: Vec, + ) -> Result { + self.execute_node_families_contract( + fee, + NodeFamiliesExecuteMsg::CreateFamily { name, description }, + "NodeFamiliesContract::CreateFamily".to_string(), + creation_fee, + ) + .await + } + + async fn disband_family(&self, fee: Option) -> Result { + self.execute_node_families_contract( + fee, + NodeFamiliesExecuteMsg::DisbandFamily {}, + "NodeFamiliesContract::DisbandFamily".to_string(), + vec![], + ) + .await + } + + async fn invite_to_family( + &self, + node_id: NodeId, + validity_secs: Option, + fee: Option, + ) -> Result { + self.execute_node_families_contract( + fee, + NodeFamiliesExecuteMsg::InviteToFamily { + node_id, + validity_secs, + }, + "NodeFamiliesContract::InviteToFamily".to_string(), + vec![], + ) + .await + } + + async fn revoke_family_invitation( + &self, + node_id: NodeId, + fee: Option, + ) -> Result { + self.execute_node_families_contract( + fee, + NodeFamiliesExecuteMsg::RevokeFamilyInvitation { node_id }, + "NodeFamiliesContract::RevokeFamilyInvitation".to_string(), + vec![], + ) + .await + } + + async fn accept_family_invitation( + &self, + family_id: NodeFamilyId, + node_id: NodeId, + fee: Option, + ) -> Result { + self.execute_node_families_contract( + fee, + NodeFamiliesExecuteMsg::AcceptFamilyInvitation { family_id, node_id }, + "NodeFamiliesContract::AcceptFamilyInvitation".to_string(), + vec![], + ) + .await + } + + async fn reject_family_invitation( + &self, + family_id: NodeFamilyId, + node_id: NodeId, + fee: Option, + ) -> Result { + self.execute_node_families_contract( + fee, + NodeFamiliesExecuteMsg::RejectFamilyInvitation { family_id, node_id }, + "NodeFamiliesContract::RejectFamilyInvitation".to_string(), + vec![], + ) + .await + } + + async fn leave_family( + &self, + node_id: NodeId, + fee: Option, + ) -> Result { + self.execute_node_families_contract( + fee, + NodeFamiliesExecuteMsg::LeaveFamily { node_id }, + "NodeFamiliesContract::LeaveFamily".to_string(), + vec![], + ) + .await + } + + async fn kick_from_family( + &self, + node_id: NodeId, + fee: Option, + ) -> Result { + self.execute_node_families_contract( + fee, + NodeFamiliesExecuteMsg::KickFromFamily { node_id }, + "NodeFamiliesContract::KickFromFamily".to_string(), + vec![], + ) + .await + } + + /// Cross-contract callback fired by the mixnet contract on node unbonding. + /// Exposed for completeness; the families contract rejects this call from + /// any sender other than the configured mixnet contract address. + async fn on_nym_node_unbond( + &self, + node_id: NodeId, + fee: Option, + ) -> Result { + self.execute_node_families_contract( + fee, + NodeFamiliesExecuteMsg::OnNymNodeUnbond { node_id }, + "NodeFamiliesContract::OnNymNodeUnbond".to_string(), + vec![], + ) + .await + } +} + +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +impl NodeFamiliesSigningClient for C +where + C: SigningCosmWasmClient + NymContractsProvider + Sync, + NyxdError: From<::Error>, +{ + async fn execute_node_families_contract( + &self, + fee: Option, + msg: NodeFamiliesExecuteMsg, + memo: String, + funds: Vec, + ) -> Result { + let node_families_contract_address = &self + .node_families_contract_address() + .ok_or_else(|| NyxdError::unavailable_contract_address("node families contract"))?; + + let fee = fee.unwrap_or(Fee::Auto(Some(self.simulated_gas_multiplier()))); + + let signer_address = &self.signer_addresses()[0]; + self.execute( + signer_address, + node_families_contract_address, + &msg, + fee, + memo, + funds, + ) + .await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::nyxd::contract_traits::tests::IgnoreValue; + use nym_node_families_contract_common::ExecuteMsg; + + // it's enough that this compiles and clippy is happy about it + #[allow(dead_code)] + fn all_execute_variants_are_covered( + client: C, + msg: NodeFamiliesExecuteMsg, + ) { + match msg { + NodeFamiliesExecuteMsg::UpdateConfig { config } => { + client.update_node_families_config(config, None).ignore() + } + NodeFamiliesExecuteMsg::CreateFamily { name, description } => client + .create_family(name, description, None, vec![]) + .ignore(), + NodeFamiliesExecuteMsg::DisbandFamily {} => client.disband_family(None).ignore(), + NodeFamiliesExecuteMsg::InviteToFamily { + node_id, + validity_secs, + } => client + .invite_to_family(node_id, validity_secs, None) + .ignore(), + NodeFamiliesExecuteMsg::RevokeFamilyInvitation { node_id } => { + client.revoke_family_invitation(node_id, None).ignore() + } + NodeFamiliesExecuteMsg::AcceptFamilyInvitation { family_id, node_id } => client + .accept_family_invitation(family_id, node_id, None) + .ignore(), + NodeFamiliesExecuteMsg::RejectFamilyInvitation { family_id, node_id } => client + .reject_family_invitation(family_id, node_id, None) + .ignore(), + NodeFamiliesExecuteMsg::LeaveFamily { node_id } => { + client.leave_family(node_id, None).ignore() + } + NodeFamiliesExecuteMsg::KickFromFamily { node_id } => { + client.kick_from_family(node_id, None).ignore() + } + ExecuteMsg::OnNymNodeUnbond { node_id } => { + client.on_nym_node_unbond(node_id, None).ignore() + } + }; + } +} diff --git a/common/client-libs/validator-client/src/nyxd/mod.rs b/common/client-libs/validator-client/src/nyxd/mod.rs index ed01097ee9..86c0f347ae 100644 --- a/common/client-libs/validator-client/src/nyxd/mod.rs +++ b/common/client-libs/validator-client/src/nyxd/mod.rs @@ -304,6 +304,10 @@ impl NyxdClient { self.config.contracts.multisig_contract_address = Some(address); } + pub fn set_node_families_contract_address(&mut self, address: AccountId) { + self.config.contracts.node_families_contract_address = Some(address); + } + pub fn set_simulated_gas_multiplier(&mut self, multiplier: f32) { self.config.simulated_gas_multiplier = multiplier; } @@ -332,6 +336,13 @@ impl NymContractsProvider for NyxdClient { .as_ref() } + fn node_families_contract_address(&self) -> Option<&AccountId> { + self.config + .contracts + .node_families_contract_address + .as_ref() + } + fn ecash_contract_address(&self) -> Option<&AccountId> { self.config.contracts.ecash_contract_address.as_ref() } diff --git a/common/commands/src/validator/cosmwasm/generators/mixnet.rs b/common/commands/src/validator/cosmwasm/generators/mixnet.rs index 9226a02653..b8e561f5ed 100644 --- a/common/commands/src/validator/cosmwasm/generators/mixnet.rs +++ b/common/commands/src/validator/cosmwasm/generators/mixnet.rs @@ -30,6 +30,9 @@ pub struct Args { #[clap(long)] pub vesting_contract_address: Option, + #[clap(long)] + pub node_families_contract_address: Option, + #[clap(long)] pub rewarding_denom: Option, @@ -130,6 +133,14 @@ pub async fn generate(args: Args) { .expect("Failed converting vesting contract address to AccountId") }); + let node_families_contract_address = args.node_families_contract_address.unwrap_or_else(|| { + let address = + std::env::var(nym_network_defaults::var_names::NODE_FAMILIES_CONTRACT_ADDRESS) + .expect("node families contract address has to be set"); + AccountId::from_str(address.as_str()) + .expect("Failed converting node families contract address to AccountId") + }); + let rewarding_denom = args.rewarding_denom.unwrap_or_else(|| { std::env::var(nym_network_defaults::var_names::MIX_DENOM) .expect("Rewarding (mix) denom has to be set") @@ -142,6 +153,7 @@ pub async fn generate(args: Args) { let instantiate_msg = InstantiateMsg { rewarding_validator_address: rewarding_validator_address.to_string(), vesting_contract_address: vesting_contract_address.to_string(), + node_families_contract_address: node_families_contract_address.to_string(), rewarding_denom, epochs_in_interval: args.epochs_in_interval, epoch_duration: Duration::from_secs(args.epoch_duration), diff --git a/common/cosmwasm-smart-contracts/contracts-common-testing/src/tester/basic_traits.rs b/common/cosmwasm-smart-contracts/contracts-common-testing/src/tester/basic_traits.rs index facc2983de..f543f019be 100644 --- a/common/cosmwasm-smart-contracts/contracts-common-testing/src/tester/basic_traits.rs +++ b/common/cosmwasm-smart-contracts/contracts-common-testing/src/tester/basic_traits.rs @@ -26,6 +26,14 @@ pub trait ContractOpts { fn addr_make(&self, input: &str) -> Addr; + fn make_sender_with_funds(&self, input: &str, funds: &[Coin]) -> MessageInfo { + message_info(&self.addr_make(input), funds) + } + + fn make_sender(&self, input: &str) -> MessageInfo { + self.make_sender_with_funds(input, &[]) + } + fn deps_mut_env(&mut self) -> (DepsMut<'_>, Env) { let env = self.env().clone(); (self.deps_mut(), env) diff --git a/common/cosmwasm-smart-contracts/ecash-contract/src/blacklist.rs b/common/cosmwasm-smart-contracts/ecash-contract/src/blacklist.rs index 00b6dfc6ae..17473c2660 100644 --- a/common/cosmwasm-smart-contracts/ecash-contract/src/blacklist.rs +++ b/common/cosmwasm-smart-contracts/ecash-contract/src/blacklist.rs @@ -1,8 +1,14 @@ // Copyright 2022 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 +//! Blacklist types. **The blacklist surface is stubbed today** - the execute +//! handlers always return `UnimplementedBlacklisting` and the storage map is +//! never populated. These types are kept for the planned redesign. + use cosmwasm_schema::cw_serde; +/// Public-key + metadata pair surfaced by `GetBlacklistedAccount` / +/// `GetBlacklistPaged`. Always empty on a freshly deployed contract. #[cw_serde] pub struct BlacklistedAccount { pub public_key: String, @@ -15,6 +21,8 @@ impl From<(String, Blacklisting)> for BlacklistedAccount { } } +/// Per-key blacklist record: the multisig proposal that approved it and the +/// block height at which finalisation landed (None until finalised). #[cw_serde] pub struct Blacklisting { pub proposal_id: u64, @@ -36,6 +44,8 @@ impl BlacklistedAccount { } } +/// Page of blacklist entries returned by `GetBlacklistPaged`. Always empty on +/// a freshly deployed contract. #[cw_serde] pub struct PagedBlacklistedAccountResponse { pub accounts: Vec, @@ -59,6 +69,8 @@ impl PagedBlacklistedAccountResponse { } } +/// Response shape for `GetBlacklistedAccount`. `account` is `None` for any +/// key not present in the (currently always-empty) blacklist. #[cw_serde] pub struct BlacklistedAccountResponse { pub account: Option, diff --git a/common/cosmwasm-smart-contracts/ecash-contract/src/counters.rs b/common/cosmwasm-smart-contracts/ecash-contract/src/counters.rs index 48ee6766bd..9212f9217f 100644 --- a/common/cosmwasm-smart-contracts/ecash-contract/src/counters.rs +++ b/common/cosmwasm-smart-contracts/ecash-contract/src/counters.rs @@ -4,6 +4,9 @@ use cosmwasm_schema::cw_serde; use cosmwasm_std::Coin; +/// Pool-level deposit accounting. Updated by every successful +/// `DepositTicketBookFunds` and (for the unredeemed-tickets counter) by every +/// successful legacy `RedeemTickets`. #[cw_serde] pub struct PoolCounters { /// Represents the total amount of funds deposited into the contract. diff --git a/common/cosmwasm-smart-contracts/ecash-contract/src/deposit.rs b/common/cosmwasm-smart-contracts/ecash-contract/src/deposit.rs index 6992e87a0c..6457f24328 100644 --- a/common/cosmwasm-smart-contracts/ecash-contract/src/deposit.rs +++ b/common/cosmwasm-smart-contracts/ecash-contract/src/deposit.rs @@ -5,8 +5,13 @@ use crate::error::EcashContractError; use cosmwasm_schema::cw_serde; use cosmwasm_std::{StdError, StdResult}; +/// Sequential identifier assigned to every accepted deposit. Starts at 0 and +/// is never recycled. pub type DepositId = u32; +/// Opaque on-chain record of a deposit: the depositor-claimed bs58-encoded +/// ed25519 identity public key. The contract does not verify control of the +/// corresponding private key. #[cw_serde] pub struct Deposit { pub bs58_encoded_ed25519_pubkey: String, @@ -19,6 +24,8 @@ impl Deposit { } } + /// Decode a bs58-encoded ed25519 public key to its 32-byte raw form. + /// Surfaces `MalformedEd25519Identity` on any bs58 / length failure. pub fn get_ed25519_pubkey_bytes(raw: &str) -> Result<[u8; 32], EcashContractError> { let mut ed25519_pubkey_bytes = [0u8; 32]; bs58::decode(raw) @@ -32,10 +39,13 @@ impl Deposit { bs58::encode(raw).into_string() } + /// Decode this deposit's identity key to its 32-byte raw form for storage. pub fn to_bytes(&self) -> Result<[u8; 32], EcashContractError> { Self::get_ed25519_pubkey_bytes(&self.bs58_encoded_ed25519_pubkey) } + /// Reconstruct a `Deposit` from a raw 32-byte ed25519 pubkey as stored + /// under the `"deposit"` namespace. pub fn try_from_bytes(bytes: &[u8]) -> StdResult { if bytes.len() != 32 { return Err(StdError::generic_err("malformed deposit data")); @@ -47,12 +57,16 @@ impl Deposit { } } +/// Response shape for `GetLatestDeposit`. `deposit` is `None` on a freshly +/// deployed contract. #[cw_serde] #[derive(Default)] pub struct LatestDepositResponse { pub deposit: Option, } +/// Response shape for `GetDeposit { deposit_id }`. `deposit` is `None` when +/// the id has not yet been assigned (`id >= total_deposits_made`). #[cw_serde] pub struct DepositResponse { pub id: DepositId, @@ -60,6 +74,8 @@ pub struct DepositResponse { pub deposit: Option, } +/// `(deposit_id, deposit)` pair surfaced by the latest-deposit and paginated +/// deposit queries. #[cw_serde] pub struct DepositData { pub id: DepositId, @@ -73,6 +89,8 @@ impl From<(DepositId, Deposit)> for DepositData { } } +/// Page of deposits returned by `GetDepositsPaged`. `start_next_after` is the +/// id of the last returned entry; pass it as the next call's `start_after`. #[cw_serde] pub struct PagedDepositsResponse { pub deposits: Vec, diff --git a/common/cosmwasm-smart-contracts/ecash-contract/src/error.rs b/common/cosmwasm-smart-contracts/ecash-contract/src/error.rs index 9fd94ab81a..e9484ad545 100644 --- a/common/cosmwasm-smart-contracts/ecash-contract/src/error.rs +++ b/common/cosmwasm-smart-contracts/ecash-contract/src/error.rs @@ -6,69 +6,108 @@ use cw_controllers::AdminError; use cw_utils::PaymentError; use thiserror::Error; +/// Errors surfaced by the ecash contract. Each reachable variant is named in at +/// least one scenario of `openspec/specs/ecash-contract/spec.md`. #[derive(Error, Debug, PartialEq)] pub enum EcashContractError { + /// Wrapper for any underlying `cosmwasm_std::StdError` (storage faults, + /// address validation, etc.). #[error(transparent)] Std(#[from] StdError), + /// Raised by `cw_utils::must_pay` on `DepositTicketBookFunds` when funds + /// are missing, multi-denom, or in the wrong denom. Inner variants + /// `NoFunds`, `MultipleDenoms`, `MissingDenom` are all reachable. #[error("Invalid deposit")] InvalidDeposit(#[from] PaymentError), + /// `DepositTicketBookFunds` with the right denom but a non-matching amount. + /// `amount` is the reduced amount (if the sender is whitelisted) or the + /// default amount. #[error("received wrong amount for deposit. got: {received}. required: {amount}")] WrongAmount { received: Coin, amount: Coin }, + /// **Unreachable** - preserved for forward compatibility (no current + /// execute path triggers this). #[error("There aren't enough funds in the contract")] NotEnoughFunds, + /// Wrapper for `cw_controllers::AdminError`. Raised by every admin-gated + /// and multisig-gated handler when the sender is wrong. #[error(transparent)] Admin(#[from] AdminError), + /// Redemption-proposal reply could not find a `proposal_id` attribute on + /// the multisig `wasm` event. #[error("could not find proposal id inside the multisig reply SubMsg")] MissingProposalId, - // realistically this should NEVER be thrown + /// Redemption-proposal reply found a `proposal_id` attribute that could + /// not be parsed as `u64`. Realistically unreachable. #[error("the proposal id returned by the multisig contract could not be parsed into an u64")] MalformedProposalId, + /// Instantiation given a `group_addr` that failed bech32 validation. #[error("Group contract invalid address '{addr}'")] InvalidGroup { addr: String }, + /// **Unreachable** - no current execute path triggers this. #[error("Unauthorized")] Unauthorized, + /// **Unreachable** - preserved for future SemVer comparisons during migration. #[error("Failed to parse {value} into a valid SemVer version: {error_message}")] SemVerFailure { value: String, error_message: String, }, + /// Reply dispatcher saw an `id` that does not match + /// `BLACKLIST_PROPOSAL_REPLY_ID` or `REDEMPTION_PROPOSAL_REPLY_ID`. #[error("received an invalid reply id: {id}. it does not correspond to any sent SubMsg")] InvalidReplyId { id: u64 }, + /// **Unreachable** - preserved for the (future) typed-deposit-info feature. #[error("reached the maximum of 255 different deposit types")] MaximumDepositTypesReached, + /// **Unreachable** - preserved for the (future) typed-deposit-info feature. #[error("compressed deposit info {typ} does not corresponds to any known type")] UnknownCompressedDepositInfoType { typ: u8 }, + /// **Unreachable** - preserved for the (future) typed-deposit-info feature. #[error("deposit info {typ} does not corresponds to any previously seen type")] UnknownDepositInfoType { typ: String }, + /// `DepositTicketBookFunds` with an `identity_key` that fails to bs58-decode + /// to exactly 32 bytes. Raised inside `Deposit::to_bytes` during + /// `save_deposit`. #[error("the provided ed25519 identity was malformed")] MalformedEd25519Identity, + /// `nym_network_defaults::TICKETBOOK_SIZE` has diverged from the value + /// snapshotted at instantiation in `Item`. Tripwire for + /// uncoordinated network-defaults bumps. #[error("the ticket book size has changed since the contract was created! This was not expected! It used to be {at_init} but it's {current} now! Please let the developers know ASAP!")] TicketBookSizeChanged { at_init: u64, current: u64 }, + /// `RequestRedemption` with a `commitment_bs58` that does not decode to a + /// 32-byte sha256 digest. #[error("the provided tickets redemption commitment is malformed")] MalformedRedemptionCommitment, + /// Always thrown by `ProposeToBlacklist` and `AddToBlacklist` until the + /// blacklist redesign lands. #[error("the account blacklisting hasn't been fully implemented yet")] UnimplementedBlacklisting, + /// `SetReducedDepositPrice` (or migration whitelist seeding) given a coin + /// whose denom does not match `Config::deposit_amount.denom`. #[error("reduced deposit must use the same denom as the default deposit (expected '{expected}', got '{got}')")] InvalidReducedDepositDenom { expected: String, got: String }, + /// `SetReducedDepositPrice` (or migration whitelist seeding) given a + /// reduced amount not strictly less than the current default. #[error( "reduced deposit amount ({reduced}) must be strictly less than the default ({default})" )] @@ -77,9 +116,13 @@ pub enum EcashContractError { default: cosmwasm_std::Uint128, }, + /// `RemoveReducedDepositPrice` invoked for an address with no current + /// reduced-deposit entry. #[error("address '{address}' does not have a custom reduced deposit price set")] NoReducedDepositPrice { address: String }, + /// `UpdateDefaultDepositValue` or `SetReducedDepositPrice` given an amount + /// below `nym_network_defaults::TICKETBOOK_SIZE`. #[error( "deposit amount ({amount}) must be at least the ticket book size ({ticket_book_size})" )] diff --git a/common/cosmwasm-smart-contracts/ecash-contract/src/event_attributes.rs b/common/cosmwasm-smart-contracts/ecash-contract/src/event_attributes.rs index 80f5daf68a..8d50174468 100644 --- a/common/cosmwasm-smart-contracts/ecash-contract/src/event_attributes.rs +++ b/common/cosmwasm-smart-contracts/ecash-contract/src/event_attributes.rs @@ -1,4 +1,7 @@ // Copyright 2022 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 +/// Duplicate of `events::PROPOSAL_ID_ATTRIBUTE_NAME`. **Dead code**: not +/// referenced anywhere in the workspace today; preserved here pending a +/// follow-on cleanup. Use `events::PROPOSAL_ID_ATTRIBUTE_NAME` instead. pub const BANDWIDTH_PROPOSAL_ID: &str = "proposal_id"; diff --git a/common/cosmwasm-smart-contracts/ecash-contract/src/events.rs b/common/cosmwasm-smart-contracts/ecash-contract/src/events.rs index a1f243b151..5e8f7d2db0 100644 --- a/common/cosmwasm-smart-contracts/ecash-contract/src/events.rs +++ b/common/cosmwasm-smart-contracts/ecash-contract/src/events.rs @@ -1,9 +1,21 @@ // Copyright 2021 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 -// event types +//! Event names and attribute keys emitted by the ecash contract. Renaming any +//! of these is a breaking change for indexers and downstream tooling. + +/// Event type emitted by every successful `DepositTicketBookFunds`. Carries a +/// single `deposit-id` attribute with the assigned id as a decimal string. pub const DEPOSITED_FUNDS_EVENT_TYPE: &str = "deposited-funds"; +/// Attribute key on the `deposited-funds` event: the newly assigned deposit id. pub const DEPOSIT_ID: &str = "deposit-id"; + +/// Name of the cosmwasm-std auto-generated event that carries handler +/// attributes (`updated_deposit`, `action`, `address`, `deposit`, +/// `proposal_id`). pub const WASM_EVENT_NAME: &str = "wasm"; + +/// Attribute key carrying the multisig-issued `proposal_id` on the `wasm` +/// event from the redemption-proposal reply handler. pub const PROPOSAL_ID_ATTRIBUTE_NAME: &str = "proposal_id"; diff --git a/common/cosmwasm-smart-contracts/ecash-contract/src/lib.rs b/common/cosmwasm-smart-contracts/ecash-contract/src/lib.rs index 5495094d70..e09ea9a9da 100644 --- a/common/cosmwasm-smart-contracts/ecash-contract/src/lib.rs +++ b/common/cosmwasm-smart-contracts/ecash-contract/src/lib.rs @@ -1,6 +1,12 @@ // Copyright 2024 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 +//! Shared types, messages, events, and errors for the ecash contract. +//! +//! Consumed by both the contract crate (`contracts/ecash`) and any off-chain +//! client (gateways, nym-api signers, indexers, validator-client). See +//! `openspec/specs/ecash-contract/spec.md` for the normative interface. + pub mod blacklist; pub mod counters; pub mod deposit; diff --git a/common/cosmwasm-smart-contracts/ecash-contract/src/msg.rs b/common/cosmwasm-smart-contracts/ecash-contract/src/msg.rs index 7ba734c294..fe37486234 100644 --- a/common/cosmwasm-smart-contracts/ecash-contract/src/msg.rs +++ b/common/cosmwasm-smart-contracts/ecash-contract/src/msg.rs @@ -15,100 +15,134 @@ use crate::reduced_deposit::WhitelistedAccountsResponse; #[cfg(feature = "schema")] use cosmwasm_schema::QueryResponses; +/// Instantiation payload. The sender of the instantiate transaction becomes the +/// contract admin; the three addresses are bech32-validated and persisted as +/// immutable cross-contract pointers (see spec requirement "Contract instantiation"). #[cw_serde] pub struct InstantiateMsg { + /// Cosmos SDK address reserved for the future pool-contract transition. + /// Stored in `Config` but never debited by the current contract. pub holding_account: String, + + /// cw3 multisig contract that gates `RedeemTickets` and (in the redesign) + /// blacklist proposals. Not updatable through any execute path. pub multisig_addr: String, + + /// cw4 group contract referenced by the (stubbed) blacklist proposal flow. pub group_addr: String, + + /// Default per-deposit price. The denom of this coin is the contract's + /// canonical denom for the rest of its lifetime. pub deposit_amount: Coin, } #[cw_serde] pub enum ExecuteMsg { - /// Used by clients to request ticket books from the signers - DepositTicketBookFunds { - identity_key: String, - }, + /// Submitted by clients to escrow funds and register a claimed ed25519 + /// identity key. Mints a sequential `deposit_id`. The contract does not + /// verify control of the identity key - that proof is checked off-chain by + /// nym-api signers at blind-sign time. + DepositTicketBookFunds { identity_key: String }, - /// Used by gateways to batch redeem tokens from the spent tickets + /// Submitted by gateways to request batch redemption of spent tickets. + /// Dispatches a `Propose` SubMsg to the multisig contract; the actual + /// transfer effect is gated behind multisig approval. RequestRedemption { commitment_bs58: String, number_of_tickets: u16, }, - /// The actual message that gets executed, after multisig votes, that transfers the ticket tokens into gateway's (and the holding) account - RedeemTickets { - n: u16, - gw: String, - }, + /// **Legacy / dead code.** Only callable by the multisig; bumps the + /// unredeemed-tickets counter and emits a `ticket_redemption` event with + /// `moved_to_holding_account = "false"`. No known consumer depends on the + /// side effects; candidate for removal in a follow-on breaking-schema + /// change. + RedeemTickets { n: u16, gw: String }, - UpdateAdmin { - admin: String, - }, + /// Transfer the contract admin role. Only the current admin may sign. + /// Dispatches via the cw_controllers `execute_update_admin` handshake. + UpdateAdmin { admin: String }, + /// Overwrite `Config::deposit_amount`. Only callable by the contract admin. + /// Rejects values below `nym_network_defaults::TICKETBOOK_SIZE` and trips + /// `TicketBookSizeChanged` if the snapshotted invariant has diverged from + /// the current crate constant. #[serde(alias = "update_deposit_value")] - UpdateDefaultDepositValue { - new_deposit: Coin, - }, + 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, - }, + 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, - }, + RemoveReducedDepositPrice { address: String }, - // TODO: properly implement - ProposeToBlacklist { - public_key: String, - }, - AddToBlacklist { - public_key: String, - }, + /// **Stubbed**: always returns `EcashContractError::UnimplementedBlacklisting`. + /// Storage, reply handler, and helper paths exist but are unreachable from + /// the public ExecuteMsg surface. Preserved for the redesign. + ProposeToBlacklist { public_key: String }, + + /// **Stubbed**: always returns `EcashContractError::UnimplementedBlacklisting`. + AddToBlacklist { public_key: String }, } #[cw_serde] #[cfg_attr(feature = "schema", derive(QueryResponses))] pub enum QueryMsg { + /// Look up a blacklist entry by its bs58-encoded ed25519 public key. Always + /// returns `None` on a freshly deployed contract because the blacklist + /// execute paths are stubbed. #[cfg_attr(feature = "schema", returns(BlacklistedAccountResponse))] GetBlacklistedAccount { public_key: String }, + /// Paginated listing of blacklist entries. Always empty today (see stubbed + /// blacklist surface). Defaults: limit 50, max 75. #[cfg_attr(feature = "schema", returns(PagedBlacklistedAccountResponse))] GetBlacklistPaged { limit: Option, start_after: Option, }, + /// Default per-deposit price (`Config::deposit_amount`). The + /// `GetRequiredDepositAmount` aliases are kept for backwards compatibility. #[cfg_attr(feature = "schema", returns(Coin))] #[serde(alias = "get_required_deposit_amount")] #[serde(alias = "GetRequiredDepositAmount")] GetDefaultDepositAmount {}, + /// Per-address reduced deposit price override, if any. `None` for any + /// non-whitelisted address. #[cfg_attr(feature = "schema", returns(Option))] GetReducedDepositAmount { address: String }, + /// Enumerate every reduced-deposit whitelist entry in ascending address + /// order. Unpaginated by design (the whitelist is expected to stay small). #[cfg_attr(feature = "schema", returns(WhitelistedAccountsResponse))] GetAllWhitelistedAccounts {}, + /// Look up a deposit by id. Returns `{ id, deposit: None }` when the id has + /// not yet been assigned. #[cfg_attr(feature = "schema", returns(DepositResponse))] GetDeposit { deposit_id: u32 }, + /// Most recently assigned deposit (or `{ deposit: None }` on a fresh + /// contract). See `DepositStorage::latest_deposit`. #[cfg_attr(feature = "schema", returns(LatestDepositResponse))] GetLatestDeposit {}, + /// Paginated listing of deposits in ascending id order. Defaults: limit 50, + /// max 100. #[cfg_attr(feature = "schema", returns(PagedDepositsResponse))] GetDepositsPaged { limit: Option, start_after: Option, }, + /// Aggregate statistics: global totals + per-account custom-price + /// breakdowns. Reassembled in a single read pass from `PoolCounters` and + /// `DepositStatsStorage`. #[cfg_attr(feature = "schema", returns(DepositsStatistics))] GetDepositsStatistics {}, } diff --git a/common/cosmwasm-smart-contracts/ecash-contract/src/redeem_credential.rs b/common/cosmwasm-smart-contracts/ecash-contract/src/redeem_credential.rs index 76bf86f5a8..d6117e816a 100644 --- a/common/cosmwasm-smart-contracts/ecash-contract/src/redeem_credential.rs +++ b/common/cosmwasm-smart-contracts/ecash-contract/src/redeem_credential.rs @@ -1,5 +1,8 @@ // Copyright 2024 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 +/// Title used for the cw3 `Propose` message dispatched by `RequestRedemption`. +/// nym-api signers cross-check this exact string when validating that an +/// in-flight proposal originated from the ecash contract. // TODO: to be moved to multisig pub const BATCH_REDEMPTION_PROPOSAL_TITLE: &str = "ecash-redemption"; diff --git a/common/cosmwasm-smart-contracts/ecash-contract/src/reduced_deposit.rs b/common/cosmwasm-smart-contracts/ecash-contract/src/reduced_deposit.rs index 95f7355e24..8d78ac328f 100644 --- a/common/cosmwasm-smart-contracts/ecash-contract/src/reduced_deposit.rs +++ b/common/cosmwasm-smart-contracts/ecash-contract/src/reduced_deposit.rs @@ -4,12 +4,16 @@ use cosmwasm_schema::cw_serde; use cosmwasm_std::{Addr, Coin}; +/// Whitelist entry: an address and the reduced deposit price it may pay. +/// Persisted in the `"reduced_deposits"` storage map. #[cw_serde] pub struct WhitelistedAccount { pub address: Addr, pub deposit: Coin, } +/// Response shape for `GetAllWhitelistedAccounts`. Unpaginated - the whitelist +/// is expected to stay small. #[cw_serde] pub struct WhitelistedAccountsResponse { pub whitelisted_accounts: Vec, diff --git a/common/cosmwasm-smart-contracts/mixnet-contract/src/helpers.rs b/common/cosmwasm-smart-contracts/mixnet-contract/src/helpers.rs index dc56386238..43dc8eb6be 100644 --- a/common/cosmwasm-smart-contracts/mixnet-contract/src/helpers.rs +++ b/common/cosmwasm-smart-contracts/mixnet-contract/src/helpers.rs @@ -3,12 +3,121 @@ use crate::error::MixnetContractError; use crate::mixnode::PendingMixNodeChanges; +use crate::nym_node::NodeOwnershipResponse; use crate::{ - EpochEventId, IntervalEventId, MixNodeBond, MixNodeDetails, NodeId, NodeRewarding, NymNodeBond, - NymNodeDetails, PendingNodeChanges, + EpochEventId, EpochId, Interval, IntervalEventId, MixNodeBond, MixNodeDetails, NodeId, + NodeRewarding, NymNodeBond, NymNodeDetails, PendingNodeChanges, QueryMsg, }; -use cosmwasm_std::{Coin, Decimal, StdError, StdResult, Uint128}; +use cosmwasm_std::{ + Addr, Binary, Coin, CustomQuery, Decimal, QuerierWrapper, StdError, StdResult, Uint128, + from_json, +}; +use cw_storage_plus::{Key, Namespace, Path, PrimaryKey}; use nym_contracts_common::IdentityKeyRef; +use serde::de::DeserializeOwned; +use std::ops::Deref; + +pub trait MixnetContractQuerier { + #[allow(dead_code)] + fn query_mixnet_contract( + &self, + address: impl Into, + msg: &QueryMsg, + ) -> StdResult; + + fn query_mixnet_contract_storage( + &self, + address: impl Into, + key: impl Into, + ) -> StdResult>>; + + fn query_mixnet_contract_storage_value( + &self, + address: impl Into, + key: impl Into, + ) -> StdResult> { + match self.query_mixnet_contract_storage(address, key)? { + None => Ok(None), + Some(value) => Ok(Some(from_json(&value)?)), + } + } + + fn query_current_mixnet_interval(&self, address: impl Into) -> StdResult { + self.query_mixnet_contract_storage_value(address, b"ci")? + .ok_or(StdError::not_found( + "unable to retrieve interval information from the mixnet contract storage", + )) + } + + fn query_current_absolute_mixnet_epoch_id( + &self, + address: impl Into, + ) -> StdResult { + self.query_current_mixnet_interval(address) + .map(|interval| interval.current_epoch_absolute_id()) + } + + fn check_node_existence(&self, address: impl Into, node_id: NodeId) -> StdResult { + let mixnet_contract_address = address.into(); + + if let Some(nym_node) = self.query_nymnode_bond(mixnet_contract_address.clone(), node_id)? { + return Ok(!nym_node.is_unbonding); + } + + Ok(false) + } + + fn query_nymnode_bond( + &self, + address: impl Into, + node_id: NodeId, + ) -> StdResult> { + // construct proper map key + let pk_namespace = "nn"; + let path: Path = Path::new( + Namespace::from_static_str(pk_namespace).as_slice(), + &node_id.key().iter().map(Key::as_ref).collect::>(), + ); + let storage_key = path.deref(); + + self.query_mixnet_contract_storage_value(address, storage_key) + } + + fn query_nymnode_ownership( + &self, + address: impl Into, + owner: &Addr, + ) -> StdResult> { + let resp: NodeOwnershipResponse = self.query_mixnet_contract( + address, + &QueryMsg::GetOwnedNymNode { + address: owner.to_string(), + }, + )?; + Ok(resp.details.map(|d| d.bond_information)) + } +} + +impl MixnetContractQuerier for QuerierWrapper<'_, C> +where + C: CustomQuery, +{ + fn query_mixnet_contract( + &self, + address: impl Into, + msg: &QueryMsg, + ) -> StdResult { + self.query_wasm_smart(address, msg) + } + + fn query_mixnet_contract_storage( + &self, + address: impl Into, + key: impl Into, + ) -> StdResult>> { + self.query_wasm_raw(address, key) + } +} #[track_caller] pub fn compare_decimals(a: Decimal, b: Decimal, epsilon: Option) { diff --git a/common/cosmwasm-smart-contracts/mixnet-contract/src/lib.rs b/common/cosmwasm-smart-contracts/mixnet-contract/src/lib.rs index 3c6f4b6b08..ac084abb6c 100644 --- a/common/cosmwasm-smart-contracts/mixnet-contract/src/lib.rs +++ b/common/cosmwasm-smart-contracts/mixnet-contract/src/lib.rs @@ -30,6 +30,7 @@ pub use gateway::{ Gateway, GatewayBond, GatewayBondResponse, GatewayConfigUpdate, GatewayOwnershipResponse, PagedGatewayResponse, }; +pub use helpers::MixnetContractQuerier; pub use interval::{ CurrentIntervalResponse, EpochId, EpochState, EpochStatus, Interval, IntervalId, }; diff --git a/common/cosmwasm-smart-contracts/mixnet-contract/src/mixnode.rs b/common/cosmwasm-smart-contracts/mixnet-contract/src/mixnode.rs index 66f7d05113..a0f55f108c 100644 --- a/common/cosmwasm-smart-contracts/mixnet-contract/src/mixnode.rs +++ b/common/cosmwasm-smart-contracts/mixnet-contract/src/mixnode.rs @@ -190,6 +190,10 @@ impl NodeRewarding { truncate_reward(self.operator, denom) } + pub fn delegations_with_reward(&self, denom: impl Into) -> Coin { + truncate_reward(self.delegates, denom) + } + pub fn pending_delegator_reward(&self, delegation: &Delegation) -> StdResult { let delegator_reward = self.determine_delegation_reward(delegation)?; Ok(truncate_reward(delegator_reward, &delegation.amount.denom)) diff --git a/common/cosmwasm-smart-contracts/mixnet-contract/src/msg.rs b/common/cosmwasm-smart-contracts/mixnet-contract/src/msg.rs index 4b0d31016d..a6cc8a8c77 100644 --- a/common/cosmwasm-smart-contracts/mixnet-contract/src/msg.rs +++ b/common/cosmwasm-smart-contracts/mixnet-contract/src/msg.rs @@ -63,6 +63,7 @@ use nym_contracts_common::{ContractBuildInformation, signing::Nonce}; pub struct InstantiateMsg { pub rewarding_validator_address: String, pub vesting_contract_address: String, + pub node_families_contract_address: String, pub rewarding_denom: String, pub epochs_in_interval: u32, @@ -305,6 +306,22 @@ pub enum ExecuteMsg { MigrateVestedDelegation { mix_id: NodeId, }, + /// Admin-only: forcibly migrate the vested mixnode owned by `owner`. + /// Used to drain the last vested entries so the mixnet contract can drop its dependency on the vesting contract. + AdminMigrateVestedMixNode { + owner: String, + }, + /// Admin-only: forcibly migrate the vested delegation `(mix_id, owner)`. + /// Used to drain the last vested entries so the mixnet contract can drop its dependency on the vesting contract. + AdminMigrateVestedDelegation { + mix_id: NodeId, + owner: String, + }, + /// Admin-only: batch variant of [`ExecuteMsg::AdminMigrateVestedDelegation`]. + /// Reverts the entire batch on the first error, so callers should treat it as all-or-nothing. + AdminBatchMigrateVestedDelegations { + entries: Vec, + }, // testing-only #[cfg(feature = "contract-testing")] @@ -394,6 +411,15 @@ impl ExecuteMsg { } ExecuteMsg::MigrateVestedMixNode { .. } => "migrate vested mixnode".into(), ExecuteMsg::MigrateVestedDelegation { .. } => "migrate vested delegation".to_string(), + ExecuteMsg::AdminMigrateVestedMixNode { owner } => { + format!("admin migrating vested mixnode of {owner}") + } + ExecuteMsg::AdminMigrateVestedDelegation { mix_id, owner } => { + format!("admin migrating vested delegation of {owner} on mixnode {mix_id}") + } + ExecuteMsg::AdminBatchMigrateVestedDelegations { entries } => { + format!("admin batch migrating {} vested delegations", entries.len()) + } ExecuteMsg::AssignRoles { .. } => "assigning epoch roles".into(), ExecuteMsg::MigrateMixnode { .. } => "migrating legacy mixnode".into(), ExecuteMsg::MigrateGateway { .. } => "migrating legacy gateway".into(), @@ -881,8 +907,15 @@ pub enum QueryMsg { GetKeyRotationId {}, } +#[cw_serde] +pub struct VestedDelegationMigrationEntry { + pub mix_id: NodeId, + pub owner: String, +} + #[cw_serde] pub struct MigrateMsg { pub unsafe_skip_state_updates: Option, pub vesting_contract_address: Option, + pub node_families_contract_address: String, } diff --git a/common/cosmwasm-smart-contracts/mixnet-contract/src/types.rs b/common/cosmwasm-smart-contracts/mixnet-contract/src/types.rs index 1b0590df1f..0cb427e201 100644 --- a/common/cosmwasm-smart-contracts/mixnet-contract/src/types.rs +++ b/common/cosmwasm-smart-contracts/mixnet-contract/src/types.rs @@ -212,6 +212,10 @@ pub struct ContractState { /// track-related messages. pub vesting_contract_address: Addr, + /// Address of the node families contract. It is called whenever nym-node unbonds + /// so that it could be removed from any family it belongs to. + pub node_families_contract_address: Addr, + /// The expected denom used for rewarding (and realistically any other operation). /// Default: `unym` pub rewarding_denom: String, diff --git a/common/cosmwasm-smart-contracts/node-families-contract/Cargo.toml b/common/cosmwasm-smart-contracts/node-families-contract/Cargo.toml new file mode 100644 index 0000000000..6a1fc4c1dc --- /dev/null +++ b/common/cosmwasm-smart-contracts/node-families-contract/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "nym-node-families-contract-common" +description = "Common crate for Nym's node families contract" +version.workspace = true +authors.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +documentation.workspace = true +rust-version = "1.85" +readme.workspace = true +publish = true + +[dependencies] +thiserror = { workspace = true } +serde = { workspace = true } +schemars = { workspace = true } + +cosmwasm-std = { workspace = true } +cosmwasm-schema = { workspace = true } +cw-controllers = { workspace = true } +cw-utils = { workspace = true } + +nym-contracts-common = { workspace = true } +nym-mixnet-contract-common = { workspace = true } + +[features] +schema = [] + +[lints] +workspace = true diff --git a/common/cosmwasm-smart-contracts/node-families-contract/src/constants.rs b/common/cosmwasm-smart-contracts/node-families-contract/src/constants.rs new file mode 100644 index 0000000000..372b2b9d9e --- /dev/null +++ b/common/cosmwasm-smart-contracts/node-families-contract/src/constants.rs @@ -0,0 +1,104 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +/// Storage key constants used by the node families contract. +/// +/// They are kept in the common crate so that off-chain tooling (indexers, migration +/// scripts) can reference them without depending on the contract crate itself. +/// Changing any of these values is a breaking change for already-deployed contracts. +pub mod storage_keys { + /// `Item`: address of the mixnet contract used to validate node existence. + pub const MIXNET_CONTRACT_ADDRESS: &str = "mixnet-contract-address"; + + /// `Item`: runtime configuration (fees, length limits) set at instantiation. + pub const CONFIG: &str = "config"; + + /// `Admin` (cw-controllers): admin allowed to perform privileged operations. + pub const CONTRACT_ADMIN: &str = "contract-admin"; + /// `Item`: monotonically increasing id counter for new families. + pub const NODE_FAMILY_ID_COUNTER: &str = "node-family-id-counter"; + /// Primary namespace for the current family-members `IndexedMap`, + /// keyed by `NodeId` with value [`crate::FamilyMembership`]. + pub const NODE_FAMILY_MEMBERS: &str = "node-family-members"; + /// Multi-index over current family members keyed by family id — + /// enables paginated listing of all nodes in a given family. + pub const NODE_FAMILY_MEMBERS_FAMILY_IDX_NAMESPACE: &str = "node-family-members__family"; + + /// Primary namespace for the families `IndexedMap`. + pub const FAMILIES_NAMESPACE: &str = "families"; + /// Secondary unique index keyed by `owner` (one family per owner). + pub const FAMILIES_OWNER_IDX_NAMESPACE: &str = "families__owner"; + /// Secondary unique index keyed by `name` (family names are globally unique). + pub const FAMILIES_NAME_IDX_NAMESPACE: &str = "families__name"; + + /// Primary namespace for the pending invitations `IndexedMap`. + pub const INVITATIONS_NAMESPACE: &str = "invitations"; + /// Multi-index over pending invitations keyed by family id. + pub const INVITATIONS_FAMILY_IDX_NAMESPACE: &str = "invitations__family"; + /// Multi-index over pending invitations keyed by node id + /// (a node can be invited to multiple families simultaneously). + pub const INVITATIONS_NODE_IDX_NAMESPACE: &str = "invitations__node"; + + /// Primary namespace for the archived (accepted/rejected/revoked) invitations `IndexedMap`. + pub const PAST_INVITATIONS_NAMESPACE: &str = "past-invitations"; + /// Multi-index over past invitations keyed by family id. + pub const PAST_INVITATIONS_FAMILY_IDX_NAMESPACE: &str = "past-invitations__family"; + /// Multi-index over past invitations keyed by node id. + pub const PAST_INVITATIONS_NODE_IDX_NAMESPACE: &str = "past-invitations__node"; + /// `Map<(NodeFamilyId, NodeId), u64>`: per-`(family, node)` counter used to + /// disambiguate repeat archive entries (a node can be invited and have the + /// invitation reach a terminal state more than once). + pub const PAST_INVITATIONS_COUNTER_NAMESPACE: &str = "past-invitations-counter"; + + /// Primary namespace for the past-members `IndexedMap`. + pub const PAST_FAMILY_MEMBER_NAMESPACE: &str = "past-family-member"; + /// Multi-index over past members keyed by family id. + pub const PAST_FAMILY_MEMBER_FAMILY_IDX_NAMESPACE: &str = "past-family-member__family"; + /// Multi-index over past members keyed by node id. + pub const PAST_FAMILY_MEMBER_NODE_IDX_NAMESPACE: &str = "past-family-member__node"; + /// `Map<(NodeFamilyId, NodeId), u64>`: per-`(family, node)` counter used to + /// disambiguate repeat past-membership entries (a node can join and leave + /// the same family more than once). + pub const PAST_FAMILY_MEMBER_COUNTER_NAMESPACE: &str = "past-family-member-counter"; +} + +pub mod events { + pub const FAMILY_CREATION_EVENT_NAME: &str = "family_creation"; + pub const FAMILY_CREATION_EVENT_FAMILY_NAME: &str = "family_name"; + pub const FAMILY_CREATION_EVENT_OWNER_ADDRESS: &str = "owner_address"; + pub const FAMILY_CREATION_EVENT_FAMILY_ID: &str = "family_id"; + pub const FAMILY_CREATION_EVENT_PAID_FEE: &str = "paid_fee"; + + pub const FAMILY_DISBAND_EVENT_NAME: &str = "family_disband"; + pub const FAMILY_DISBAND_EVENT_FAMILY_ID: &str = "family_id"; + pub const FAMILY_DISBAND_EVENT_OWNER_ADDRESS: &str = "owner_address"; + pub const FAMILY_DISBAND_EVENT_REFUNDED_FEE: &str = "refunded_fee"; + + pub const FAMILY_INVITATION_EVENT_NAME: &str = "family_invitation"; + pub const FAMILY_INVITATION_EVENT_FAMILY_ID: &str = "family_id"; + pub const FAMILY_INVITATION_EVENT_NODE_ID: &str = "node_id"; + pub const FAMILY_INVITATION_EVENT_EXPIRES_AT: &str = "expires_at"; + + pub const FAMILY_INVITATION_REVOKED_EVENT_NAME: &str = "family_invitation_revoked"; + pub const FAMILY_INVITATION_REVOKED_EVENT_FAMILY_ID: &str = "family_id"; + pub const FAMILY_INVITATION_REVOKED_EVENT_NODE_ID: &str = "node_id"; + + pub const FAMILY_INVITATION_ACCEPTED_EVENT_NAME: &str = "family_invitation_accepted"; + pub const FAMILY_INVITATION_ACCEPTED_EVENT_FAMILY_ID: &str = "family_id"; + pub const FAMILY_INVITATION_ACCEPTED_EVENT_NODE_ID: &str = "node_id"; + + pub const FAMILY_INVITATION_REJECTED_EVENT_NAME: &str = "family_invitation_rejected"; + pub const FAMILY_INVITATION_REJECTED_EVENT_FAMILY_ID: &str = "family_id"; + pub const FAMILY_INVITATION_REJECTED_EVENT_NODE_ID: &str = "node_id"; + + pub const FAMILY_MEMBER_LEFT_EVENT_NAME: &str = "family_member_left"; + pub const FAMILY_MEMBER_LEFT_EVENT_FAMILY_ID: &str = "family_id"; + pub const FAMILY_MEMBER_LEFT_EVENT_NODE_ID: &str = "node_id"; + + pub const FAMILY_MEMBER_KICKED_EVENT_NAME: &str = "family_member_kicked"; + pub const FAMILY_MEMBER_KICKED_EVENT_FAMILY_ID: &str = "family_id"; + pub const FAMILY_MEMBER_KICKED_EVENT_NODE_ID: &str = "node_id"; + + pub const NODE_UNBOND_CLEANUP_EVENT_NAME: &str = "family_node_unbond_cleanup"; + pub const NODE_UNBOND_CLEANUP_EVENT_NODE_ID: &str = "node_id"; +} diff --git a/common/cosmwasm-smart-contracts/node-families-contract/src/error.rs b/common/cosmwasm-smart-contracts/node-families-contract/src/error.rs new file mode 100644 index 0000000000..404fa09c28 --- /dev/null +++ b/common/cosmwasm-smart-contracts/node-families-contract/src/error.rs @@ -0,0 +1,161 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use crate::NodeFamilyId; +use cosmwasm_std::{Addr, Coin}; +use cw_controllers::AdminError; +use cw_utils::PaymentError; +use nym_mixnet_contract_common::NodeId; +use thiserror::Error; + +/// Errors returned from any entry point of the node families contract. +#[derive(Error, Debug, PartialEq)] +pub enum NodeFamiliesContractError { + /// Returned from `migrate` when the on-chain state cannot be brought forward + /// to the current contract version (e.g. unsupported source version, malformed + /// stored data). + #[error("could not perform contract migration: {comment}")] + FailedMigration { comment: String }, + + /// The referenced family does not exist (or no longer exists). + #[error("family with id {family_id} does not exist")] + FamilyNotFound { family_id: NodeFamilyId }, + + /// Disbanding was requested on a family that still has members. + #[error("family {family_id} cannot be disbanded: it still has {members} member(s)")] + FamilyNotEmpty { + family_id: NodeFamilyId, + members: u64, + }, + + /// The given node is not currently a member of any family. + #[error("node {node_id} is not currently a member of any family")] + NodeNotInFamily { node_id: NodeId }, + + /// The given node is a member of a different family than the one the + /// caller is acting on. Distinct from [`NodeNotInFamily`] (which means the + /// node has no membership at all) — surfaces when, e.g., a family owner + /// tries to kick a node that belongs to someone else's family. + #[error("node {node_id} is not a member of family {family_id}")] + NodeNotMemberOfFamily { + node_id: NodeId, + family_id: NodeFamilyId, + }, + + /// A cross-contract callback (e.g. `OnNymNodeUnbond`) was received from a + /// sender that is not the configured mixnet contract address. + #[error("address {sender} is not authorised to invoke the mixnet-contract callback")] + UnauthorisedMixnetCallback { sender: Addr }, + + /// No pending invitation exists for the given `(family, node)` pair. + #[error("no pending invitation for node {node_id} from family {family_id}")] + InvitationNotFound { + family_id: NodeFamilyId, + node_id: NodeId, + }, + + /// A pending invitation for the given `(family, node)` pair already exists; + /// issuing a new one would silently overwrite it. + #[error("a pending invitation for node {node_id} from family {family_id} already exists")] + PendingInvitationAlreadyExists { + family_id: NodeFamilyId, + node_id: NodeId, + }, + + /// The invitation exists but its `expires_at` is at or before the current + /// block time, so it can no longer be acted on. + #[error( + "invitation for node {node_id} from family {family_id} expired at {expires_at} (now: {now})" + )] + InvitationExpired { + family_id: NodeFamilyId, + node_id: NodeId, + expires_at: u64, + now: u64, + }, + + /// The funds attached to a paid execution failed `cw_utils` payment + /// validation (no funds, wrong/extra denom). + #[error("invalid fee provided: {0}")] + InvalidDeposit(#[from] PaymentError), + + /// The funds attached to a `CreateFamily` execution don't match the + /// configured `create_family_fee`. + #[error("expected exactly {expected} as family creation fee; received {received:?}")] + InvalidFamilyCreationFee { expected: Coin, received: Vec }, + + /// The submitted family name normalised to the empty string (i.e. it + /// contained no ASCII alphanumeric characters). + #[error("family name cannot be empty after normalisation")] + EmptyFamilyName, + + /// The submitted family name exceeds the configured length limit. + #[error("family name length {length} exceeds the configured limit of {limit}")] + FamilyNameTooLong { length: usize, limit: usize }, + + /// The submitted family description exceeds the configured length limit. + #[error("family description length {length} exceeds the configured limit of {limit}")] + FamilyDescriptionTooLong { length: usize, limit: usize }, + + /// The transaction sender already owns a family. + #[error("address {address} already owns family {family_id}")] + SenderAlreadyOwnsAFamily { + address: Addr, + family_id: NodeFamilyId, + }, + + /// The transaction sender does not currently own any family - emitted by + /// owner-gated operations like `disband_family` when the sender has + /// nothing to act on. + #[error("address {address} does not currently own any family")] + SenderDoesntOwnAFamily { address: Addr }, + + /// The transaction sender is not the controller of the bonded node + /// referenced by the message. Covers all of: sender controls no bonded + /// node, sender controls a different node id, and sender's node has + /// entered the unbonding state. + #[error("address {address} is not the controller of bonded node {node_id}")] + SenderDoesntControlNode { address: Addr, node_id: NodeId }, + + /// A family with the requested (normalised) name already exists. + #[error("a family with name {name:?} already exists (id {family_id})")] + FamilyNameAlreadyTaken { + name: String, + family_id: NodeFamilyId, + }, + + /// A node controlled by the address is currently a member of a family, + /// so the address cannot also become a family owner or join another family. + #[error("address {address} controls node {node_id} which is currently in family {family_id}")] + AlreadyInFamily { + address: Addr, + node_id: NodeId, + family_id: NodeFamilyId, + }, + + /// The node referenced by an invitation does not exist as a bonded node + /// in the mixnet contract (or has already unbonded). + #[error("node {node_id} is not a bonded node in the mixnet contract")] + NodeDoesntExist { node_id: NodeId }, + + /// The node referenced by an invitation is already a member of a family, + /// so it cannot be invited to another one until it leaves / is removed. + #[error("node {node_id} is already a member of family {family_id}")] + NodeAlreadyInFamily { + node_id: NodeId, + family_id: NodeFamilyId, + }, + + /// The sender supplied a `validity_secs` of `0` for an invitation, which + /// would create one that is already expired at the moment it is stored. + #[error("invitation validity must be strictly positive")] + ZeroInvitationValidity, + + /// Wraps errors raised by `cw-controllers::Admin` (e.g. caller is not admin). + #[error(transparent)] + Admin(#[from] AdminError), + + /// Wraps any underlying `cosmwasm_std::StdError` (storage, serialization, etc.). + #[error(transparent)] + StdErr(#[from] cosmwasm_std::StdError), +} diff --git a/common/cosmwasm-smart-contracts/node-families-contract/src/lib.rs b/common/cosmwasm-smart-contracts/node-families-contract/src/lib.rs new file mode 100644 index 0000000000..265794d3e8 --- /dev/null +++ b/common/cosmwasm-smart-contracts/node-families-contract/src/lib.rs @@ -0,0 +1,22 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +//! Common types, messages, errors and storage-key constants shared between the +//! node families contract and any off-chain client. +//! +//! Keeping these in a separate crate allows clients to depend on the contract's +//! public surface without pulling in `cw-storage-plus` and other on-chain-only +//! dependencies. + +/// Storage-key string constants. See [`constants::storage_keys`]. +pub mod constants; +/// Contract-level error type. +pub mod error; +/// `InstantiateMsg`, `ExecuteMsg`, `QueryMsg`, `MigrateMsg` definitions. +pub mod msg; +/// Domain types stored in / returned by the contract. +pub mod types; + +pub use error::*; +pub use msg::{ExecuteMsg, InstantiateMsg, MigrateMsg, QueryMsg}; +pub use types::*; diff --git a/common/cosmwasm-smart-contracts/node-families-contract/src/msg.rs b/common/cosmwasm-smart-contracts/node-families-contract/src/msg.rs new file mode 100644 index 0000000000..b5a509edcf --- /dev/null +++ b/common/cosmwasm-smart-contracts/node-families-contract/src/msg.rs @@ -0,0 +1,211 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use crate::{ + Config, GlobalPastFamilyInvitationCursor, NodeFamilyId, PastFamilyInvitationCursor, + PastFamilyInvitationForNodeCursor, PastFamilyMemberCursor, PastFamilyMemberForNodeCursor, +}; +use cosmwasm_schema::cw_serde; +use nym_mixnet_contract_common::NodeId; + +#[cfg(feature = "schema")] +use crate::{ + AllFamilyMembersPagedResponse, AllPastFamilyInvitationsPagedResponse, FamiliesPagedResponse, + FamilyMembersPagedResponse, NodeFamilyByNameResponse, NodeFamilyByOwnerResponse, + NodeFamilyMembershipResponse, NodeFamilyResponse, PastFamilyInvitationsForNodePagedResponse, + PastFamilyInvitationsPagedResponse, PastFamilyMembersForNodePagedResponse, + PastFamilyMembersPagedResponse, PendingFamilyInvitationResponse, + PendingFamilyInvitationsPagedResponse, PendingInvitationsForNodePagedResponse, + PendingInvitationsPagedResponse, +}; + +/// Message used to instantiate the node families contract. +#[cw_serde] +pub struct InstantiateMsg { + pub config: Config, + + pub mixnet_contract_address: String, +} + +/// Execute messages accepted by the contract. +#[cw_serde] +pub enum ExecuteMsg { + /// Replace the contract's runtime [`Config`]. Restricted to the contract + /// admin. + UpdateConfig { config: Config }, + + /// Create a new family owned by the message sender. The configured + /// `create_family_fee` must be attached as funds. + CreateFamily { name: String, description: String }, + + /// Disband the family owned by the message sender. The family must have + /// no current members; any still-pending invitations are revoked. + DisbandFamily {}, + + /// Invite a node to the family owned by the message sender. If + /// `validity_secs` is omitted the invitation expires + /// `default_invitation_validity_secs` seconds (from [`Config`]) after the + /// current block time. + InviteToFamily { + node_id: NodeId, + validity_secs: Option, + }, + + /// Revoke a still-pending invitation previously issued by the sender's + /// family. + RevokeFamilyInvitation { node_id: NodeId }, + + /// Accept a pending invitation. The sender must control `node_id`. + AcceptFamilyInvitation { + family_id: NodeFamilyId, + node_id: NodeId, + }, + + /// Reject a pending invitation. The sender must control `node_id`. + RejectFamilyInvitation { + family_id: NodeFamilyId, + node_id: NodeId, + }, + + /// Leave the family `node_id` currently belongs to. The sender must + /// control `node_id`. + LeaveFamily { node_id: NodeId }, + + /// Remove `node_id` from the family owned by the message sender. + KickFromFamily { node_id: NodeId }, + + /// Cross-contract callback fired by the mixnet contract the moment + /// node with `node_id` initiates unbonding. + /// Removes the node from any family it currently + /// belongs to and rejects every pending invitation issued to it. + /// Sender must be the configured mixnet contract address. + OnNymNodeUnbond { node_id: NodeId }, +} + +/// Query messages accepted by the contract. +#[cw_serde] +#[cfg_attr(feature = "schema", derive(cosmwasm_schema::QueryResponses))] +pub enum QueryMsg { + /// Look up a single family by its id. + #[cfg_attr(feature = "schema", returns(NodeFamilyResponse))] + GetFamilyById { family_id: NodeFamilyId }, + + /// Look up the (at most one) family owned by a given address. + #[cfg_attr(feature = "schema", returns(NodeFamilyByOwnerResponse))] + GetFamilyByOwner { owner: String }, + + /// Look up a single family by its name. The lookup is normalised + /// contract-side (lowercased, non-alphanumerics stripped), so equivalent + /// inputs resolve to the same family. + #[cfg_attr(feature = "schema", returns(NodeFamilyByNameResponse))] + GetFamilyByName { name: String }, + + #[cfg_attr(feature = "schema", returns(FamiliesPagedResponse))] + GetFamiliesPaged { + start_after: Option, + limit: Option, + }, + + /// Look up which family — if any — a node currently belongs to. + #[cfg_attr(feature = "schema", returns(NodeFamilyMembershipResponse))] + GetFamilyMembership { node_id: NodeId }, + + /// Page through every node currently in a given family. + #[cfg_attr(feature = "schema", returns(FamilyMembersPagedResponse))] + GetFamilyMembersPaged { + family_id: NodeFamilyId, + start_after: Option, + limit: Option, + }, + + /// Page through every current family member across all families, in + /// ascending [`NodeId`] order. Each entry carries the membership record + /// (which in turn names the family the node belongs to). + #[cfg_attr(feature = "schema", returns(AllFamilyMembersPagedResponse))] + GetAllFamilyMembersPaged { + start_after: Option, + limit: Option, + }, + + /// Look up the pending invitation for a specific `(family_id, node_id)` + /// pair. + #[cfg_attr(feature = "schema", returns(PendingFamilyInvitationResponse))] + GetPendingInvitation { + family_id: NodeFamilyId, + node_id: NodeId, + }, + + /// Page through every pending invitation issued by a given family. + #[cfg_attr(feature = "schema", returns(PendingFamilyInvitationsPagedResponse))] + GetPendingInvitationsForFamilyPaged { + family_id: NodeFamilyId, + start_after: Option, + limit: Option, + }, + + /// Page through every pending invitation issued for a given node. + #[cfg_attr(feature = "schema", returns(PendingInvitationsForNodePagedResponse))] + GetPendingInvitationsForNodePaged { + node_id: NodeId, + start_after: Option, + limit: Option, + }, + + /// Page through every pending invitation across all families. + #[cfg_attr(feature = "schema", returns(PendingInvitationsPagedResponse))] + GetAllPendingInvitationsPaged { + start_after: Option<(NodeFamilyId, NodeId)>, + limit: Option, + }, + + /// Page through every archived (terminal-state) invitation issued by a + /// given family. + #[cfg_attr(feature = "schema", returns(PastFamilyInvitationsPagedResponse))] + GetPastInvitationsForFamilyPaged { + family_id: NodeFamilyId, + start_after: Option, + limit: Option, + }, + + /// Page through every archived (terminal-state) invitation issued to a + /// given node. + #[cfg_attr(feature = "schema", returns(PastFamilyInvitationsForNodePagedResponse))] + GetPastInvitationsForNodePaged { + node_id: NodeId, + start_after: Option, + limit: Option, + }, + + /// Page through every archived (terminal-state) invitation across all + /// families. + #[cfg_attr(feature = "schema", returns(AllPastFamilyInvitationsPagedResponse))] + GetAllPastInvitationsPaged { + start_after: Option, + limit: Option, + }, + + /// Page through every archived membership record for a given family + /// (nodes that used to belong to it but have since been removed). + #[cfg_attr(feature = "schema", returns(PastFamilyMembersPagedResponse))] + GetPastMembersForFamilyPaged { + family_id: NodeFamilyId, + start_after: Option, + limit: Option, + }, + + /// Page through every archived membership record for a given node + /// (every family the node used to belong to but has since been removed + /// from), across all families. + #[cfg_attr(feature = "schema", returns(PastFamilyMembersForNodePagedResponse))] + GetPastMembersForNodePaged { + node_id: NodeId, + start_after: Option, + limit: Option, + }, +} + +/// Message passed to the contract's `migrate` entry point. +#[cw_serde] +pub struct MigrateMsg { + // +} diff --git a/common/cosmwasm-smart-contracts/node-families-contract/src/types.rs b/common/cosmwasm-smart-contracts/node-families-contract/src/types.rs new file mode 100644 index 0000000000..6fefaae4ba --- /dev/null +++ b/common/cosmwasm-smart-contracts/node-families-contract/src/types.rs @@ -0,0 +1,403 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{Addr, Coin}; +use nym_mixnet_contract_common::NodeId; + +/// Identifier of a node family. +/// +/// Issued sequentially by the contract on family creation; never reused even if the +/// family is later disbanded. +pub type NodeFamilyId = u32; + +/// Runtime configuration of the node families contract. +#[cw_serde] +pub struct Config { + /// Fee charged on each successful `create_family` execution. + pub create_family_fee: Coin, + + /// Maximum allowed length, in characters, of a family name. + pub family_name_length_limit: usize, + + /// Maximum allowed length, in characters, of a family description. + pub family_description_length_limit: usize, + + /// Default lifetime, in seconds, used by `invite_to_family` when the + /// sender doesn't supply an explicit value. Senders may override this + /// per-invitation via the optional `validity_secs` argument. + pub default_invitation_validity_secs: u64, +} + +/// On-chain representation of a node family. +#[cw_serde] +pub struct NodeFamily { + /// The id of the node family + pub id: NodeFamilyId, + + /// The name of the node family + pub name: String, + + /// Normalised name of the node family used for uniqueness checks + pub normalised_name: String, + + /// The optional description of the node family + pub description: String, + + /// The owner of the node family + pub owner: Addr, + + /// Records the fee paid when the family was created, + /// so that the appropriate amount could be returned upon it getting disbanded. + pub paid_fee: Coin, + + /// Memoized value of the current number of members in the node family + /// Used to detect if the family is empty + pub members: u64, + + /// Timestamp of the creation of the node family + pub created_at: u64, +} + +/// A pending invitation for a node to join a particular family. +/// +/// Invitations are stored until they are accepted, rejected, revoked, or until the +/// chain advances past `expires_at` (in which case they remain in storage but are +/// treated as inert — there is no background process clearing expired invitations). +#[cw_serde] +pub struct FamilyInvitation { + /// The family that issued the invitation. + pub family_id: NodeFamilyId, + + /// The node being invited. + pub node_id: NodeId, + + /// Block timestamp (unix seconds) after which the invitation is no longer valid. + pub expires_at: u64, +} + +/// On-chain record of a node's current family membership. +/// +/// A node belongs to at most one family at a time, so this is keyed by +/// `NodeId` alone — `family_id` is carried in the value to support reverse +/// lookups (all nodes in a given family) via a secondary index. +#[cw_serde] +pub struct FamilyMembership { + /// The family the node is currently a member of. + pub family_id: NodeFamilyId, + + /// Block timestamp (unix seconds) at which the node accepted its + /// invitation and joined the family. + pub joined_at: u64, +} + +/// Historical record of a node that used to be part of a family but has since been +/// removed (kicked, left voluntarily, or because the family was disbanded). +#[cw_serde] +pub struct PastFamilyMember { + /// The family the node used to belong to. + pub family_id: NodeFamilyId, + + /// The node that was removed. + pub node_id: NodeId, + + /// Block timestamp (unix seconds) at which the membership was terminated. + pub removed_at: u64, +} + +/// Terminal status for an invitation that has been moved out of the pending set. +/// +/// Note: timed-out invitations are not represented here — they are simply left in +/// the pending set (see `FamilyInvitation::expires_at`). +#[cw_serde] +pub enum FamilyInvitationStatus { + /// Still awaiting a response. Recorded with a timestamp for completeness even + /// though pending invitations live in a separate map. + Pending { at: u64 }, + /// The invitee accepted and joined the family at the given timestamp. + Accepted { at: u64 }, + /// The invitee explicitly rejected the invitation at the given timestamp. + Rejected { at: u64 }, + /// The family revoked the invitation at the given timestamp before it could + /// be accepted or rejected. + Revoked { at: u64 }, +} + +/// Historical record of an invitation that has reached a terminal state +/// (`Accepted`, `Rejected`, or `Revoked`). Timed-out invitations are **not** +/// archived here — they remain in the pending map until explicitly cleared. +#[cw_serde] +pub struct PastFamilyInvitation { + /// The original invitation as it was issued. + pub invitation: FamilyInvitation, + + /// What ultimately happened to it. + pub status: FamilyInvitationStatus, +} + +/// Response to [`QueryMsg::GetFamilyById`](crate::QueryMsg::GetFamilyById). +#[cw_serde] +pub struct NodeFamilyResponse { + /// The id that was queried, echoed back so paginated callers can correlate. + pub family_id: NodeFamilyId, + + /// The matching family, or `None` if no family with `family_id` exists. + pub family: Option, +} + +/// Response to [`QueryMsg::GetFamilyByOwner`](crate::QueryMsg::GetFamilyByOwner). +#[cw_serde] +pub struct NodeFamilyByOwnerResponse { + /// The (validated) owner address that was queried, echoed back so callers + /// can correlate. + pub owner: Addr, + + /// The matching family, or `None` if `owner` does not currently own one. + pub family: Option, +} + +/// Response to [`QueryMsg::GetFamilyByName`](crate::QueryMsg::GetFamilyByName). +#[cw_serde] +pub struct NodeFamilyByNameResponse { + /// The name that was queried, echoed back so callers can correlate. + pub name: String, + + /// The matching family, or `None` if no family with that name exists. + pub family: Option, +} + +/// Response to [`QueryMsg::GetFamilyMembership`](crate::QueryMsg::GetFamilyMembership). +#[cw_serde] +pub struct NodeFamilyMembershipResponse { + /// The node that was queried. + pub node_id: NodeId, + + /// The id of the family the node currently belongs to, or `None` if the + /// node is not currently a member of any family. + pub family_id: Option, +} + +/// A pending [`FamilyInvitation`] paired with whether it has already timed +/// out at the time the query was served. +#[cw_serde] +pub struct PendingFamilyInvitationDetails { + /// The stored invitation as it was issued. + pub invitation: FamilyInvitation, + + /// `true` iff `now >= invitation.expires_at` at query time, i.e. the + /// invitation is still in the pending map but can no longer be acted on. + pub expired: bool, +} + +/// Response to [`QueryMsg::GetPendingInvitation`](crate::QueryMsg::GetPendingInvitation). +#[cw_serde] +pub struct PendingFamilyInvitationResponse { + /// The family component of the queried `(family_id, node_id)` key. + pub family_id: NodeFamilyId, + + /// The node component of the queried `(family_id, node_id)` key. + pub node_id: NodeId, + + /// The matching pending invitation along with an explicit expiry flag, + /// or `None` if no such invitation exists. + pub invitation: Option, +} + +/// One entry in a [`FamilyMembersPagedResponse`] page — pairs a node id with +/// its [`FamilyMembership`] record (notably its `joined_at` timestamp). +#[cw_serde] +pub struct FamilyMemberRecord { + /// The node currently in the family. + pub node_id: NodeId, + + /// The membership record (carries `family_id` and `joined_at`). + pub membership: FamilyMembership, +} + +/// Response to [`QueryMsg::GetFamilyMembersPaged`](crate::QueryMsg::GetFamilyMembersPaged). +#[cw_serde] +pub struct FamilyMembersPagedResponse { + /// The family whose members were queried, echoed back so paginated + /// callers can correlate. + pub family_id: NodeFamilyId, + + /// The members on this page, in ascending [`NodeId`] order. + pub members: Vec, + + /// Cursor to pass as `start_after` on the next call, or `None` if this + /// page is empty (which the caller should treat as end-of-list). + pub start_next_after: Option, +} + +/// Response to [`QueryMsg::GetAllFamilyMembersPaged`](crate::QueryMsg::GetAllFamilyMembersPaged). +#[cw_serde] +pub struct AllFamilyMembersPagedResponse { + /// The members on this page, in ascending [`NodeId`] order across every + /// family. + pub members: Vec, + + /// Cursor (last `node_id`) to pass as `start_after` on the next call, + /// or `None` if this page is empty (treat as end-of-list). + pub start_next_after: Option, +} + +/// Response to [`QueryMsg::GetPendingInvitationsForFamilyPaged`](crate::QueryMsg::GetPendingInvitationsForFamilyPaged). +#[cw_serde] +pub struct PendingFamilyInvitationsPagedResponse { + /// The family whose pending invitations were queried, echoed back so + /// paginated callers can correlate. + pub family_id: NodeFamilyId, + + /// The pending invitations on this page, in ascending invitee + /// [`NodeId`] order, each stamped with whether it had already timed out + /// at the time the query was served. + pub invitations: Vec, + + /// Cursor (last invitee node id) to pass as `start_after` on the next + /// call, or `None` if this page is empty (treat as end-of-list). + pub start_next_after: Option, +} + +/// Response to [`QueryMsg::GetPendingInvitationsForNodePaged`](crate::QueryMsg::GetPendingInvitationsForNodePaged). +#[cw_serde] +pub struct PendingInvitationsForNodePagedResponse { + /// The node whose pending invitations were queried, echoed back so + /// paginated callers can correlate. + pub node_id: NodeId, + + /// The pending invitations addressed to this node on this page, in + /// ascending [`NodeFamilyId`] order, each stamped with whether it had + /// already timed out at the time the query was served. + pub invitations: Vec, + + /// Cursor (last issuing family id) to pass as `start_after` on the + /// next call, or `None` if this page is empty (treat as end-of-list). + pub start_next_after: Option, +} + +/// Response to [`QueryMsg::GetAllPendingInvitationsPaged`](crate::QueryMsg::GetAllPendingInvitationsPaged). +#[cw_serde] +pub struct PendingInvitationsPagedResponse { + /// The pending invitations on this page, in ascending + /// `(family_id, node_id)` order, each stamped with whether it had + /// already timed out at the time the query was served. + pub invitations: Vec, + + /// Cursor (last `(family_id, node_id)` pair) to pass as `start_after` + /// on the next call, or `None` if this page is empty (treat as + /// end-of-list). + pub start_next_after: Option<(NodeFamilyId, NodeId)>, +} + +/// Cursor for paginating per-family past-invitation listings: identifies a +/// single archive entry within a family by `(node_id, counter)`. The +/// `counter` is the per-`(family, node)` archive slot — multiple archived +/// invitations can exist for the same `(family, node)` pair (a node may be +/// invited and have the invitation reach a terminal state more than once). +pub type PastFamilyInvitationCursor = (NodeId, u64); + +/// Cursor for paginating per-node past-invitation listings: identifies a +/// single archive entry addressed to a fixed node by `(family_id, counter)`. +pub type PastFamilyInvitationForNodeCursor = (NodeFamilyId, u64); + +/// Cursor for paginating global past-invitation listings: identifies a +/// single archive entry across all families by `((family_id, node_id), counter)`. +pub type GlobalPastFamilyInvitationCursor = ((NodeFamilyId, NodeId), u64); + +/// Response to [`QueryMsg::GetPastInvitationsForFamilyPaged`](crate::QueryMsg::GetPastInvitationsForFamilyPaged). +#[cw_serde] +pub struct PastFamilyInvitationsPagedResponse { + /// The family whose archived invitations were queried, echoed back so + /// paginated callers can correlate. + pub family_id: NodeFamilyId, + + /// The archived invitations on this page, in ascending + /// `(node_id, counter)` order across all terminal statuses. + pub invitations: Vec, + + /// Cursor to pass as `start_after` on the next call, or `None` if this + /// page is empty (treat as end-of-list). + pub start_next_after: Option, +} + +/// Response to [`QueryMsg::GetPastInvitationsForNodePaged`](crate::QueryMsg::GetPastInvitationsForNodePaged). +#[cw_serde] +pub struct PastFamilyInvitationsForNodePagedResponse { + /// The node whose past invitations were queried, echoed back so + /// paginated callers can correlate. + pub node_id: NodeId, + + /// The archived invitations addressed to this node on this page, in + /// ascending `(family_id, counter)` order across all terminal statuses. + pub invitations: Vec, + + /// Cursor to pass as `start_after` on the next call, or `None` if this + /// page is empty (treat as end-of-list). + pub start_next_after: Option, +} + +/// Response to [`QueryMsg::GetAllPastInvitationsPaged`](crate::QueryMsg::GetAllPastInvitationsPaged). +#[cw_serde] +pub struct AllPastFamilyInvitationsPagedResponse { + /// The archived invitations on this page, in ascending + /// `((family_id, node_id), counter)` order across all terminal statuses. + pub invitations: Vec, + + /// Cursor to pass as `start_after` on the next call, or `None` if this + /// page is empty (treat as end-of-list). + pub start_next_after: Option, +} + +/// Cursor for paginating per-family past-member listings: identifies a single +/// archive entry within a family by `(node_id, counter)`. The `counter` is the +/// per-`(family, node)` archive slot — multiple archived membership entries +/// can exist for the same `(family, node)` pair (a node may join, leave, and +/// re-join the same family more than once). +pub type PastFamilyMemberCursor = (NodeId, u64); + +/// Cursor for paginating per-node past-member listings: identifies a single +/// archive entry for a fixed node by `(family_id, counter)`. +pub type PastFamilyMemberForNodeCursor = (NodeFamilyId, u64); + +/// Response to [`QueryMsg::GetPastMembersForFamilyPaged`](crate::QueryMsg::GetPastMembersForFamilyPaged). +#[cw_serde] +pub struct PastFamilyMembersPagedResponse { + /// The family whose archived memberships were queried, echoed back so + /// paginated callers can correlate. + pub family_id: NodeFamilyId, + + /// The archived membership records on this page, in ascending + /// `(node_id, counter)` order. + pub members: Vec, + + /// Cursor to pass as `start_after` on the next call, or `None` if this + /// page is empty (treat as end-of-list). + pub start_next_after: Option, +} + +/// Response to [`QueryMsg::GetPastMembersForNodePaged`](crate::QueryMsg::GetPastMembersForNodePaged). +#[cw_serde] +pub struct PastFamilyMembersForNodePagedResponse { + /// The node whose archived memberships were queried, echoed back so + /// paginated callers can correlate. + pub node_id: NodeId, + + /// The archived membership records for this node on this page, in + /// ascending `(family_id, counter)` order. + pub members: Vec, + + /// Cursor to pass as `start_after` on the next call, or `None` if this + /// page is empty (treat as end-of-list). + pub start_next_after: Option, +} + +/// Response to [`QueryMsg::GetFamiliesPaged`](crate::QueryMsg::GetFamiliesPaged). +#[cw_serde] +pub struct FamiliesPagedResponse { + /// The families on this page, in ascending [`NodeFamilyId`] order. + pub families: Vec, + + /// Cursor to pass as `start_after` on the next call, or `None` if this + /// page is empty (which the caller should treat as end-of-list). + pub start_next_after: Option, +} diff --git a/common/credential-storage/src/backends/memory.rs b/common/credential-storage/src/backends/memory.rs index 28b99e4196..189350f604 100644 --- a/common/credential-storage/src/backends/memory.rs +++ b/common/credential-storage/src/backends/memory.rs @@ -230,8 +230,8 @@ impl MemoryEcachTicketbookManager { expiration_date: t.ticketbook.expiration_date(), ticketbook_type: t.ticketbook.ticketbook_type().to_string(), epoch_id: t.ticketbook.epoch_id() as u32, - total_tickets: t.ticketbook.spent_tickets() as u32, - used_tickets: t.total_tickets, + total_tickets: t.total_tickets, + used_tickets: t.ticketbook.spent_tickets() as u32, }) .collect() } @@ -333,3 +333,339 @@ impl MemoryEcachTicketbookManager { guard.emergency_credentials.remove(typ); } } + +#[cfg(test)] +mod tests { + use super::*; + use nym_compact_ecash::tests::helpers::generate_expiration_date_signatures; + use nym_compact_ecash::{issue, ttp_keygen}; + use nym_credentials_interface::TicketType; + use nym_crypto::asymmetric::ed25519; + use nym_ecash_time::EcashTime; + use nym_test_utils::helpers::deterministic_rng; + + fn mock_issuance(deposit_id: u32) -> IssuanceTicketBook { + let identifier = "foomp"; + let mut rng = deterministic_rng(); + let key = ed25519::PrivateKey::new(&mut rng); + let typ = TicketType::V1MixnetEntry; + IssuanceTicketBook::new(deposit_id, identifier, key, typ) + } + + fn mock_ticketbook() -> anyhow::Result { + let signing_keys = ttp_keygen(1, 1)?.remove(0); + let issuance = mock_issuance(42); + let expiration_date = issuance.expiration_date(); + + let sig_req = issuance.prepare_for_signing(); + let _exp_date_sigs = generate_expiration_date_signatures( + sig_req.expiration_date.ecash_unix_timestamp(), + &[signing_keys.secret_key()], + &[signing_keys.verification_key()], + &signing_keys.verification_key(), + &[1], + )?; + let blind_sig = issue( + signing_keys.secret_key(), + sig_req.ecash_pub_key, + &sig_req.withdrawal_request, + expiration_date.ecash_unix_timestamp(), + issuance.ticketbook_type().encode(), + )?; + + let partial_wallet = + issuance.unblind_signature(&signing_keys.verification_key(), &sig_req, blind_sig, 1)?; + + let wallet = issuance.aggregate_signature_shares( + &signing_keys.verification_key(), + &[partial_wallet], + sig_req, + )?; + + Ok(issuance.into_issued_ticketbook(wallet, 1)) + } + + fn mock_verification_key() -> VerificationKeyAuth { + ttp_keygen(1, 1).unwrap().remove(0).verification_key() + } + + #[tokio::test] + async fn get_ticketbooks_info_empty() { + let manager = MemoryEcachTicketbookManager::new(); + let info = manager.get_ticketbooks_info().await; + assert!(info.is_empty()); + } + + #[tokio::test] + async fn get_ticketbooks_info_maps_inserted_ticketbook() -> anyhow::Result<()> { + let manager = MemoryEcachTicketbookManager::new(); + let ticketbook = mock_ticketbook()?; + let total_tickets = 100; + let used_tickets = 25; + + manager + .insert_new_ticketbook(&ticketbook, total_tickets, used_tickets) + .await; + + let info = manager.get_ticketbooks_info().await; + assert_eq!(info.len(), 1); + let entry = &info[0]; + assert_eq!(entry.id, 0); + assert_eq!(entry.expiration_date, ticketbook.expiration_date()); + assert_eq!( + entry.ticketbook_type, + ticketbook.ticketbook_type().to_string() + ); + assert_eq!(entry.epoch_id, ticketbook.epoch_id() as u32); + assert_eq!(entry.total_tickets, total_tickets); + assert_eq!(entry.used_tickets, used_tickets); + + Ok(()) + } + + #[tokio::test] + async fn contains_ticketbook_reflects_insertion() -> anyhow::Result<()> { + let manager = MemoryEcachTicketbookManager::new(); + let ticketbook = mock_ticketbook()?; + + assert!(!manager.contains_ticketbook(&ticketbook).await); + + manager.insert_new_ticketbook(&ticketbook, 100, 0).await; + + assert!(manager.contains_ticketbook(&ticketbook).await); + Ok(()) + } + + #[tokio::test] + async fn insert_new_ticketbook_assigns_incrementing_ids() -> anyhow::Result<()> { + let manager = MemoryEcachTicketbookManager::new(); + let ticketbook = mock_ticketbook()?; + + manager.insert_new_ticketbook(&ticketbook, 100, 0).await; + manager.insert_new_ticketbook(&ticketbook, 100, 0).await; + + let mut ids: Vec = manager + .get_ticketbooks_info() + .await + .into_iter() + .map(|i| i.id) + .collect(); + ids.sort(); + assert_eq!(ids, vec![0, 1]); + Ok(()) + } + + #[tokio::test] + async fn get_next_unspent_ticketbook_updates_spent_and_exhausts() -> anyhow::Result<()> { + let manager = MemoryEcachTicketbookManager::new(); + let ticketbook = mock_ticketbook()?; + let typ = ticketbook.ticketbook_type().to_string(); + + // total = 3, used = 0 — leaves 3 tickets available + manager.insert_new_ticketbook(&ticketbook, 3, 0).await; + + let first = manager + .get_next_unspent_ticketbook_and_update(typ.clone(), 2) + .await; + assert!(first.is_some()); + let first = first.unwrap(); + assert_eq!(first.total_tickets, 3); + // returned ticketbook reflects state *before* the update + assert_eq!(first.ticketbook.spent_tickets(), 0); + + // next withdrawal of 2 should be rejected (only 1 left) + let second = manager + .get_next_unspent_ticketbook_and_update(typ.clone(), 2) + .await; + assert!(second.is_none()); + + // but a withdrawal of 1 succeeds + let third = manager + .get_next_unspent_ticketbook_and_update(typ.clone(), 1) + .await; + assert!(third.is_some()); + + // and now nothing left + let fourth = manager.get_next_unspent_ticketbook_and_update(typ, 1).await; + assert!(fourth.is_none()); + + Ok(()) + } + + #[tokio::test] + async fn get_next_unspent_ticketbook_filters_by_type() -> anyhow::Result<()> { + let manager = MemoryEcachTicketbookManager::new(); + let ticketbook = mock_ticketbook()?; + + manager.insert_new_ticketbook(&ticketbook, 5, 0).await; + + let mismatched = manager + .get_next_unspent_ticketbook_and_update("nonexistent_type".to_string(), 1) + .await; + assert!(mismatched.is_none()); + Ok(()) + } + + #[tokio::test] + async fn revert_ticketbook_withdrawal_resets_spent_only_when_expected_matches( + ) -> anyhow::Result<()> { + let manager = MemoryEcachTicketbookManager::new(); + let ticketbook = mock_ticketbook()?; + let typ = ticketbook.ticketbook_type().to_string(); + + manager.insert_new_ticketbook(&ticketbook, 10, 0).await; + manager + .get_next_unspent_ticketbook_and_update(typ.clone(), 4) + .await + .expect("should withdraw"); + + // stale expected_current_total_spent — should be rejected + assert!(!manager.revert_ticketbook_withdrawal(0, 4, 99).await); + // spent_tickets unchanged + let used_after_failed = manager.get_ticketbooks_info().await[0].used_tickets; + assert_eq!(used_after_failed, 4); + + // matching expected — should succeed and restore + assert!(manager.revert_ticketbook_withdrawal(0, 4, 4).await); + let used_after_revert = manager.get_ticketbooks_info().await[0].used_tickets; + assert_eq!(used_after_revert, 0); + + // unknown ticketbook_id is rejected + assert!(!manager.revert_ticketbook_withdrawal(999, 1, 0).await); + + Ok(()) + } + + #[tokio::test] + async fn pending_ticketbook_round_trip() { + let manager = MemoryEcachTicketbookManager::new(); + let issuance = mock_issuance(7); + let deposit_id = issuance.deposit_id() as i64; + + assert!(manager.get_pending_ticketbooks().await.is_empty()); + + manager.insert_pending_ticketbook(&issuance).await; + + let pending = manager.get_pending_ticketbooks().await; + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].pending_id, deposit_id); + assert_eq!( + pending[0].pending_ticketbook.deposit_id(), + issuance.deposit_id() + ); + + manager.remove_pending_ticketbook(deposit_id).await; + assert!(manager.get_pending_ticketbooks().await.is_empty()); + + // removing a non-existent id is a no-op + manager.remove_pending_ticketbook(999).await; + } + + #[tokio::test] + async fn emergency_credential_lifecycle() { + let manager = MemoryEcachTicketbookManager::new(); + + let cred_a = EmergencyCredentialContent { + typ: "type-a".to_string(), + content: vec![1, 2, 3], + expiration: None, + }; + let cred_b = EmergencyCredentialContent { + typ: "type-a".to_string(), + content: vec![4, 5, 6], + expiration: None, + }; + let cred_c = EmergencyCredentialContent { + typ: "type-b".to_string(), + content: vec![7, 8, 9], + expiration: None, + }; + + assert!(manager.get_emergency_credential("type-a").await.is_none()); + + manager.insert_emergency_credential(&cred_a).await; + manager.insert_emergency_credential(&cred_b).await; + manager.insert_emergency_credential(&cred_c).await; + + // get returns the first inserted entry for the type + let first = manager.get_emergency_credential("type-a").await.unwrap(); + assert_eq!(first.id, 0); + assert_eq!(first.data.content, vec![1, 2, 3]); + + // remove by id drops only that entry; type-a now exposes cred_b + manager.remove_emergency_credential(0).await; + let after_remove = manager.get_emergency_credential("type-a").await.unwrap(); + assert_eq!(after_remove.id, 1); + assert_eq!(after_remove.data.content, vec![4, 5, 6]); + + // remove by type clears the bucket entirely + manager.remove_emergency_credentials_of_type("type-a").await; + assert!(manager.get_emergency_credential("type-a").await.is_none()); + + // unrelated type is untouched + assert!(manager.get_emergency_credential("type-b").await.is_some()); + } + + #[tokio::test] + async fn master_verification_key_round_trip() { + let manager = MemoryEcachTicketbookManager::new(); + let key = mock_verification_key(); + let epoch = EpochVerificationKey { + epoch_id: 7, + key: key.clone(), + }; + + assert!(manager.get_master_verification_key(7).await.is_none()); + + manager.insert_master_verification_key(&epoch).await; + + assert_eq!(manager.get_master_verification_key(7).await, Some(key)); + assert!(manager.get_master_verification_key(8).await.is_none()); + } + + #[tokio::test] + async fn coin_index_signatures_round_trip() { + let manager = MemoryEcachTicketbookManager::new(); + let sigs = AggregatedCoinIndicesSignatures { + epoch_id: 3, + signatures: vec![], + }; + + assert!(manager.get_coin_index_signatures(3).await.is_none()); + + manager.insert_coin_index_signatures(&sigs).await; + + let retrieved = manager.get_coin_index_signatures(3).await; + assert!(retrieved.is_some()); + assert!(retrieved.unwrap().is_empty()); + assert!(manager.get_coin_index_signatures(4).await.is_none()); + } + + #[tokio::test] + async fn expiration_date_signatures_round_trip() { + let manager = MemoryEcachTicketbookManager::new(); + let date = nym_ecash_time::ecash_today().date(); + let sigs = AggregatedExpirationDateSignatures { + epoch_id: 5, + expiration_date: date, + signatures: vec![], + }; + + assert!(manager + .get_expiration_date_signatures(date, 5) + .await + .is_none()); + + manager.insert_expiration_date_signatures(&sigs).await; + + let retrieved = manager.get_expiration_date_signatures(date, 5).await; + assert!(retrieved.is_some()); + assert!(retrieved.unwrap().is_empty()); + + // wrong epoch / wrong date → miss + assert!(manager + .get_expiration_date_signatures(date, 6) + .await + .is_none()); + } +} diff --git a/common/crypto/Cargo.toml b/common/crypto/Cargo.toml index 1cc48788b6..ac426b25e5 100644 --- a/common/crypto/Cargo.toml +++ b/common/crypto/Cargo.toml @@ -8,7 +8,9 @@ license = { workspace = true } repository = { workspace = true } homepage.workspace = true documentation.workspace = true -rust-version.workspace = true +# pinned (not inherited from workspace) because this crate is imported by the ecash contract, +# and the contracts workspace cannot be built with rustc more recent than 1.86 +rust-version = "1.86.0" readme.workspace = true publish = true diff --git a/common/http-api-client/src/lib.rs b/common/http-api-client/src/lib.rs index b0a5a3d30a..b5d067bbca 100644 --- a/common/http-api-client/src/lib.rs +++ b/common/http-api-client/src/lib.rs @@ -1173,7 +1173,16 @@ impl ApiClientCore for Client { }; match response { - Ok(resp) => return Ok(resp), + Ok(resp) => { + // Check if the response includes a rate limit error from the vercel API + if is_http_rate_limit_err(&resp) { + warn!("encountered vercel rate limit error for {}", url.as_str()); + // if we have multiple urls, update to the next + self.maybe_rotate_hosts(Some(url.clone())); + } + + return Ok(resp); + } Err(err) => { #[cfg(target_arch = "wasm32")] let is_network_err = err.is_timeout(); @@ -1226,17 +1235,39 @@ impl ApiClientCore for Client { } } +const VERCEL_CHALLENGE_HEADER: &str = "x-vercel-mitigated"; +const VERCEL_CHALLENGE_VALUE: &[u8] = b"challenge"; + +/// Check for Rate Limit challenge response from the vercel API +pub(crate) fn is_http_rate_limit_err(resp: &Response) -> bool { + let status = resp.status() == StatusCode::FORBIDDEN; + let header = resp + .headers() + .get(VERCEL_CHALLENGE_HEADER) + .is_some_and(|v| v.as_bytes() == VERCEL_CHALLENGE_VALUE); + let content_type = resp + .headers() + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()) + .is_some_and(|mime_type| { + mime_type.type_() == mime::TEXT && mime_type.subtype() == mime::HTML + }); + + status && header && content_type +} + #[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. +/// This functions attempts to check the error returned by reqwest to see if rotating host +/// information (for clients with multiple 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 equivalent 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. +/// 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() { @@ -1697,6 +1728,13 @@ where decode_raw_response(&headers, full) } else if res.status() == StatusCode::NOT_FOUND { Err(HttpClientError::NotFound { url: Box::new(url) }) + } else if is_http_rate_limit_err(&res) { + Err(HttpClientError::EndpointFailure { + url: Box::new(url), + status, + headers: Box::new(headers), + error: String::from("received vercel rate limit challenge response"), + }) } else { let Ok(plaintext) = res.text().await else { return Err(HttpClientError::RequestFailure { diff --git a/common/http-api-common/src/middleware/client_ip.rs b/common/http-api-common/src/middleware/client_ip.rs new file mode 100644 index 0000000000..a206ce9ec8 --- /dev/null +++ b/common/http-api-common/src/middleware/client_ip.rs @@ -0,0 +1,39 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use axum::extract::{ConnectInfo, FromRequestParts}; +use axum::http::request::Parts; +use axum_client_ip::RightmostXForwardedFor; +use std::convert::Infallible; +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use tracing::warn; + +/// Best-effort client IP extractor. +/// +/// Prefers the rightmost entry of `X-Forwarded-For` (set by a trusted reverse +/// proxy); falls back to the TCP peer address when the header is absent, and to +/// the unspecified address when neither is available (tests). +#[derive(Debug, Clone, Copy)] +pub struct ClientIpAddr(pub IpAddr); + +impl FromRequestParts for ClientIpAddr +where + S: Send + Sync, +{ + type Rejection = Infallible; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + if let Ok(RightmostXForwardedFor(ip)) = + RightmostXForwardedFor::from_request_parts(parts, state).await + { + return Ok(ClientIpAddr(ip)); + } + if let Ok(ConnectInfo(addr)) = + ConnectInfo::::from_request_parts(parts, state).await + { + return Ok(ClientIpAddr(addr.ip())); + } + warn!("ClientIpAddr: no X-Forwarded-For or ConnectInfo found; using 0.0.0.0 fallback"); + Ok(ClientIpAddr(IpAddr::V4(Ipv4Addr::UNSPECIFIED))) + } +} diff --git a/common/http-api-common/src/middleware/logging.rs b/common/http-api-common/src/middleware/logging.rs index dfa03d5b50..f5ca3639d9 100644 --- a/common/http-api-common/src/middleware/logging.rs +++ b/common/http-api-common/src/middleware/logging.rs @@ -1,12 +1,12 @@ // Copyright 2024 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 +use crate::middleware::client_ip::ClientIpAddr; use axum::extract::Request; use axum::http::HeaderValue; use axum::http::header::{HOST, USER_AGENT}; use axum::middleware::Next; use axum::response::IntoResponse; -use axum_client_ip::InsecureClientIp; use colored::Colorize; use std::time::Instant; use tracing::{debug, info}; @@ -17,24 +17,24 @@ enum LogLevel { } pub async fn log_request_info( - insecure_client_ip: InsecureClientIp, + client_ip: ClientIpAddr, request: Request, next: Next, ) -> impl IntoResponse { - log_request(insecure_client_ip, request, next, LogLevel::Info).await + log_request(client_ip, request, next, LogLevel::Info).await } pub async fn log_request_debug( - insecure_client_ip: InsecureClientIp, + client_ip: ClientIpAddr, request: Request, next: Next, ) -> impl IntoResponse { - log_request(insecure_client_ip, request, next, LogLevel::Debug).await + log_request(client_ip, request, next, LogLevel::Debug).await } /// Simple logger for requests async fn log_request( - InsecureClientIp(addr): InsecureClientIp, + ClientIpAddr(addr): ClientIpAddr, request: Request, next: Next, level: LogLevel, diff --git a/common/http-api-common/src/middleware/mod.rs b/common/http-api-common/src/middleware/mod.rs index d81923bce5..13763b71dd 100644 --- a/common/http-api-common/src/middleware/mod.rs +++ b/common/http-api-common/src/middleware/mod.rs @@ -2,4 +2,5 @@ // SPDX-License-Identifier: Apache-2.0 pub mod bearer_auth; +pub mod client_ip; pub mod logging; diff --git a/common/http-api-common/src/response/mod.rs b/common/http-api-common/src/response/mod.rs index 95e8cb5533..fcffebe052 100644 --- a/common/http-api-common/src/response/mod.rs +++ b/common/http-api-common/src/response/mod.rs @@ -129,6 +129,41 @@ where } } +#[derive(Default, Debug, Serialize, Deserialize, Copy, Clone)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "lowercase")] +pub enum OutputV2 { + #[default] + Json, + Yaml, +} + +#[derive(Default, Debug, Serialize, Deserialize, Copy, Clone)] +#[cfg_attr(feature = "utoipa", derive(utoipa::IntoParams, utoipa::ToSchema))] +#[serde(default)] +pub struct OutputParamsV2 { + pub output: Option, +} + +impl OutputParamsV2 { + pub fn get_output(&self) -> OutputV2 { + self.output.unwrap_or_default() + } + + pub fn to_response(self, data: T) -> FormattedResponse { + self.get_output().to_response(data) + } +} + +impl OutputV2 { + pub fn to_response(self, data: T) -> FormattedResponse { + match self { + OutputV2::Json => FormattedResponse::Json(Json::from(data)), + OutputV2::Yaml => FormattedResponse::Yaml(Yaml::from(data)), + } + } +} + #[derive(Default, Debug, Serialize, Deserialize, Copy, Clone)] #[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] #[serde(rename_all = "lowercase")] diff --git a/common/ip-packet-requests/src/lib.rs b/common/ip-packet-requests/src/lib.rs index 1a1c6f197d..770fcaa933 100644 --- a/common/ip-packet-requests/src/lib.rs +++ b/common/ip-packet-requests/src/lib.rs @@ -20,6 +20,22 @@ pub const MAX_NON_STREAM_VERSION: u8 = v8::VERSION; /// mixnet sends, matching the node-side enforcement in `ip-packet-router`. pub const SPHINX_STREAM_VERSION_THRESHOLD: u8 = v9::VERSION; +#[cfg(test)] +mod tests { + use super::*; + + const _: () = { + assert!(SPHINX_STREAM_VERSION_THRESHOLD > MAX_NON_STREAM_VERSION); + }; + + #[test] + fn stream_transport_threshold_is_consistent() { + assert_eq!(MAX_NON_STREAM_VERSION, 8); + assert_eq!(SPHINX_STREAM_VERSION_THRESHOLD, 9); + const _: () = assert!(SPHINX_STREAM_VERSION_THRESHOLD > MAX_NON_STREAM_VERSION); + } +} + // version 3: initial version // version 4: IPv6 support // version 5: Add severity level to info response diff --git a/common/network-defaults/Cargo.toml b/common/network-defaults/Cargo.toml index 823e9fc200..b4256a0c89 100644 --- a/common/network-defaults/Cargo.toml +++ b/common/network-defaults/Cargo.toml @@ -8,7 +8,9 @@ license.workspace = true repository.workspace = true homepage.workspace = true documentation.workspace = true -rust-version.workspace = true +# pinned (not inherited from workspace) because this crate is imported by the ecash contract, +# and the contracts workspace cannot be built with rustc more recent than 1.86 +rust-version = "1.86.0" readme.workspace = true publish = true # Exclude build.rs from published crate - it's only used for dev-time sync diff --git a/common/network-defaults/src/mainnet.rs b/common/network-defaults/src/mainnet.rs index 0ce8772863..b58b078486 100644 --- a/common/network-defaults/src/mainnet.rs +++ b/common/network-defaults/src/mainnet.rs @@ -24,6 +24,10 @@ pub const PERFORMANCE_CONTRACT_ADDRESS: &str = ""; pub const NETWORK_MONITORS_CONTRACT_ADDRESS: &str = "n1m3a2ltkjqud8mkmrpqvgllrtv2p4r6js6qwl7p8cqkzrq8jg6e2qwqgl8z"; +// \/ TODO: this has to be updated once the contract is deployed +pub const NODE_FAMILIES_CONTRACT_ADDRESS: &str = ""; +// /\ TODO: this has to be updated once the contract is deployed + pub const ECASH_CONTRACT_ADDRESS: &str = "n1r7s6aksyc6pqardx88k3rkgfagwvj4z4zum9mmz2sfk3zm2mha0sd4dnun"; pub const GROUP_CONTRACT_ADDRESS: &str = diff --git a/common/network-defaults/src/network.rs b/common/network-defaults/src/network.rs index 868ef8bf81..eee438a212 100644 --- a/common/network-defaults/src/network.rs +++ b/common/network-defaults/src/network.rs @@ -41,6 +41,8 @@ pub struct NymContracts { pub performance_contract_address: Option, #[serde(default)] pub network_monitors_contract_address: Option, + #[serde(default)] + pub node_families_contract_address: Option, pub ecash_contract_address: Option, pub group_contract_address: Option, pub multisig_contract_address: Option, @@ -185,6 +187,9 @@ impl NymNetworkDetails { )) .with_mixnet_contract(get_optional_env(var_names::MIXNET_CONTRACT_ADDRESS)) .with_vesting_contract(get_optional_env(var_names::VESTING_CONTRACT_ADDRESS)) + .with_node_families_contract(get_optional_env( + var_names::NODE_FAMILIES_CONTRACT_ADDRESS, + )) .with_ecash_contract(get_optional_env(var_names::ECASH_CONTRACT_ADDRESS)) .with_group_contract(get_optional_env(var_names::GROUP_CONTRACT_ADDRESS)) .with_multisig_contract(get_optional_env(var_names::MULTISIG_CONTRACT_ADDRESS)) @@ -217,6 +222,9 @@ impl NymNetworkDetails { network_monitors_contract_address: parse_optional_str( mainnet::NETWORK_MONITORS_CONTRACT_ADDRESS, ), + node_families_contract_address: parse_optional_str( + mainnet::NODE_FAMILIES_CONTRACT_ADDRESS, + ), ecash_contract_address: parse_optional_str(mainnet::ECASH_CONTRACT_ADDRESS), group_contract_address: parse_optional_str(mainnet::GROUP_CONTRACT_ADDRESS), multisig_contract_address: parse_optional_str(mainnet::MULTISIG_CONTRACT_ADDRESS), @@ -270,6 +278,8 @@ impl NymNetworkDetails { set_optional_var(var_names::MIXNET_CONTRACT_ADDRESS, self.contracts.mixnet_contract_address); set_optional_var(var_names::VESTING_CONTRACT_ADDRESS, self.contracts.vesting_contract_address); + set_optional_var(var_names::NETWORK_MONITORS_CONTRACT_ADDRESS, self.contracts.network_monitors_contract_address); + set_optional_var(var_names::NODE_FAMILIES_CONTRACT_ADDRESS, self.contracts.node_families_contract_address); set_optional_var(var_names::ECASH_CONTRACT_ADDRESS, self.contracts.ecash_contract_address); set_optional_var(var_names::GROUP_CONTRACT_ADDRESS, self.contracts.group_contract_address); set_optional_var(var_names::MULTISIG_CONTRACT_ADDRESS, self.contracts.multisig_contract_address); @@ -358,6 +368,12 @@ impl NymNetworkDetails { self } + #[must_use] + pub fn with_node_families_contract>(mut self, contract: Option) -> Self { + self.contracts.node_families_contract_address = contract.map(Into::into); + self + } + #[must_use] pub fn with_ecash_contract>(mut self, contract: Option) -> Self { self.contracts.ecash_contract_address = contract.map(Into::into); diff --git a/common/network-defaults/src/var_names.rs b/common/network-defaults/src/var_names.rs index 96dd561650..484ff47877 100644 --- a/common/network-defaults/src/var_names.rs +++ b/common/network-defaults/src/var_names.rs @@ -17,6 +17,7 @@ pub const VESTING_CONTRACT_ADDRESS: &str = "VESTING_CONTRACT_ADDRESS"; pub const ECASH_CONTRACT_ADDRESS: &str = "ECASH_CONTRACT_ADDRESS"; pub const GROUP_CONTRACT_ADDRESS: &str = "GROUP_CONTRACT_ADDRESS"; pub const MULTISIG_CONTRACT_ADDRESS: &str = "MULTISIG_CONTRACT_ADDRESS"; +pub const NODE_FAMILIES_CONTRACT_ADDRESS: &str = "NODE_FAMILIES_CONTRACT_ADDRESS"; pub const COCONUT_DKG_CONTRACT_ADDRESS: &str = "COCONUT_DKG_CONTRACT_ADDRESS"; pub const PERFORMANCE_CONTRACT_ADDRESS: &str = "PERFORMANCE_CONTRACT_ADDRESS"; pub const NETWORK_MONITORS_CONTRACT_ADDRESS: &str = "NETWORK_MONITORS_CONTRACT_ADDRESS"; diff --git a/common/nym-connection-monitor/src/error.rs b/common/nym-connection-monitor/src/error.rs index 463499a95d..2a4dd10d24 100644 --- a/common/nym-connection-monitor/src/error.rs +++ b/common/nym-connection-monitor/src/error.rs @@ -25,6 +25,9 @@ pub enum Error { #[error("failed to create ipv4 packet")] Ipv4PacketCreationFailure, + + #[error("packet length {length} exceeds the u16 IP header field")] + PacketLengthOverflow { length: usize }, } // Result type based on our error type diff --git a/common/nym-connection-monitor/src/packet_helpers.rs b/common/nym-connection-monitor/src/packet_helpers.rs index 8dfdc9e02a..cb23d5ce2b 100644 --- a/common/nym-connection-monitor/src/packet_helpers.rs +++ b/common/nym-connection-monitor/src/packet_helpers.rs @@ -79,9 +79,14 @@ pub fn wrap_icmp_in_ipv4( let mut ipv4_packet = MutableIpv4Packet::owned(ipv4_buffer).ok_or(Error::Ipv4PacketCreationFailure)?; + let total_length_u16 = + u16::try_from(total_length).map_err(|_| Error::PacketLengthOverflow { + length: total_length, + })?; + ipv4_packet.set_version(4); ipv4_packet.set_header_length(5); - ipv4_packet.set_total_length(total_length as u16); + ipv4_packet.set_total_length(total_length_u16); ipv4_packet.set_ttl(64); ipv4_packet.set_next_level_protocol(pnet_packet::ip::IpNextHeaderProtocols::Icmp); ipv4_packet.set_source(source); @@ -101,12 +106,18 @@ pub fn wrap_icmp_in_ipv6( source: Ipv6Addr, destination: Ipv6Addr, ) -> Result { - let ipv6_buffer = vec![0u8; 40 + icmp_echo_request.packet().len()]; + let payload_length = icmp_echo_request.packet().len(); + let payload_length_u16 = + u16::try_from(payload_length).map_err(|_| Error::PacketLengthOverflow { + length: payload_length, + })?; + + let ipv6_buffer = vec![0u8; 40 + payload_length]; let mut ipv6_packet = MutableIpv6Packet::owned(ipv6_buffer).ok_or(Error::Ipv4PacketCreationFailure)?; ipv6_packet.set_version(6); - ipv6_packet.set_payload_length(icmp_echo_request.packet().len() as u16); + ipv6_packet.set_payload_length(payload_length_u16); ipv6_packet.set_next_header(pnet_packet::ip::IpNextHeaderProtocols::Icmpv6); ipv6_packet.set_hop_limit(64); ipv6_packet.set_source(source); @@ -164,3 +175,122 @@ pub(crate) fn is_icmp_v6_echo_reply(packet: &Bytes) -> Option<(u16, Ipv6Addr, Ip } None } + +#[cfg(test)] +mod tests { + use super::*; + use pnet_packet::icmp::IcmpTypes; + use pnet_packet::icmpv6::Icmpv6Types; + use pnet_packet::ip::IpNextHeaderProtocols; + + const V4_SRC: Ipv4Addr = Ipv4Addr::new(10, 0, 0, 1); + const V4_DST: Ipv4Addr = Ipv4Addr::new(10, 0, 0, 2); + const V6_SRC: Ipv6Addr = Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 1); + const V6_DST: Ipv6Addr = Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 2); + + #[test] + fn icmpv4_echo_request_sets_fields_and_valid_checksum() { + let echo = create_icmpv4_echo_request(42, 7).unwrap(); + assert_eq!(echo.get_sequence_number(), 42); + assert_eq!(echo.get_identifier(), 7); + assert_eq!(echo.get_icmp_type(), IcmpTypes::EchoRequest); + + // pnet's `checksum` skips the checksum word, so recomputing on the produced + // packet must equal the stored value. + let icmp = IcmpPacket::new(echo.packet()).unwrap(); + assert_eq!(echo.get_checksum(), pnet_packet::icmp::checksum(&icmp)); + } + + #[test] + fn icmpv6_echo_request_sets_fields_and_valid_checksum() { + let echo = create_icmpv6_echo_request(99, 12, &V6_SRC, &V6_DST).unwrap(); + assert_eq!(echo.get_sequence_number(), 99); + assert_eq!(echo.get_identifier(), 12); + assert_eq!(echo.get_icmpv6_type(), Icmpv6Types::EchoRequest); + + let icmpv6 = icmpv6::Icmpv6Packet::new(echo.packet()).unwrap(); + assert_eq!( + echo.get_checksum(), + pnet_packet::icmpv6::checksum(&icmpv6, &V6_SRC, &V6_DST) + ); + } + + #[test] + fn wrap_icmp_in_ipv4_sets_headers_and_payload() { + let echo = create_icmpv4_echo_request(1, 2).unwrap(); + let echo_bytes = echo.packet().to_vec(); + let packet = wrap_icmp_in_ipv4(echo, V4_SRC, V4_DST).unwrap(); + + assert_eq!(packet.get_version(), 4); + assert_eq!(packet.get_header_length(), 5); + assert_eq!(packet.get_total_length() as usize, 20 + echo_bytes.len()); + assert_eq!(packet.get_ttl(), 64); + assert_eq!( + packet.get_next_level_protocol(), + IpNextHeaderProtocols::Icmp + ); + assert_eq!(packet.get_source(), V4_SRC); + assert_eq!(packet.get_destination(), V4_DST); + assert_eq!(packet.payload(), echo_bytes.as_slice()); + } + + #[test] + fn wrap_icmp_in_ipv6_sets_headers_and_payload() { + let echo = create_icmpv6_echo_request(1, 2, &V6_SRC, &V6_DST).unwrap(); + let echo_bytes = echo.packet().to_vec(); + let packet = wrap_icmp_in_ipv6(echo, V6_SRC, V6_DST).unwrap(); + + assert_eq!(packet.get_version(), 6); + assert_eq!(packet.get_payload_length() as usize, echo_bytes.len()); + assert_eq!(packet.get_next_header(), IpNextHeaderProtocols::Icmpv6); + assert_eq!(packet.get_hop_limit(), 64); + assert_eq!(packet.get_source(), V6_SRC); + assert_eq!(packet.get_destination(), V6_DST); + assert_eq!(packet.payload(), echo_bytes.as_slice()); + } + + #[test] + fn compute_ipv4_checksum_is_zero_on_correctly_checksummed_packet() { + let echo = create_icmpv4_echo_request(1, 2).unwrap(); + let packet = wrap_icmp_in_ipv4(echo, V4_SRC, V4_DST).unwrap(); + // RFC 1071: summing every 16-bit word of a header that already contains its + // own checksum yields all-ones; the one's complement is therefore zero. + assert_eq!(compute_ipv4_checksum(&packet), 0); + } + + #[test] + fn is_icmp_echo_reply_extracts_identifier_and_addresses() { + // pnet's EchoReply/EchoRequest share the same byte layout (only the ICMP + // type field differs) and `is_icmp_echo_reply` does not check the type, + // so a wrapped echo *request* exercises the same parsing path. + let identifier = 1234; + let echo = create_icmpv4_echo_request(7, identifier).unwrap(); + let packet = wrap_icmp_in_ipv4(echo, V4_SRC, V4_DST).unwrap(); + let bytes = Bytes::copy_from_slice(packet.packet()); + + assert_eq!( + is_icmp_echo_reply(&bytes), + Some((identifier, V4_SRC, V4_DST)) + ); + } + + #[test] + fn is_icmp_v6_echo_reply_extracts_identifier_and_addresses() { + let identifier = 5678; + let echo = create_icmpv6_echo_request(7, identifier, &V6_SRC, &V6_DST).unwrap(); + let packet = wrap_icmp_in_ipv6(echo, V6_SRC, V6_DST).unwrap(); + let bytes = Bytes::copy_from_slice(packet.packet()); + + assert_eq!( + is_icmp_v6_echo_reply(&bytes), + Some((identifier, V6_SRC, V6_DST)) + ); + } + + #[test] + fn is_icmp_echo_reply_returns_none_for_undersized_bytes() { + let bytes = Bytes::from_static(&[0u8; 4]); + assert!(is_icmp_echo_reply(&bytes).is_none()); + assert!(is_icmp_v6_echo_reply(&bytes).is_none()); + } +} diff --git a/common/nym-kcp/src/session.rs b/common/nym-kcp/src/session.rs index e6924c5be7..35657b977a 100644 --- a/common/nym-kcp/src/session.rs +++ b/common/nym-kcp/src/session.rs @@ -6,7 +6,7 @@ use std::{ use ansi_term::Color::Yellow; use bytes::{Buf, BytesMut}; -use log::{debug, error, warn}; +use log::{debug, error, trace, warn}; use std::thread; use crate::MAX_RTO; @@ -499,21 +499,9 @@ impl KcpSession { self.snd_buf.len(), post_retain_sns ); - // Corrected format string arguments for the removed count log - debug!( - "[ConvID: {}, Thread: {:?}] parse_una(una={}): Removed {} segment(s) from snd_buf ({} -> {}). Remaining sns: {:?}", - self.conv, - thread::current().id(), - una, - removed_count, - original_len, - self.snd_buf.len(), - post_retain_sns - ); - if removed_count > 0 { - // Use trace level if no segments were removed but buffer wasn't empty - debug!( + if removed_count == 0 { + trace!( "[ConvID: {}, Thread: {:?}] parse_una(una={}): No segments removed from snd_buf (len={}). Remaining sns: {:?}", self.conv, thread::current().id(), @@ -521,6 +509,17 @@ impl KcpSession { original_len, self.snd_buf.iter().map(|s| s.sn).collect::>() ); + } else { + debug!( + "[ConvID: {}, Thread: {:?}] parse_una(una={}): Removed {} segment(s) from snd_buf ({} -> {}). Remaining sns: {:?}", + self.conv, + thread::current().id(), + una, + removed_count, + original_len, + self.snd_buf.len(), + post_retain_sns + ); } // Update the known acknowledged sequence number. diff --git a/common/nym-kkt/Cargo.toml b/common/nym-kkt/Cargo.toml index e278e33887..1157ee3869 100644 --- a/common/nym-kkt/Cargo.toml +++ b/common/nym-kkt/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "nym-kkt" description = "Key transport protocol for the Nym network" -version = "0.1.0" +version = "1.21.0" authors = ["Georgio Nicolas "] edition = { workspace = true } license.workspace = true diff --git a/common/nym-lp-data/Cargo.toml b/common/nym-lp-data/Cargo.toml new file mode 100644 index 0000000000..dc129af727 --- /dev/null +++ b/common/nym-lp-data/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "nym-lp-data" +description = "Lewes Protocol data structure for the Nym network" +version.workspace = true +authors.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +documentation.workspace = true +rust-version.workspace = true +readme.workspace = true +publish = true + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +bytes.workspace = true +num_enum.workspace = true +tracing.workspace = true +thiserror.workspace = true + +nym-common.workspace = true + + +[lints] +workspace = true diff --git a/common/nym-lp-data/src/lib.rs b/common/nym-lp-data/src/lib.rs new file mode 100644 index 0000000000..46f172a2fd --- /dev/null +++ b/common/nym-lp-data/src/lib.rs @@ -0,0 +1,4 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +pub mod packet; diff --git a/common/nym-lp/src/packet/error.rs b/common/nym-lp-data/src/packet/error.rs similarity index 100% rename from common/nym-lp/src/packet/error.rs rename to common/nym-lp-data/src/packet/error.rs diff --git a/common/nym-lp/src/packet/frame.rs b/common/nym-lp-data/src/packet/frame.rs similarity index 96% rename from common/nym-lp/src/packet/frame.rs rename to common/nym-lp-data/src/packet/frame.rs index 81b73de04f..c0bd7b265d 100644 --- a/common/nym-lp/src/packet/frame.rs +++ b/common/nym-lp-data/src/packet/frame.rs @@ -110,7 +110,9 @@ impl LpFrame { } } - pub(crate) fn len(&self) -> usize { + // is_empty in the sense len == 0 doesn't make sense in that case + #[allow(clippy::len_without_is_empty)] + pub fn len(&self) -> usize { LpFrameHeader::SIZE + self.content.len() } } @@ -165,6 +167,8 @@ impl SphinxStreamFrameAttributes { } pub fn parse(attrs: &LpFrameAttributes) -> Result { + // SAFETY : 8 bytes slice into 8 bytes array + #[allow(clippy::unwrap_used)] let stream_id = u64::from_be_bytes(attrs[0..8].try_into().unwrap()); let msg_type = match attrs[8] { 0 => SphinxStreamMsgType::Open, @@ -175,6 +179,8 @@ impl SphinxStreamFrameAttributes { ))); } }; + // SAFETY : 4 bytes slice into 4 bytes array + #[allow(clippy::unwrap_used)] let sequence_num = u32::from_be_bytes(attrs[9..13].try_into().unwrap()); Ok(Self { stream_id, diff --git a/common/nym-lp/src/packet/header.rs b/common/nym-lp-data/src/packet/header.rs similarity index 98% rename from common/nym-lp/src/packet/header.rs rename to common/nym-lp-data/src/packet/header.rs index 4055284f58..f6f573cecb 100644 --- a/common/nym-lp/src/packet/header.rs +++ b/common/nym-lp-data/src/packet/header.rs @@ -1,11 +1,13 @@ // Copyright 2026 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 +use crate::packet::error::MalformedLpPacketError; use crate::packet::version; -use crate::{packet::error::MalformedLpPacketError, peer_config::LpReceiverIndex}; use bytes::{BufMut, BytesMut}; use tracing::warn; +pub type LpReceiverIndex = u32; + /// Outer header (12 bytes) - always cleartext, used for routing. /// /// This is the first 12 bytes of every LP packet, containing only the fields diff --git a/common/nym-lp/src/packet/mod.rs b/common/nym-lp-data/src/packet/mod.rs similarity index 99% rename from common/nym-lp/src/packet/mod.rs rename to common/nym-lp-data/src/packet/mod.rs index e5e8687c4b..b45dfc812e 100644 --- a/common/nym-lp/src/packet/mod.rs +++ b/common/nym-lp-data/src/packet/mod.rs @@ -13,7 +13,6 @@ pub use header::{InnerHeader, LpHeader, OuterHeader}; pub mod error; pub mod frame; pub mod header; -pub mod replay; pub mod version { /// The current version of the Lewes Protocol that is put into each new constructed header. diff --git a/common/nym-lp/Cargo.toml b/common/nym-lp/Cargo.toml index 24f78711a0..03767d06f6 100644 --- a/common/nym-lp/Cargo.toml +++ b/common/nym-lp/Cargo.toml @@ -25,10 +25,10 @@ nym-crypto = { workspace = true, features = ["hashing"] } nym-common.workspace = true nym-kkt = { workspace = true } nym-kkt-ciphersuite = { workspace = true } +nym-lp-data.workspace = true # libcrux dependencies for PSQ (Post-Quantum PSK derivation) libcrux-psq = { workspace = true, features = ["test-utils"] } -num_enum = { workspace = true } zeroize = { workspace = true, features = ["zeroize_derive"] } @@ -48,3 +48,6 @@ mock = ["nym-test-utils"] [[bench]] name = "replay_protection" harness = false + +[lints] +workspace = true diff --git a/common/nym-lp/benches/replay_protection.rs b/common/nym-lp/benches/replay_protection.rs index 63c1b615d9..14e6237891 100644 --- a/common/nym-lp/benches/replay_protection.rs +++ b/common/nym-lp/benches/replay_protection.rs @@ -1,3 +1,5 @@ +#![allow(clippy::unwrap_used)] + use criterion::{BenchmarkId, Criterion, Throughput, black_box, criterion_group, criterion_main}; use nym_lp::replay::ReceivingKeyCounterValidator; use nym_test_utils::helpers::deterministic_rng_09; diff --git a/common/nym-lp/src/codec.rs b/common/nym-lp/src/codec.rs index b8a6b44cbf..dcd74fb0f0 100644 --- a/common/nym-lp/src/codec.rs +++ b/common/nym-lp/src/codec.rs @@ -2,9 +2,9 @@ // SPDX-License-Identifier: Apache-2.0 use crate::LpError; -use crate::packet::{EncryptedLpPacket, InnerHeader, LpFrame, LpHeader, LpPacket}; use bytes::BytesMut; use libcrux_psq::Channel; +use nym_lp_data::packet::{EncryptedLpPacket, InnerHeader, LpFrame, LpHeader, LpPacket}; // needs to be equal or above to the actual overhead pub(crate) const SANE_ENC_OVERHEAD: usize = 32; @@ -82,12 +82,12 @@ pub(crate) fn decrypt_lp_packet( mod tests { use crate::LpError; use crate::codec::{decrypt_data, decrypt_lp_packet, encrypt_data, encrypt_lp_packet}; - use crate::packet::{EncryptedLpPacket, LpFrame, LpHeader, LpPacket}; use crate::peer::mock_peers; use crate::psq::initiator::{build_psq_ciphersuite, build_psq_principal}; use crate::psq::{PSQ_MSG2_SIZE, psq_msg1_size, responder}; use libcrux_psq::{Channel, IntoSession}; use nym_kkt_ciphersuite::KEM; + use nym_lp_data::packet::{EncryptedLpPacket, LpFrame, LpHeader, LpPacket}; use nym_test_utils::helpers::u64_seeded_rng_09; fn mock_transport() -> ( diff --git a/common/nym-lp/src/error.rs b/common/nym-lp/src/error.rs index d292a08952..ac1d5b7357 100644 --- a/common/nym-lp/src/error.rs +++ b/common/nym-lp/src/error.rs @@ -1,13 +1,13 @@ // Copyright 2025 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 -use crate::packet::MalformedLpPacketError; -use crate::peer_config::LpReceiverIndex; use crate::replay::ReplayError; use crate::transport::LpTransportError; use libcrux_psq::handshake::HandshakeError; use libcrux_psq::handshake::builders::BuilderError; use libcrux_psq::session::SessionError; +use nym_lp_data::packet::MalformedLpPacketError; +use nym_lp_data::packet::header::LpReceiverIndex; // use nym_crypto::asymmetric::ed25519::Ed25519RecoveryError; use nym_kkt::error::KKTError; use nym_kkt_ciphersuite::{HashFunction, KEM}; diff --git a/common/nym-lp/src/lib.rs b/common/nym-lp/src/lib.rs index a1193bd153..e9bc674833 100644 --- a/common/nym-lp/src/lib.rs +++ b/common/nym-lp/src/lib.rs @@ -3,7 +3,6 @@ pub mod codec; pub mod error; -pub mod packet; pub mod peer; pub mod peer_config; pub mod psq; @@ -43,9 +42,13 @@ pub struct SessionsMock { #[cfg(any(feature = "mock", test))] impl SessionsMock { + // Unwrap in test is fine + #![allow(clippy::unwrap_used)] + #![allow(clippy::panic)] + pub fn mock_seeded_post_handshake(seed: u64, kem: KEM) -> SessionsMock { use crate::peer::mock_peers; - use crate::peer_config::LpReceiverIndex; + use nym_lp_data::packet::header::LpReceiverIndex; use rand09::Rng; let (init, resp) = mock_peers(); diff --git a/common/nym-lp/src/packet/replay.rs b/common/nym-lp/src/packet/replay.rs deleted file mode 100644 index 5cbdc3c0d2..0000000000 --- a/common/nym-lp/src/packet/replay.rs +++ /dev/null @@ -1,33 +0,0 @@ -use crate::{LpError, packet::LpPacket, replay::ReceivingKeyCounterValidator}; - -pub trait LpPacketReplayExt { - /// Validate packet counter against a replay protection validator - /// - /// This performs a quick check to see if the packet counter is valid before - /// any expensive processing is done. - fn validate_counter(&self, validator: &ReceivingKeyCounterValidator) -> Result<(), LpError>; - - /// Mark packet as received in the replay protection validator - /// - /// This should be called after a packet has been successfully processed. - fn mark_received(&self, validator: &mut ReceivingKeyCounterValidator) -> Result<(), LpError>; -} - -impl LpPacketReplayExt for LpPacket { - /// Validate packet counter against a replay protection validator - /// - /// This performs a quick check to see if the packet counter is valid before - /// any expensive processing is done. - fn validate_counter(&self, validator: &ReceivingKeyCounterValidator) -> Result<(), LpError> { - validator.will_accept_branchless(self.header().outer.counter)?; - Ok(()) - } - - /// Mark packet as received in the replay protection validator - /// - /// This should be called after a packet has been successfully processed. - fn mark_received(&self, validator: &mut ReceivingKeyCounterValidator) -> Result<(), LpError> { - validator.mark_did_receive_branchless(self.header().outer.counter)?; - Ok(()) - } -} diff --git a/common/nym-lp/src/peer_config.rs b/common/nym-lp/src/peer_config.rs index d28dd6bc23..ab57bdfcea 100644 --- a/common/nym-lp/src/peer_config.rs +++ b/common/nym-lp/src/peer_config.rs @@ -6,12 +6,11 @@ use libcrux_psq::handshake::types::Authenticator; use nym_crypto::hkdf::blake3::derive_key_blake3_multi_input; use nym_kkt::keys::EncapsulationKey; +use nym_lp_data::packet::header::LpReceiverIndex; use rand09::{self, CryptoRng, Rng}; use tls_codec::Serialize; use zeroize::Zeroize; -pub type LpReceiverIndex = u32; - pub const MAX_HOPS: u8 = 16; pub const LP_PEER_CONFIG_SIZE: usize = 20; diff --git a/common/nym-lp/src/psq/mod.rs b/common/nym-lp/src/psq/mod.rs index 0386e62880..f8afee6abf 100644 --- a/common/nym-lp/src/psq/mod.rs +++ b/common/nym-lp/src/psq/mod.rs @@ -1,10 +1,10 @@ // Copyright 2026 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 -use crate::packet::version; use crate::peer::{LpLocalPeer, LpRemotePeer}; use crate::transport::traits::LpHandshakeChannel; use nym_kkt_ciphersuite::{HashFunction, IntoEnumIterator, KEM, KEMKeyDigests, SignatureScheme}; +use nym_lp_data::packet::version; use std::collections::BTreeMap; pub(crate) mod handshake_message; diff --git a/common/nym-lp/src/replay/mod.rs b/common/nym-lp/src/replay/mod.rs index 6363600b4c..e19911710a 100644 --- a/common/nym-lp/src/replay/mod.rs +++ b/common/nym-lp/src/replay/mod.rs @@ -7,9 +7,44 @@ //! replay attacks and ensure packet ordering. It uses a bitmap-based //! approach to track received packets and validate their sequence. +use crate::LpError; +use nym_lp_data::packet::LpPacket; + pub mod error; pub mod simd; pub mod validator; pub use error::ReplayError; pub use validator::ReceivingKeyCounterValidator; + +pub trait LpPacketReplayExt { + /// Validate packet counter against a replay protection validator + /// + /// This performs a quick check to see if the packet counter is valid before + /// any expensive processing is done. + fn validate_counter(&self, validator: &ReceivingKeyCounterValidator) -> Result<(), LpError>; + + /// Mark packet as received in the replay protection validator + /// + /// This should be called after a packet has been successfully processed. + fn mark_received(&self, validator: &mut ReceivingKeyCounterValidator) -> Result<(), LpError>; +} + +impl LpPacketReplayExt for LpPacket { + /// Validate packet counter against a replay protection validator + /// + /// This performs a quick check to see if the packet counter is valid before + /// any expensive processing is done. + fn validate_counter(&self, validator: &ReceivingKeyCounterValidator) -> Result<(), LpError> { + validator.will_accept_branchless(self.header().outer.counter)?; + Ok(()) + } + + /// Mark packet as received in the replay protection validator + /// + /// This should be called after a packet has been successfully processed. + fn mark_received(&self, validator: &mut ReceivingKeyCounterValidator) -> Result<(), LpError> { + validator.mark_did_receive_branchless(self.header().outer.counter)?; + Ok(()) + } +} diff --git a/common/nym-lp/src/session.rs b/common/nym-lp/src/session.rs index dc122d425a..ea2ff84375 100644 --- a/common/nym-lp/src/session.rs +++ b/common/nym-lp/src/session.rs @@ -6,9 +6,7 @@ //! This module implements session management functionality, including replay protection use crate::codec::{decrypt_lp_packet, encrypt_lp_packet}; -use crate::packet::{EncryptedLpPacket, LpFrame, LpHeader, LpPacket}; use crate::peer::{LpLocalPeer, LpRemotePeer}; -use crate::peer_config::LpReceiverIndex; use crate::psq::initiator::HandshakeMode; use crate::psq::{ InitiatorData, PSQHandshakeState, PSQHandshakeStateInitiator, PSQHandshakeStateResponder, @@ -21,6 +19,8 @@ use libcrux_psq::handshake::types::{Authenticator, DHPublicKey}; use libcrux_psq::session::{Session, SessionBinding}; use nym_kkt::keys::EncapsulationKey; use nym_kkt_ciphersuite::{KEM, KEMKeyDigests}; +use nym_lp_data::packet::header::LpReceiverIndex; +use nym_lp_data::packet::{EncryptedLpPacket, LpFrame, LpHeader, LpPacket}; use std::collections::BTreeMap; use std::fmt::{Debug, Formatter}; @@ -355,7 +355,7 @@ impl LpTransportSession { self.receiving_counter_mark(ctr)?; // 4. deliver the message - Ok(LpAction::DeliverFrame(packet.frame)) + Ok(LpAction::DeliverFrame(packet.into_frame())) } LpInput::SendFrame(data) => { // Encrypt and send application data diff --git a/common/nym-lp/src/session_integration/mod.rs b/common/nym-lp/src/session_integration/mod.rs index e07538f45b..fd35b2b94f 100644 --- a/common/nym-lp/src/session_integration/mod.rs +++ b/common/nym-lp/src/session_integration/mod.rs @@ -1,9 +1,9 @@ #[cfg(test)] mod tests { - use crate::packet::{EncryptedLpPacket, LpFrame}; use crate::session::{LpAction, LpInput}; use crate::{LpError, SessionManager, SessionsMock}; use nym_kkt_ciphersuite::{IntoEnumIterator, KEM}; + use nym_lp_data::packet::{EncryptedLpPacket, LpFrame}; // helpers to make tests smaller trait ActionExtract { diff --git a/common/nym-lp/src/session_manager.rs b/common/nym-lp/src/session_manager.rs index 6c54d67bbf..36d5ced5cb 100644 --- a/common/nym-lp/src/session_manager.rs +++ b/common/nym-lp/src/session_manager.rs @@ -6,9 +6,9 @@ //! This module implements session lifecycle management functionality, handling //! creation, retrieval, and storage of sessions. -use crate::packet::{EncryptedLpPacket, LpFrame}; -use crate::peer_config::LpReceiverIndex; use crate::{LpError, LpTransportSession}; +use nym_lp_data::packet::header::LpReceiverIndex; +use nym_lp_data::packet::{EncryptedLpPacket, LpFrame}; use std::collections::HashMap; pub use crate::replay::validator::PacketCount; diff --git a/common/nym-lp/src/transport/traits.rs b/common/nym-lp/src/transport/traits.rs index 0d71632cf5..49c90b40db 100644 --- a/common/nym-lp/src/transport/traits.rs +++ b/common/nym-lp/src/transport/traits.rs @@ -1,10 +1,10 @@ // Copyright 2026 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 -use crate::packet::{EncryptedLpPacket, OuterHeader}; use crate::transport::error::LpTransportError; use nym_kkt::context::KKTMode; use nym_kkt_ciphersuite::KEM; +use nym_lp_data::packet::{EncryptedLpPacket, OuterHeader}; use std::net::SocketAddr; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; use tokio::net::TcpStream; diff --git a/common/nym_offline_compact_ecash/src/proofs/mod.rs b/common/nym_offline_compact_ecash/src/proofs/mod.rs index c1a19acc8c..bd3cc63e89 100644 --- a/common/nym_offline_compact_ecash/src/proofs/mod.rs +++ b/common/nym_offline_compact_ecash/src/proofs/mod.rs @@ -31,9 +31,7 @@ where // instead we could maybe use the `from_bytes` variant and adding some suffix // when computing the digest until we produce a valid scalar. let mut bytes = [0u8; 64]; - let pad_size = 64usize - .checked_sub(D::OutputSize::to_usize()) - .unwrap_or_default(); + let pad_size = 64usize.saturating_sub(D::OutputSize::to_usize()); bytes[pad_size..].copy_from_slice(&digest); diff --git a/common/nym_offline_compact_ecash/src/scheme/keygen.rs b/common/nym_offline_compact_ecash/src/scheme/keygen.rs index c771b6658f..4164a3723f 100644 --- a/common/nym_offline_compact_ecash/src/scheme/keygen.rs +++ b/common/nym_offline_compact_ecash/src/scheme/keygen.rs @@ -112,7 +112,7 @@ impl SecretKeyAuth { let ys_len = self.ys.len(); let mut bytes = Vec::with_capacity(8 + (ys_len + 1) * 32); bytes.extend_from_slice(&self.x.to_bytes()); - bytes.extend_from_slice(&ys_len.to_le_bytes()); + bytes.extend_from_slice(&(ys_len as u64).to_le_bytes()); for y in self.ys.iter() { bytes.extend_from_slice(&y.to_bytes()) } @@ -337,7 +337,7 @@ impl VerificationKeyAuth { bytes.extend_from_slice(&self.alpha.to_affine().to_compressed()); - bytes.extend_from_slice(&beta_g1_len.to_le_bytes()); + bytes.extend_from_slice(&(beta_g1_len as u64).to_le_bytes()); for beta_g1 in self.beta_g1.iter() { bytes.extend_from_slice(&beta_g1.to_affine().to_compressed()) diff --git a/common/nym_offline_compact_ecash/src/scheme/mod.rs b/common/nym_offline_compact_ecash/src/scheme/mod.rs index 8d830c1aaf..9fba6bfd7e 100644 --- a/common/nym_offline_compact_ecash/src/scheme/mod.rs +++ b/common/nym_offline_compact_ecash/src/scheme/mod.rs @@ -719,6 +719,10 @@ impl Payment { return Err(CompactEcashError::SpendSignaturesValidity); } + if verification_key.beta_g2.len() < 4 { + return Err(CompactEcashError::VerificationKeyTooShort); + } + let kappa_type = self.kappa + verification_key.beta_g2[3] * type_scalar(self.t_type); if !check_bilinear_pairing( &self.sig.h.to_affine(), diff --git a/common/nymsphinx/chunking/src/fragment.rs b/common/nymsphinx/chunking/src/fragment.rs index f823d9d3bc..3abae28166 100644 --- a/common/nymsphinx/chunking/src/fragment.rs +++ b/common/nymsphinx/chunking/src/fragment.rs @@ -386,8 +386,13 @@ impl FragmentHeader { { return Err(ChunkingError::MalformedHeaderError); } + // post-link requires total == current == u8::MAX so the constructor + // stays in lockstep with `try_from_bytes`'s deserialiser check. if let Some(nfid) = next_fragments_set_id - && (nfid <= 0 || current_fragment != total_fragments || nfid == id) + && (nfid <= 0 + || current_fragment != u8::MAX + || total_fragments != u8::MAX + || nfid == id) { return Err(ChunkingError::MalformedHeaderError); } @@ -1124,9 +1129,13 @@ mod fragment_header { } #[test] - fn can_only_be_post_linked_for_last_fragment() { - assert!(FragmentHeader::try_new(12345, 10, 10, None, Some(1234)).is_ok()); - assert!(FragmentHeader::try_new(12345, u8::MAX, u8::MAX, None, Some(1234),).is_ok()); + fn can_only_be_post_linked_for_last_fragment_of_full_set() { + // post-linking requires total == current == u8::MAX (a *full* set) + assert!(FragmentHeader::try_new(12345, u8::MAX, u8::MAX, None, Some(1234)).is_ok()); + assert!(FragmentHeader::try_new(12345, 10, 10, None, Some(1234)).is_err()); + assert!( + FragmentHeader::try_new(12345, u8::MAX - 1, u8::MAX - 1, None, Some(1234)).is_err() + ); assert!(FragmentHeader::try_new(12345, 10, 2, Some(1234), None).is_err()); } @@ -1192,5 +1201,23 @@ mod fragment_header { assert_eq!(fragmented_header, recovered_header); assert_eq!(LINKED_FRAGMENTED_HEADER_LEN, bytes_used); } + + #[test] + fn post_linked_with_non_max_total_is_rejected_by_constructor_and_deserialiser() { + // Regression: try_new used to accept post-linked headers where + // total/current != u8::MAX, but try_from_bytes rejects them, so + // such headers could never round-trip. + assert!(FragmentHeader::try_new(12345, 10, 10, None, Some(1234)).is_err()); + + // The deserialiser must still reject the corresponding bytes if + // some future change tries to emit them. Build the malformed bytes + // by hand from a valid post-linked header, then overwrite the + // total/current fragment counts. + let valid = FragmentHeader::try_new(12345, u8::MAX, u8::MAX, None, Some(1234)).unwrap(); + let mut malformed = valid.to_bytes(); + malformed[4] = 10; // total_fragments + malformed[5] = 10; // current_fragment + assert!(FragmentHeader::try_from_bytes(&malformed).is_err()); + } } } diff --git a/common/pemstore/Cargo.toml b/common/pemstore/Cargo.toml index ff2d78250e..4019c56770 100644 --- a/common/pemstore/Cargo.toml +++ b/common/pemstore/Cargo.toml @@ -8,7 +8,9 @@ license = { workspace = true } repository = { workspace = true } homepage.workspace = true documentation.workspace = true -rust-version.workspace = true +# pinned (not inherited from workspace) because this crate is imported by the ecash contract, +# and the contracts workspace cannot be built with rustc more recent than 1.86 +rust-version = "1.86.0" readme.workspace = true publish = true diff --git a/common/socks5-client-core/src/socks/client.rs b/common/socks5-client-core/src/socks/client.rs index ffdca82a70..db5bf475dd 100644 --- a/common/socks5-client-core/src/socks/client.rs +++ b/common/socks5-client-core/src/socks/client.rs @@ -411,7 +411,7 @@ impl SocksClient { let recipient = self.service_provider; let packet_type = self.packet_type; - let (stream, _) = ProxyRunner::new( + let proxy_result = ProxyRunner::new( stream, local_stream_remote, remote_proxy_target, @@ -449,8 +449,15 @@ impl SocksClient { ) } }) - .await - .into_inner(); + .await; + + let (stream, _) = match proxy_result { + Ok(runner) => runner.into_inner(), + Err(err) => { + log::error!("proxy runner for connection {connection_id} failed: {err}"); + return; + } + }; // recover stream from the proxy self.stream.finish_proxy(stream) } diff --git a/common/socks5/proxy-helpers/src/proxy_runner/mod.rs b/common/socks5/proxy-helpers/src/proxy_runner/mod.rs index 75a51e366a..a0ce6f25e4 100644 --- a/common/socks5/proxy-helpers/src/proxy_runner/mod.rs +++ b/common/socks5/proxy-helpers/src/proxy_runner/mod.rs @@ -8,6 +8,7 @@ use nym_task::connections::LaneQueueLengths; use nym_task::ShutdownTracker; use std::fmt::Debug; use std::{sync::Arc, time::Duration}; +use tokio::task::JoinError; use tokio::{net::TcpStream, sync::Notify}; mod inbound; @@ -92,7 +93,7 @@ where // The `adapter_fn` is used to transform whatever was read into appropriate // request/response as required by entity running particular side of the proxy. - pub async fn run(mut self, adapter_fn: F) -> Self + pub async fn run(mut self, adapter_fn: F) -> Result where F: Fn(SocketData) -> S + Send + Sync + 'static, { @@ -148,16 +149,22 @@ where let (inbound_result, outbound_result) = futures::future::join(handle_inbound, handle_outbound).await; - if inbound_result.is_err() || outbound_result.is_err() { - panic!("TODO: some future error?") - } - - let read_half = inbound_result.unwrap(); - let (write_half, mix_receiver) = outbound_result.unwrap(); + let read_half = inbound_result.inspect_err(|err| { + log::error!( + "inbound proxy task for connection {} failed: {err}", + self.connection_id + ) + })?; + let (write_half, mix_receiver) = outbound_result.inspect_err(|err| { + log::error!( + "outbound proxy task for connection {} failed: {err}", + self.connection_id + ) + })?; self.socket = Some(write_half.reunite(read_half).unwrap()); self.mix_receiver = Some(mix_receiver); - self + Ok(self) } pub fn into_inner(mut self) -> (TcpStream, ConnectionReceiver) { diff --git a/common/statistics/src/clients/packet_statistics.rs b/common/statistics/src/clients/packet_statistics.rs index 81de7e35f6..c9c853c2a0 100644 --- a/common/statistics/src/clients/packet_statistics.rs +++ b/common/statistics/src/clients/packet_statistics.rs @@ -490,6 +490,11 @@ impl PacketStatisticsControl { // Do basic averaging over the entire history, which just uses the first and last if let Some((start, start_stats)) = self.history.front() { let duration_secs = Instant::now().duration_since(start).as_secs_f64(); + // skip when only one entry was just pushed in this tick: dividing by 0 + // would yield inf/NaN rates that downstream consumers treat as real values. + if duration_secs == 0.0 { + return None; + } let delta = self.stats.clone() - start_stats.clone(); let rates = PacketRates::from(delta) / duration_secs; Some(rates) diff --git a/common/verloc/src/measurements/sender.rs b/common/verloc/src/measurements/sender.rs index f27c531603..86fab92f47 100644 --- a/common/verloc/src/measurements/sender.rs +++ b/common/verloc/src/measurements/sender.rs @@ -109,8 +109,8 @@ impl PacketSender { let mut results = Vec::with_capacity(self.packets_per_node); - let mut seq = self.random_sequence_number(); - for _ in 0..self.packets_per_node { + let start_seq = self.random_sequence_number(); + for seq in start_seq..start_seq + self.packets_per_node as u64 { let packet = EchoPacket::new(seq, &self.identity); let start = Instant::now(); // TODO: should we get the start time after or before actually sending the data? @@ -210,7 +210,6 @@ impl PacketSender { let time_taken = Instant::now().duration_since(start); results.push(time_taken); - seq += 1; sleep(self.delay_between_packets).await; } diff --git a/common/wireguard-private-metadata/tests/src/mock_connect_info.rs b/common/wireguard-private-metadata/tests/src/mock_connect_info.rs index 95633d9e01..d9b05695ee 100644 --- a/common/wireguard-private-metadata/tests/src/mock_connect_info.rs +++ b/common/wireguard-private-metadata/tests/src/mock_connect_info.rs @@ -1,7 +1,6 @@ // Copyright 2025 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 -use async_trait::async_trait; use axum::extract::FromRequestParts; use axum::http::Request; use axum::http::request::Parts; @@ -56,7 +55,6 @@ impl DummyConnectInfo { } } -#[async_trait] impl FromRequestParts for DummyConnectInfo where S: Send + Sync, diff --git a/common/wireguard-private-metadata/tests/src/v2/network.rs b/common/wireguard-private-metadata/tests/src/v2/network.rs index fd2520e8e5..3d289ae97e 100644 --- a/common/wireguard-private-metadata/tests/src/v2/network.rs +++ b/common/wireguard-private-metadata/tests/src/v2/network.rs @@ -57,7 +57,7 @@ pub(crate) mod test { async fn handle_check_request(&mut self, polled_request: CheckRequest) { let mut requests = vec![polled_request]; - while let Ok(Some(queued_up)) = self.check_request_receiver.try_next() { + while let Ok(queued_up) = self.check_request_receiver.try_recv() { requests.push(queued_up); } diff --git a/contracts/Cargo.lock b/contracts/Cargo.lock index 10663f6bd1..76e96899ba 100644 --- a/contracts/Cargo.lock +++ b/contracts/Cargo.lock @@ -2,17 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "aes" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" -dependencies = [ - "cfg-if", - "cipher", - "cpufeatures", -] - [[package]] name = "ahash" version = "0.8.11" @@ -87,7 +76,7 @@ dependencies = [ "ark-serialize", "ark-std", "derivative", - "digest 0.10.7", + "digest", "itertools 0.10.5", "num-bigint", "num-traits", @@ -141,7 +130,7 @@ checksum = "adb7b85a02b83d2f22f89bd5cac66c9c89474240cb6207cb1efc16d098e822a5" dependencies = [ "ark-serialize-derive", "ark-std", - "digest 0.10.7", + "digest", "num-bigint", ] @@ -167,12 +156,6 @@ dependencies = [ "rayon", ] -[[package]] -name = "arrayref" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" - [[package]] name = "autocfg" version = "1.2.0" @@ -215,25 +198,13 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d965446196e3b7decd44aa7ee49e31d630118f90ef12f97900f262eb915c951d" -[[package]] -name = "blake2" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94cb07b0da6a73955f8fb85d24c466778e70cda767a568229b104f0264089330" -dependencies = [ - "byte-tools", - "crypto-mac", - "digest 0.8.1", - "opaque-debug", -] - [[package]] name = "block-buffer" version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" dependencies = [ - "generic-array 0.14.7", + "generic-array", ] [[package]] @@ -251,12 +222,6 @@ dependencies = [ "tinyvec", ] -[[package]] -name = "byte-tools" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3b5ca7a04898ad4bcd41c90c5285445ff5b791899bb1b0abdd2a2aa791211d7" - [[package]] name = "byteorder" version = "1.5.0" @@ -321,26 +286,6 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" -[[package]] -name = "chacha" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddf3c081b5fba1e5615640aae998e0fbd10c24cbd897ee39ed754a77601a4862" -dependencies = [ - "byteorder", - "keystream", -] - -[[package]] -name = "cipher" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" -dependencies = [ - "crypto-common", - "inout", -] - [[package]] name = "const-oid" version = "0.9.6" @@ -380,7 +325,7 @@ dependencies = [ "ark-serialize", "cosmwasm-core", "curve25519-dalek", - "digest 0.10.7", + "digest", "ecdsa", "ed25519-zebra", "k256", @@ -491,9 +436,9 @@ version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" dependencies = [ - "generic-array 0.14.7", + "generic-array", "rand_core", - "subtle 2.4.1", + "subtle", "zeroize", ] @@ -503,29 +448,10 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ - "generic-array 0.14.7", + "generic-array", "typenum", ] -[[package]] -name = "crypto-mac" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4434400df11d95d556bac068ddfedd482915eb18fe8bea89bc80b6e4b1c179e5" -dependencies = [ - "generic-array 0.12.4", - "subtle 1.0.0", -] - -[[package]] -name = "ctr" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" -dependencies = [ - "cipher", -] - [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -535,10 +461,10 @@ dependencies = [ "cfg-if", "cpufeatures", "curve25519-dalek-derive", - "digest 0.10.7", + "digest", "fiat-crypto", "rustc_version", - "subtle 2.4.1", + "subtle", "zeroize", ] @@ -709,7 +635,7 @@ dependencies = [ "nym-contracts-common", "nym-contracts-common-testing", "nym-group-contract-common", - "nym-multisig-contract-common 1.20.4 (registry+https://github.com/rust-lang/crates.io-index)", + "nym-multisig-contract-common 1.21.0 (registry+https://github.com/rust-lang/crates.io-index)", ] [[package]] @@ -796,15 +722,6 @@ dependencies = [ "unicode-xid", ] -[[package]] -name = "digest" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3d0c8c8752312f9713efd397ff63acb9f85585afbf179282e720e7704954dd5" -dependencies = [ - "generic-array 0.12.4", -] - [[package]] name = "digest" version = "0.10.7" @@ -814,7 +731,7 @@ dependencies = [ "block-buffer", "const-oid", "crypto-common", - "subtle 2.4.1", + "subtle", ] [[package]] @@ -825,7 +742,7 @@ checksum = "0d6ef0072f8a535281e4876be788938b528e9a1d43900b82c2569af7da799125" [[package]] name = "easy-addr" -version = "1.20.4" +version = "1.21.0" dependencies = [ "cosmwasm-std", "quote", @@ -839,7 +756,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ "der", - "digest 0.10.7", + "digest", "elliptic-curve", "rfc6979", "signature", @@ -866,7 +783,7 @@ dependencies = [ "rand_core", "serde", "sha2", - "subtle 2.4.1", + "subtle", "zeroize", ] @@ -899,13 +816,13 @@ checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ "base16ct", "crypto-bigint", - "digest 0.10.7", + "digest", "ff", - "generic-array 0.14.7", + "generic-array", "group", "rand_core", "sec1", - "subtle 2.4.1", + "subtle", "zeroize", ] @@ -922,7 +839,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ded41244b729663b1e574f1b4fb731469f69f79c17667b5d776b16cda0479449" dependencies = [ "rand_core", - "subtle 2.4.1", + "subtle", ] [[package]] @@ -931,15 +848,6 @@ version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c007b1ae3abe1cb6f85a16305acd418b7ca6343b953633fee2b76d8f108b830f" -[[package]] -name = "generic-array" -version = "0.12.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffdf9f34f1447443d37393cc6c2b8313aebddcd96906caf34e54c68d8e57d7bd" -dependencies = [ - "typenum", -] - [[package]] name = "generic-array" version = "0.14.7" @@ -970,7 +878,7 @@ checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" dependencies = [ "ff", "rand_core", - "subtle 2.4.1", + "subtle", ] [[package]] @@ -1004,22 +912,13 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" -[[package]] -name = "hkdf" -version = "0.12.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" -dependencies = [ - "hmac", -] - [[package]] name = "hmac" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest 0.10.7", + "digest", ] [[package]] @@ -1048,15 +947,6 @@ dependencies = [ "hashbrown 0.15.2", ] -[[package]] -name = "inout" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" -dependencies = [ - "generic-array 0.14.7", -] - [[package]] name = "itertools" version = "0.10.5" @@ -1102,12 +992,6 @@ dependencies = [ "sha2", ] -[[package]] -name = "keystream" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33070833c9ee02266356de0c43f723152bd38bd96ddf52c82b3af10c9138b28" - [[package]] name = "konst" version = "0.3.16" @@ -1141,45 +1025,12 @@ version = "0.2.180" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" -[[package]] -name = "libm" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" - -[[package]] -name = "lioness" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ae926706ba42c425c9457121178330d75e273df2e82e28b758faf3de3a9acb9" -dependencies = [ - "arrayref", - "blake2", - "chacha", - "keystream", -] - [[package]] name = "memchr" version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c8640c5d730cb13ebd907d8d04b52f55ac9a2eec55b440c8892f40d56c76c1d" -[[package]] -name = "mixnet-vesting-integration-tests" -version = "0.1.0" -dependencies = [ - "cosmwasm-std", - "cw-multi-test", - "nym-contracts-common", - "nym-crypto", - "nym-mixnet-contract", - "nym-mixnet-contract-common", - "nym-vesting-contract", - "nym-vesting-contract-common", - "rand_chacha", -] - [[package]] name = "network-monitors" version = "1.0.0" @@ -1196,6 +1047,26 @@ dependencies = [ "nym-network-monitors-contract-common", ] +[[package]] +name = "node-families" +version = "0.1.0" +dependencies = [ + "anyhow", + "cosmwasm-schema", + "cosmwasm-std", + "cw-controllers", + "cw-storage-plus", + "cw-utils", + "cw2", + "nym-contracts-common", + "nym-contracts-common-testing", + "nym-crypto", + "nym-mixnet-contract", + "nym-mixnet-contract-common", + "nym-node-families-contract-common", + "serde", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -1228,7 +1099,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da0df0e5185db44f69b44f26786fe401b6c293d1907744beaa7fa62b2e5a517a" dependencies = [ "autocfg", - "libm", ] [[package]] @@ -1264,9 +1134,9 @@ dependencies = [ [[package]] name = "nym-coconut-dkg-common" -version = "1.20.4" +version = "1.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a95dc43ef8954a4f79846e3224434cf389d4a9c14b77f526dae3cfd2221c6141" +checksum = "fcdfaf17c8b2a73bd6d14a3b9432118d66cc2335a371f185791099d32c25feb0" dependencies = [ "cosmwasm-schema", "cosmwasm-std", @@ -1274,14 +1144,12 @@ dependencies = [ "cw2", "cw4", "nym-contracts-common", - "nym-multisig-contract-common 1.20.4 (registry+https://github.com/rust-lang/crates.io-index)", + "nym-multisig-contract-common 1.21.0 (registry+https://github.com/rust-lang/crates.io-index)", ] [[package]] name = "nym-contracts-common" -version = "1.20.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47bb3e8427c193cd500c802274b11879086863c3643525b6ece3e9ab1c77bddc" +version = "1.21.0" dependencies = [ "bs58", "cosmwasm-schema", @@ -1295,9 +1163,7 @@ dependencies = [ [[package]] name = "nym-contracts-common-testing" -version = "1.20.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3628aac6715e844f3ab20e3b8ae8c4684f144ccb78e205f002c1c3ae375e956" +version = "1.21.0" dependencies = [ "anyhow", "cosmwasm-std", @@ -1311,16 +1177,13 @@ dependencies = [ [[package]] name = "nym-crypto" -version = "1.20.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b710addc28c9950dd961e7dd3837ef3b479492d2b21b5f2437eb7d2899403027" +version = "1.21.0" dependencies = [ "base64 0.22.1", "bs58", "curve25519-dalek", "ed25519-dalek", "nym-pemstore", - "nym-sphinx-types", "rand", "sha2", "subtle-encoding", @@ -1348,7 +1211,7 @@ dependencies = [ "nym-contracts-common-testing", "nym-crypto", "nym-ecash-contract-common", - "nym-multisig-contract-common 1.20.4 (registry+https://github.com/rust-lang/crates.io-index)", + "nym-multisig-contract-common 1.21.0 (registry+https://github.com/rust-lang/crates.io-index)", "nym-network-defaults", "schemars", "semver", @@ -1359,7 +1222,7 @@ dependencies = [ [[package]] name = "nym-ecash-contract-common" -version = "1.20.4" +version = "1.21.0" dependencies = [ "bs58", "cosmwasm-schema", @@ -1367,15 +1230,15 @@ dependencies = [ "cw-controllers", "cw-utils", "cw2", - "nym-multisig-contract-common 1.20.4", + "nym-multisig-contract-common 1.21.0", "thiserror 2.0.12", ] [[package]] name = "nym-group-contract-common" -version = "1.20.4" +version = "1.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb13102740426a4a2b683f54bbd6614fe9ecd745f5117bcf197c49c300b15edf" +checksum = "f6f9773b3adc4b979c4e0f86d8932a78d10149df36e136f770653ad5405ce41a" dependencies = [ "cosmwasm-schema", "cw-controllers", @@ -1401,6 +1264,7 @@ dependencies = [ "nym-crypto", "nym-mixnet-contract", "nym-mixnet-contract-common", + "nym-node-families-contract-common", "nym-vesting-contract-common", "rand", "rand_chacha", @@ -1410,9 +1274,7 @@ dependencies = [ [[package]] name = "nym-mixnet-contract-common" -version = "1.20.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41c21bceb3bb8ee2789851b3f381fc035485af825bf7290b7c99a5af4e8f6ba1" +version = "1.21.0" dependencies = [ "bs58", "cosmwasm-schema", @@ -1432,7 +1294,7 @@ dependencies = [ [[package]] name = "nym-multisig-contract-common" -version = "1.20.4" +version = "1.21.0" dependencies = [ "cosmwasm-schema", "cosmwasm-std", @@ -1447,9 +1309,9 @@ dependencies = [ [[package]] name = "nym-multisig-contract-common" -version = "1.20.4" +version = "1.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4a20b931ee849f6179ce2b387accd058720017f644ffbc8c2422f3e9ac3ff54" +checksum = "7681c7b43201d45a4958eab012a93e285ce47f7bba405e1ba1808edd195f3347" dependencies = [ "cosmwasm-schema", "cosmwasm-std", @@ -1464,9 +1326,7 @@ dependencies = [ [[package]] name = "nym-network-defaults" -version = "1.20.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9834193b4641acdf9f360aea684a6bd841cad287930bc0d7c3241a133756464" +version = "1.21.0" dependencies = [ "cargo_metadata 0.19.2", "regex", @@ -1474,7 +1334,7 @@ dependencies = [ [[package]] name = "nym-network-monitors-contract-common" -version = "1.20.4" +version = "1.21.0" dependencies = [ "cosmwasm-schema", "cosmwasm-std", @@ -1484,11 +1344,24 @@ dependencies = [ "thiserror 2.0.12", ] +[[package]] +name = "nym-node-families-contract-common" +version = "1.21.0" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-controllers", + "cw-utils", + "nym-contracts-common", + "nym-mixnet-contract-common", + "schemars", + "serde", + "thiserror 2.0.12", +] + [[package]] name = "nym-pemstore" -version = "1.20.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03077f9ebeb40caf8aa8e6f7bf8728449f73733e7a246986e492fa34ad3e70ab" +version = "1.21.0" dependencies = [ "pem", "tracing", @@ -1505,20 +1378,22 @@ dependencies = [ "cw-controllers", "cw-storage-plus", "cw2", + "node-families", "nym-contracts-common", "nym-contracts-common-testing", "nym-crypto", "nym-mixnet-contract", "nym-mixnet-contract-common", + "nym-node-families-contract-common", "nym-performance-contract-common", "serde", ] [[package]] name = "nym-performance-contract-common" -version = "1.20.4" +version = "1.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42129a72f4b0dc0304a48b0ca1769b27694d913687ace5692d4c6924ca9f2a13" +checksum = "c6e47f2a04d8b0e1c492cdd7dcfc98face5efc2a4cb999c03887a5b77e4cf055" dependencies = [ "cosmwasm-schema", "cosmwasm-std", @@ -1546,9 +1421,9 @@ dependencies = [ [[package]] name = "nym-pool-contract-common" -version = "1.20.4" +version = "1.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70239cc26beda3ad19289188c50d554522af29646d7d3f855bda6fc8ed332fe7" +checksum = "9965dce8aaee8e9943b342695ff3fda45c8a4f1daab943ed752a8a0b47aa04c7" dependencies = [ "cosmwasm-schema", "cosmwasm-std", @@ -1558,16 +1433,6 @@ dependencies = [ "thiserror 2.0.12", ] -[[package]] -name = "nym-sphinx-types" -version = "1.20.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ba662d39fd6da9e13166fa1162ff41c2cfaed78a77c70df72fbda6fef5eb4f5" -dependencies = [ - "sphinx-packet", - "thiserror 2.0.12", -] - [[package]] name = "nym-vesting-contract" version = "1.4.1" @@ -1591,9 +1456,9 @@ dependencies = [ [[package]] name = "nym-vesting-contract-common" -version = "1.20.4" +version = "1.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "676c2793efbf9ccdf86bb788c903f778a2d5993a5174729303f9511a297f4ca8" +checksum = "af0179bfbb30551414277ca7bed631d48f3321df7b18e3032f23f2f111a577d7" dependencies = [ "cosmwasm-schema", "cosmwasm-std", @@ -1610,12 +1475,6 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" -[[package]] -name = "opaque-debug" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2839e79665f131bdb5782e51f2c6c9599c133c6098982a54c794358bf432529c" - [[package]] name = "p256" version = "0.13.2" @@ -1758,9 +1617,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", "rand_chacha", @@ -1786,16 +1645,6 @@ dependencies = [ "getrandom", ] -[[package]] -name = "rand_distr" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31" -dependencies = [ - "num-traits", - "rand", -] - [[package]] name = "rayon" version = "1.10.0" @@ -1852,7 +1701,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" dependencies = [ "hmac", - "subtle 2.4.1", + "subtle", ] [[package]] @@ -1930,8 +1779,8 @@ checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" dependencies = [ "base16ct", "der", - "generic-array 0.14.7", - "subtle 2.4.1", + "generic-array", + "subtle", "zeroize", ] @@ -2024,7 +1873,7 @@ checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" dependencies = [ "cfg-if", "cpufeatures", - "digest 0.10.7", + "digest", ] [[package]] @@ -2033,36 +1882,10 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ - "digest 0.10.7", + "digest", "rand_core", ] -[[package]] -name = "sphinx-packet" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c26f0c20d909fdda1c5d0ece3973127ca421984d55b000215df365e93722fc6e" -dependencies = [ - "aes", - "arrayref", - "blake2", - "bs58", - "byteorder", - "chacha", - "ctr", - "curve25519-dalek", - "digest 0.10.7", - "hkdf", - "hmac", - "lioness", - "rand", - "rand_distr", - "sha2", - "subtle 2.4.1", - "x25519-dalek", - "zeroize", -] - [[package]] name = "spki" version = "0.7.3" @@ -2079,12 +1902,6 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" -[[package]] -name = "subtle" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d67a5a62ba6e01cb2192ff309324cb4875d0c451d55fe2319433abe7a05a8ee" - [[package]] name = "subtle" version = "2.4.1" diff --git a/contracts/Cargo.toml b/contracts/Cargo.toml index 342e9029bf..cf3911c319 100644 --- a/contracts/Cargo.toml +++ b/contracts/Cargo.toml @@ -4,13 +4,13 @@ members = [ "coconut-dkg", "ecash", "mixnet", - "mixnet-vesting-integration-tests", - "nym-pool", "multisig/cw3-flex-multisig", "multisig/cw4-group", - "vesting", + "node-families", + "nym-pool", "performance", "network-monitors", + "vesting", ] [workspace.package] @@ -51,7 +51,7 @@ cw3-fixed-multisig = "=2.0.0" cw4 = "=2.0.0" cw20 = "=2.0.0" cw20-base = "2.0.0" -rand = "0.8.5" +rand = "0.8.6" rand_chacha = "0.3.1" semver = "1.0.21" serde = "1.0.196" @@ -60,32 +60,34 @@ schemars = "0.8.16" thiserror = "2.0.11" -easy-addr = { version = "1.20.1", path = "../common/cosmwasm-smart-contracts/easy_addr" } +easy-addr = { version = "1.21.0", path = "../common/cosmwasm-smart-contracts/easy_addr" } # For local development with modifications, add a [patch.crates-io] section (see bottom of file) -nym-coconut-dkg-common = "1.20.4" -nym-contracts-common = "1.20.4" -nym-contracts-common-testing = "1.20.4" -nym-crypto = { version = "1.20.4", default-features = false } -nym-ecash-contract-common = "1.20.4" -nym-group-contract-common = "1.20.4" -nym-mixnet-contract-common = "1.20.4" -nym-multisig-contract-common = "1.20.4" -nym-network-monitors-contract-common = "1.20.4" -nym-network-defaults = { version = "1.20.4", default-features = false } -nym-performance-contract-common = "1.20.4" -nym-pool-contract-common = "1.20.4" -nym-vesting-contract-common = "1.20.4" +nym-coconut-dkg-common = "1.21.0" +nym-contracts-common = "1.21.0" +nym-contracts-common-testing = "1.21.0" +nym-crypto = { version = "1.21.0", default-features = false } +nym-ecash-contract-common = "1.21.0" +nym-group-contract-common = "1.21.0" +nym-mixnet-contract-common = "1.21.0" +nym-multisig-contract-common = "1.21.0" +nym-network-monitors-contract-common = "1.21.0" +nym-network-defaults = { version = "1.21.0", default-features = false } +nym-performance-contract-common = "1.21.0" +nym-pool-contract-common = "1.21.0" +nym-vesting-contract-common = "1.21.0" # Aliases for crates that some contracts import under different names -contracts-common = { version = "1.20.4", package = "nym-contracts-common" } -mixnet-contract-common = { version = "1.20.4", package = "nym-mixnet-contract-common" } -vesting-contract-common = { version = "1.20.4", package = "nym-vesting-contract-common" } +contracts-common = { version = "1.21.0", package = "nym-contracts-common" } +mixnet-contract-common = { version = "1.21.0", package = "nym-mixnet-contract-common" } +vesting-contract-common = { version = "1.21.0", package = "nym-vesting-contract-common" } +nym-node-families-contract-common = { version = "1.21.0", package = "nym-node-families-contract-common" } # Internal contract workspace members (for cross-contract testing) cw3-flex-multisig = { version = "2.0.0", path = "multisig/cw3-flex-multisig" } cw4-group = { version = "2.0.0", path = "multisig/cw4-group" } nym-mixnet-contract = { version = "1.5.1", path = "mixnet" } nym-vesting-contract = { version = "1.4.1", path = "vesting" } +node-families = { version = "0.1.0", path = "node-families" } [workspace.lints.clippy] unwrap_used = "deny" @@ -100,4 +102,10 @@ unreachable = "deny" # For local development, import via path instead of crates.io, e.g. [patch.crates-io] nym-network-monitors-contract-common = { path = "../common/cosmwasm-smart-contracts/network-monitors-contract" } +nym-network-defaults = { path = "../common/network-defaults" } +nym-crypto = { path = "../common/crypto" } +nym-contracts-common-testing = { path = "../common/cosmwasm-smart-contracts/contracts-common-testing" } +nym-contracts-common = { path = "../common/cosmwasm-smart-contracts/contracts-common" } nym-ecash-contract-common = { path = "../common/cosmwasm-smart-contracts/ecash-contract" } +nym-mixnet-contract-common = { path = "../common/cosmwasm-smart-contracts/mixnet-contract" } +nym-node-families-contract-common = { path = "../common/cosmwasm-smart-contracts/node-families-contract" } diff --git a/contracts/ecash/schema/nym-ecash.json b/contracts/ecash/schema/nym-ecash.json index 7bf0c27804..71f7a0c8f6 100644 --- a/contracts/ecash/schema/nym-ecash.json +++ b/contracts/ecash/schema/nym-ecash.json @@ -5,6 +5,7 @@ "instantiate": { "$schema": "http://json-schema.org/draft-07/schema#", "title": "InstantiateMsg", + "description": "Instantiation payload. The sender of the instantiate transaction becomes the contract admin; the three addresses are bech32-validated and persisted as immutable cross-contract pointers (see spec requirement \"Contract instantiation\").", "type": "object", "required": [ "deposit_amount", @@ -14,15 +15,23 @@ ], "properties": { "deposit_amount": { - "$ref": "#/definitions/Coin" + "description": "Default per-deposit price. The denom of this coin is the contract's canonical denom for the rest of its lifetime.", + "allOf": [ + { + "$ref": "#/definitions/Coin" + } + ] }, "group_addr": { + "description": "cw4 group contract referenced by the (stubbed) blacklist proposal flow.", "type": "string" }, "holding_account": { + "description": "Cosmos SDK address reserved for the future pool-contract transition. Stored in `Config` but never debited by the current contract.", "type": "string" }, "multisig_addr": { + "description": "cw3 multisig contract that gates `RedeemTickets` and (in the redesign) blacklist proposals. Not updatable through any execute path.", "type": "string" } }, @@ -55,7 +64,7 @@ "title": "ExecuteMsg", "oneOf": [ { - "description": "Used by clients to request ticket books from the signers", + "description": "Submitted by clients to escrow funds and register a claimed ed25519 identity key. Mints a sequential `deposit_id`. The contract does not verify control of the identity key - that proof is checked off-chain by nym-api signers at blind-sign time.", "type": "object", "required": [ "deposit_ticket_book_funds" @@ -77,7 +86,7 @@ "additionalProperties": false }, { - "description": "Used by gateways to batch redeem tokens from the spent tickets", + "description": "Submitted by gateways to request batch redemption of spent tickets. Dispatches a `Propose` SubMsg to the multisig contract; the actual transfer effect is gated behind multisig approval.", "type": "object", "required": [ "request_redemption" @@ -105,7 +114,7 @@ "additionalProperties": false }, { - "description": "The actual message that gets executed, after multisig votes, that transfers the ticket tokens into gateway's (and the holding) account", + "description": "**Legacy / dead code.** Only callable by the multisig; bumps the unredeemed-tickets counter and emits a `ticket_redemption` event with `moved_to_holding_account = \"false\"`. No known consumer depends on the side effects; candidate for removal in a follow-on breaking-schema change.", "type": "object", "required": [ "redeem_tickets" @@ -133,6 +142,7 @@ "additionalProperties": false }, { + "description": "Transfer the contract admin role. Only the current admin may sign. Dispatches via the cw_controllers `execute_update_admin` handshake.", "type": "object", "required": [ "update_admin" @@ -154,6 +164,7 @@ "additionalProperties": false }, { + "description": "Overwrite `Config::deposit_amount`. Only callable by the contract admin. Rejects values below `nym_network_defaults::TICKETBOOK_SIZE` and trips `TicketBookSizeChanged` if the snapshotted invariant has diverged from the current crate constant.", "type": "object", "required": [ "update_default_deposit_value" @@ -223,6 +234,7 @@ "additionalProperties": false }, { + "description": "**Stubbed**: always returns `EcashContractError::UnimplementedBlacklisting`. Storage, reply handler, and helper paths exist but are unreachable from the public ExecuteMsg surface. Preserved for the redesign.", "type": "object", "required": [ "propose_to_blacklist" @@ -244,6 +256,7 @@ "additionalProperties": false }, { + "description": "*Stubbed**: always returns `EcashContractError::UnimplementedBlacklisting`.", "type": "object", "required": [ "add_to_blacklist" @@ -293,6 +306,7 @@ "title": "QueryMsg", "oneOf": [ { + "description": "Look up a blacklist entry by its bs58-encoded ed25519 public key. Always returns `None` on a freshly deployed contract because the blacklist execute paths are stubbed.", "type": "object", "required": [ "get_blacklisted_account" @@ -314,6 +328,7 @@ "additionalProperties": false }, { + "description": "Paginated listing of blacklist entries. Always empty today (see stubbed blacklist surface). Defaults: limit 50, max 75.", "type": "object", "required": [ "get_blacklist_paged" @@ -343,6 +358,7 @@ "additionalProperties": false }, { + "description": "Default per-deposit price (`Config::deposit_amount`). The `GetRequiredDepositAmount` aliases are kept for backwards compatibility.", "type": "object", "required": [ "get_default_deposit_amount" @@ -356,6 +372,7 @@ "additionalProperties": false }, { + "description": "Per-address reduced deposit price override, if any. `None` for any non-whitelisted address.", "type": "object", "required": [ "get_reduced_deposit_amount" @@ -377,6 +394,7 @@ "additionalProperties": false }, { + "description": "Enumerate every reduced-deposit whitelist entry in ascending address order. Unpaginated by design (the whitelist is expected to stay small).", "type": "object", "required": [ "get_all_whitelisted_accounts" @@ -390,6 +408,7 @@ "additionalProperties": false }, { + "description": "Look up a deposit by id. Returns `{ id, deposit: None }` when the id has not yet been assigned.", "type": "object", "required": [ "get_deposit" @@ -413,6 +432,7 @@ "additionalProperties": false }, { + "description": "Most recently assigned deposit (or `{ deposit: None }` on a fresh contract). See `DepositStorage::latest_deposit`.", "type": "object", "required": [ "get_latest_deposit" @@ -426,6 +446,7 @@ "additionalProperties": false }, { + "description": "Paginated listing of deposits in ascending id order. Defaults: limit 50, max 100.", "type": "object", "required": [ "get_deposits_paged" @@ -457,6 +478,7 @@ "additionalProperties": false }, { + "description": "Aggregate statistics: global totals + per-account custom-price breakdowns. Reassembled in a single read pass from `PoolCounters` and `DepositStatsStorage`.", "type": "object", "required": [ "get_deposits_statistics" @@ -533,6 +555,7 @@ "get_all_whitelisted_accounts": { "$schema": "http://json-schema.org/draft-07/schema#", "title": "WhitelistedAccountsResponse", + "description": "Response shape for `GetAllWhitelistedAccounts`. Unpaginated - the whitelist is expected to stay small.", "type": "object", "required": [ "whitelisted_accounts" @@ -572,6 +595,7 @@ "type": "string" }, "WhitelistedAccount": { + "description": "Whitelist entry: an address and the reduced deposit price it may pay. Persisted in the `\"reduced_deposits\"` storage map.", "type": "object", "required": [ "address", @@ -592,6 +616,7 @@ "get_blacklist_paged": { "$schema": "http://json-schema.org/draft-07/schema#", "title": "PagedBlacklistedAccountResponse", + "description": "Page of blacklist entries returned by `GetBlacklistPaged`. Always empty on a freshly deployed contract.", "type": "object", "required": [ "accounts", @@ -620,6 +645,7 @@ "additionalProperties": false, "definitions": { "BlacklistedAccount": { + "description": "Public-key + metadata pair surfaced by `GetBlacklistedAccount` / `GetBlacklistPaged`. Always empty on a freshly deployed contract.", "type": "object", "required": [ "info", @@ -636,6 +662,7 @@ "additionalProperties": false }, "Blacklisting": { + "description": "Per-key blacklist record: the multisig proposal that approved it and the block height at which finalisation landed (None until finalised).", "type": "object", "required": [ "proposal_id" @@ -662,6 +689,7 @@ "get_blacklisted_account": { "$schema": "http://json-schema.org/draft-07/schema#", "title": "BlacklistedAccountResponse", + "description": "Response shape for `GetBlacklistedAccount`. `account` is `None` for any key not present in the (currently always-empty) blacklist.", "type": "object", "properties": { "account": { @@ -678,6 +706,7 @@ "additionalProperties": false, "definitions": { "Blacklisting": { + "description": "Per-key blacklist record: the multisig proposal that approved it and the block height at which finalisation landed (None until finalised).", "type": "object", "required": [ "proposal_id" @@ -728,6 +757,7 @@ "get_deposit": { "$schema": "http://json-schema.org/draft-07/schema#", "title": "DepositResponse", + "description": "Response shape for `GetDeposit { deposit_id }`. `deposit` is `None` when the id has not yet been assigned (`id >= total_deposits_made`).", "type": "object", "required": [ "id" @@ -752,6 +782,7 @@ "additionalProperties": false, "definitions": { "Deposit": { + "description": "Opaque on-chain record of a deposit: the depositor-claimed bs58-encoded ed25519 identity public key. The contract does not verify control of the corresponding private key.", "type": "object", "required": [ "bs58_encoded_ed25519_pubkey" @@ -768,6 +799,7 @@ "get_deposits_paged": { "$schema": "http://json-schema.org/draft-07/schema#", "title": "PagedDepositsResponse", + "description": "Page of deposits returned by `GetDepositsPaged`. `start_next_after` is the id of the last returned entry; pass it as the next call's `start_after`.", "type": "object", "required": [ "deposits" @@ -792,6 +824,7 @@ "additionalProperties": false, "definitions": { "Deposit": { + "description": "Opaque on-chain record of a deposit: the depositor-claimed bs58-encoded ed25519 identity public key. The contract does not verify control of the corresponding private key.", "type": "object", "required": [ "bs58_encoded_ed25519_pubkey" @@ -804,6 +837,7 @@ "additionalProperties": false }, "DepositData": { + "description": "`(deposit_id, deposit)` pair surfaced by the latest-deposit and paginated deposit queries.", "type": "object", "required": [ "deposit", @@ -919,6 +953,7 @@ "get_latest_deposit": { "$schema": "http://json-schema.org/draft-07/schema#", "title": "LatestDepositResponse", + "description": "Response shape for `GetLatestDeposit`. `deposit` is `None` on a freshly deployed contract.", "type": "object", "properties": { "deposit": { @@ -935,6 +970,7 @@ "additionalProperties": false, "definitions": { "Deposit": { + "description": "Opaque on-chain record of a deposit: the depositor-claimed bs58-encoded ed25519 identity public key. The contract does not verify control of the corresponding private key.", "type": "object", "required": [ "bs58_encoded_ed25519_pubkey" @@ -947,6 +983,7 @@ "additionalProperties": false }, "DepositData": { + "description": "`(deposit_id, deposit)` pair surfaced by the latest-deposit and paginated deposit queries.", "type": "object", "required": [ "deposit", diff --git a/contracts/ecash/schema/raw/execute.json b/contracts/ecash/schema/raw/execute.json index 3f64fb7f29..766d010bd4 100644 --- a/contracts/ecash/schema/raw/execute.json +++ b/contracts/ecash/schema/raw/execute.json @@ -3,7 +3,7 @@ "title": "ExecuteMsg", "oneOf": [ { - "description": "Used by clients to request ticket books from the signers", + "description": "Submitted by clients to escrow funds and register a claimed ed25519 identity key. Mints a sequential `deposit_id`. The contract does not verify control of the identity key - that proof is checked off-chain by nym-api signers at blind-sign time.", "type": "object", "required": [ "deposit_ticket_book_funds" @@ -25,7 +25,7 @@ "additionalProperties": false }, { - "description": "Used by gateways to batch redeem tokens from the spent tickets", + "description": "Submitted by gateways to request batch redemption of spent tickets. Dispatches a `Propose` SubMsg to the multisig contract; the actual transfer effect is gated behind multisig approval.", "type": "object", "required": [ "request_redemption" @@ -53,7 +53,7 @@ "additionalProperties": false }, { - "description": "The actual message that gets executed, after multisig votes, that transfers the ticket tokens into gateway's (and the holding) account", + "description": "**Legacy / dead code.** Only callable by the multisig; bumps the unredeemed-tickets counter and emits a `ticket_redemption` event with `moved_to_holding_account = \"false\"`. No known consumer depends on the side effects; candidate for removal in a follow-on breaking-schema change.", "type": "object", "required": [ "redeem_tickets" @@ -81,6 +81,7 @@ "additionalProperties": false }, { + "description": "Transfer the contract admin role. Only the current admin may sign. Dispatches via the cw_controllers `execute_update_admin` handshake.", "type": "object", "required": [ "update_admin" @@ -102,6 +103,7 @@ "additionalProperties": false }, { + "description": "Overwrite `Config::deposit_amount`. Only callable by the contract admin. Rejects values below `nym_network_defaults::TICKETBOOK_SIZE` and trips `TicketBookSizeChanged` if the snapshotted invariant has diverged from the current crate constant.", "type": "object", "required": [ "update_default_deposit_value" @@ -171,6 +173,7 @@ "additionalProperties": false }, { + "description": "**Stubbed**: always returns `EcashContractError::UnimplementedBlacklisting`. Storage, reply handler, and helper paths exist but are unreachable from the public ExecuteMsg surface. Preserved for the redesign.", "type": "object", "required": [ "propose_to_blacklist" @@ -192,6 +195,7 @@ "additionalProperties": false }, { + "description": "*Stubbed**: always returns `EcashContractError::UnimplementedBlacklisting`.", "type": "object", "required": [ "add_to_blacklist" diff --git a/contracts/ecash/schema/raw/instantiate.json b/contracts/ecash/schema/raw/instantiate.json index 540a22ab4e..bbf1f393cf 100644 --- a/contracts/ecash/schema/raw/instantiate.json +++ b/contracts/ecash/schema/raw/instantiate.json @@ -1,6 +1,7 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "title": "InstantiateMsg", + "description": "Instantiation payload. The sender of the instantiate transaction becomes the contract admin; the three addresses are bech32-validated and persisted as immutable cross-contract pointers (see spec requirement \"Contract instantiation\").", "type": "object", "required": [ "deposit_amount", @@ -10,15 +11,23 @@ ], "properties": { "deposit_amount": { - "$ref": "#/definitions/Coin" + "description": "Default per-deposit price. The denom of this coin is the contract's canonical denom for the rest of its lifetime.", + "allOf": [ + { + "$ref": "#/definitions/Coin" + } + ] }, "group_addr": { + "description": "cw4 group contract referenced by the (stubbed) blacklist proposal flow.", "type": "string" }, "holding_account": { + "description": "Cosmos SDK address reserved for the future pool-contract transition. Stored in `Config` but never debited by the current contract.", "type": "string" }, "multisig_addr": { + "description": "cw3 multisig contract that gates `RedeemTickets` and (in the redesign) blacklist proposals. Not updatable through any execute path.", "type": "string" } }, diff --git a/contracts/ecash/schema/raw/query.json b/contracts/ecash/schema/raw/query.json index 00b2db97bb..bd4b0e4dd8 100644 --- a/contracts/ecash/schema/raw/query.json +++ b/contracts/ecash/schema/raw/query.json @@ -3,6 +3,7 @@ "title": "QueryMsg", "oneOf": [ { + "description": "Look up a blacklist entry by its bs58-encoded ed25519 public key. Always returns `None` on a freshly deployed contract because the blacklist execute paths are stubbed.", "type": "object", "required": [ "get_blacklisted_account" @@ -24,6 +25,7 @@ "additionalProperties": false }, { + "description": "Paginated listing of blacklist entries. Always empty today (see stubbed blacklist surface). Defaults: limit 50, max 75.", "type": "object", "required": [ "get_blacklist_paged" @@ -53,6 +55,7 @@ "additionalProperties": false }, { + "description": "Default per-deposit price (`Config::deposit_amount`). The `GetRequiredDepositAmount` aliases are kept for backwards compatibility.", "type": "object", "required": [ "get_default_deposit_amount" @@ -66,6 +69,7 @@ "additionalProperties": false }, { + "description": "Per-address reduced deposit price override, if any. `None` for any non-whitelisted address.", "type": "object", "required": [ "get_reduced_deposit_amount" @@ -87,6 +91,7 @@ "additionalProperties": false }, { + "description": "Enumerate every reduced-deposit whitelist entry in ascending address order. Unpaginated by design (the whitelist is expected to stay small).", "type": "object", "required": [ "get_all_whitelisted_accounts" @@ -100,6 +105,7 @@ "additionalProperties": false }, { + "description": "Look up a deposit by id. Returns `{ id, deposit: None }` when the id has not yet been assigned.", "type": "object", "required": [ "get_deposit" @@ -123,6 +129,7 @@ "additionalProperties": false }, { + "description": "Most recently assigned deposit (or `{ deposit: None }` on a fresh contract). See `DepositStorage::latest_deposit`.", "type": "object", "required": [ "get_latest_deposit" @@ -136,6 +143,7 @@ "additionalProperties": false }, { + "description": "Paginated listing of deposits in ascending id order. Defaults: limit 50, max 100.", "type": "object", "required": [ "get_deposits_paged" @@ -167,6 +175,7 @@ "additionalProperties": false }, { + "description": "Aggregate statistics: global totals + per-account custom-price breakdowns. Reassembled in a single read pass from `PoolCounters` and `DepositStatsStorage`.", "type": "object", "required": [ "get_deposits_statistics" 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 index 256867acb3..39d0c0a1ed 100644 --- a/contracts/ecash/schema/raw/response_to_get_all_whitelisted_accounts.json +++ b/contracts/ecash/schema/raw/response_to_get_all_whitelisted_accounts.json @@ -1,6 +1,7 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "title": "WhitelistedAccountsResponse", + "description": "Response shape for `GetAllWhitelistedAccounts`. Unpaginated - the whitelist is expected to stay small.", "type": "object", "required": [ "whitelisted_accounts" @@ -40,6 +41,7 @@ "type": "string" }, "WhitelistedAccount": { + "description": "Whitelist entry: an address and the reduced deposit price it may pay. Persisted in the `\"reduced_deposits\"` storage map.", "type": "object", "required": [ "address", diff --git a/contracts/ecash/schema/raw/response_to_get_blacklist_paged.json b/contracts/ecash/schema/raw/response_to_get_blacklist_paged.json index fd40f5ccef..08ed947196 100644 --- a/contracts/ecash/schema/raw/response_to_get_blacklist_paged.json +++ b/contracts/ecash/schema/raw/response_to_get_blacklist_paged.json @@ -1,6 +1,7 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "title": "PagedBlacklistedAccountResponse", + "description": "Page of blacklist entries returned by `GetBlacklistPaged`. Always empty on a freshly deployed contract.", "type": "object", "required": [ "accounts", @@ -29,6 +30,7 @@ "additionalProperties": false, "definitions": { "BlacklistedAccount": { + "description": "Public-key + metadata pair surfaced by `GetBlacklistedAccount` / `GetBlacklistPaged`. Always empty on a freshly deployed contract.", "type": "object", "required": [ "info", @@ -45,6 +47,7 @@ "additionalProperties": false }, "Blacklisting": { + "description": "Per-key blacklist record: the multisig proposal that approved it and the block height at which finalisation landed (None until finalised).", "type": "object", "required": [ "proposal_id" diff --git a/contracts/ecash/schema/raw/response_to_get_blacklisted_account.json b/contracts/ecash/schema/raw/response_to_get_blacklisted_account.json index 64873f42cd..8938398065 100644 --- a/contracts/ecash/schema/raw/response_to_get_blacklisted_account.json +++ b/contracts/ecash/schema/raw/response_to_get_blacklisted_account.json @@ -1,6 +1,7 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "title": "BlacklistedAccountResponse", + "description": "Response shape for `GetBlacklistedAccount`. `account` is `None` for any key not present in the (currently always-empty) blacklist.", "type": "object", "properties": { "account": { @@ -17,6 +18,7 @@ "additionalProperties": false, "definitions": { "Blacklisting": { + "description": "Per-key blacklist record: the multisig proposal that approved it and the block height at which finalisation landed (None until finalised).", "type": "object", "required": [ "proposal_id" diff --git a/contracts/ecash/schema/raw/response_to_get_deposit.json b/contracts/ecash/schema/raw/response_to_get_deposit.json index a0f7ffbff8..956d8dc6b5 100644 --- a/contracts/ecash/schema/raw/response_to_get_deposit.json +++ b/contracts/ecash/schema/raw/response_to_get_deposit.json @@ -1,6 +1,7 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "title": "DepositResponse", + "description": "Response shape for `GetDeposit { deposit_id }`. `deposit` is `None` when the id has not yet been assigned (`id >= total_deposits_made`).", "type": "object", "required": [ "id" @@ -25,6 +26,7 @@ "additionalProperties": false, "definitions": { "Deposit": { + "description": "Opaque on-chain record of a deposit: the depositor-claimed bs58-encoded ed25519 identity public key. The contract does not verify control of the corresponding private key.", "type": "object", "required": [ "bs58_encoded_ed25519_pubkey" diff --git a/contracts/ecash/schema/raw/response_to_get_deposits_paged.json b/contracts/ecash/schema/raw/response_to_get_deposits_paged.json index d93401a3ba..44e2e5dad8 100644 --- a/contracts/ecash/schema/raw/response_to_get_deposits_paged.json +++ b/contracts/ecash/schema/raw/response_to_get_deposits_paged.json @@ -1,6 +1,7 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "title": "PagedDepositsResponse", + "description": "Page of deposits returned by `GetDepositsPaged`. `start_next_after` is the id of the last returned entry; pass it as the next call's `start_after`.", "type": "object", "required": [ "deposits" @@ -25,6 +26,7 @@ "additionalProperties": false, "definitions": { "Deposit": { + "description": "Opaque on-chain record of a deposit: the depositor-claimed bs58-encoded ed25519 identity public key. The contract does not verify control of the corresponding private key.", "type": "object", "required": [ "bs58_encoded_ed25519_pubkey" @@ -37,6 +39,7 @@ "additionalProperties": false }, "DepositData": { + "description": "`(deposit_id, deposit)` pair surfaced by the latest-deposit and paginated deposit queries.", "type": "object", "required": [ "deposit", diff --git a/contracts/ecash/schema/raw/response_to_get_latest_deposit.json b/contracts/ecash/schema/raw/response_to_get_latest_deposit.json index 016a159498..f08e04cace 100644 --- a/contracts/ecash/schema/raw/response_to_get_latest_deposit.json +++ b/contracts/ecash/schema/raw/response_to_get_latest_deposit.json @@ -1,6 +1,7 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "title": "LatestDepositResponse", + "description": "Response shape for `GetLatestDeposit`. `deposit` is `None` on a freshly deployed contract.", "type": "object", "properties": { "deposit": { @@ -17,6 +18,7 @@ "additionalProperties": false, "definitions": { "Deposit": { + "description": "Opaque on-chain record of a deposit: the depositor-claimed bs58-encoded ed25519 identity public key. The contract does not verify control of the corresponding private key.", "type": "object", "required": [ "bs58_encoded_ed25519_pubkey" @@ -29,6 +31,7 @@ "additionalProperties": false }, "DepositData": { + "description": "`(deposit_id, deposit)` pair surfaced by the latest-deposit and paginated deposit queries.", "type": "object", "required": [ "deposit", diff --git a/contracts/ecash/src/constants.rs b/contracts/ecash/src/constants.rs index ce5ff212fe..958d031421 100644 --- a/contracts/ecash/src/constants.rs +++ b/contracts/ecash/src/constants.rs @@ -1,5 +1,15 @@ // Copyright 2024 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 +//! Reply-id constants for the contract's reply dispatcher. Both ids are part +//! of the public contract surface - changing them between versions invalidates +//! any in-flight submessage. + +/// Reply id for the cw3 propose dispatched by the (stubbed) blacklist flow. +/// Wired but unreachable from the public ExecuteMsg surface today. pub const BLACKLIST_PROPOSAL_REPLY_ID: u64 = 7759; + +/// Reply id for the cw3 propose dispatched by `RequestRedemption`. The handler +/// captures the multisig-issued `proposal_id` and re-exposes it as the +/// response data. pub const REDEMPTION_PROPOSAL_REPLY_ID: u64 = 2137; diff --git a/contracts/ecash/src/contract/helpers.rs b/contracts/ecash/src/contract/helpers.rs index cb7acde076..a75ebab062 100644 --- a/contracts/ecash/src/contract/helpers.rs +++ b/contracts/ecash/src/contract/helpers.rs @@ -11,12 +11,21 @@ use nym_multisig_contract_common::msg::QueryMsg as MultisigQueryMsg; use nym_network_defaults::TICKETBOOK_SIZE; use sylvia::ctx::ExecCtx; +/// Snapshot of network-defaults values that the contract considers immutable +/// over its lifetime. Persisted at the `"expected_invariants"` storage key on +/// instantiation; every priced operation cross-checks the snapshot against +/// the current crate constant. #[cw_serde] pub(crate) struct Invariants { + /// `nym_network_defaults::TICKETBOOK_SIZE` at instantiation time. Mismatch + /// against the live constant trips `TicketBookSizeChanged`. pub(crate) ticket_book_size: u64, } impl NymEcashContract { + /// Return `nym_network_defaults::TICKETBOOK_SIZE` if it matches the value + /// snapshotted at instantiation; otherwise surface `TicketBookSizeChanged` + /// so the caller halts before any state mutation. pub(crate) fn get_ticketbook_size( &self, storage: &dyn Storage, diff --git a/contracts/ecash/src/contract/mod.rs b/contracts/ecash/src/contract/mod.rs index 78b415c457..694aaf6be5 100644 --- a/contracts/ecash/src/contract/mod.rs +++ b/contracts/ecash/src/contract/mod.rs @@ -76,6 +76,9 @@ impl NymEcashContract { } } + /// One-shot contract setup. Persists the cross-contract pointers, snapshots + /// the ticketbook-size invariant, zero-initialises the pool counters and + /// default-tier stats, and records the cw2 version + build metadata. #[sv::msg(instantiate)] pub fn instantiate( &self, @@ -144,6 +147,8 @@ impl NymEcashContract { /*================== ======QUERIES======= ==================*/ + /// Paginated listing of blacklist entries. Always empty today - see the + /// stubbed blacklist requirement in the spec. #[sv::msg(query)] pub fn get_blacklist_paged( &self, @@ -175,6 +180,8 @@ impl NymEcashContract { )) } + /// Single-key blacklist lookup. Always returns `None` on a freshly deployed + /// contract because the blacklist execute surface is stubbed. #[sv::msg(query)] pub fn get_blacklisted_account( &self, @@ -185,6 +192,7 @@ impl NymEcashContract { Ok(BlacklistedAccountResponse::new(account)) } + /// Default per-deposit price (`Config::deposit_amount`). #[sv::msg(query)] pub fn get_default_deposit_amount(&self, ctx: QueryCtx) -> StdResult { let deposit_amount = self.config.load(ctx.deps.storage)?.deposit_amount; @@ -192,12 +200,16 @@ impl NymEcashContract { Ok(deposit_amount) } + /// Backwards-compatible alias for `get_default_deposit_amount`. Returns the + /// same value; clients picking either name see identical behaviour. // 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) } + /// Per-address reduced deposit price override. `None` for any + /// non-whitelisted sender. #[sv::msg(query)] pub fn get_reduced_deposit_amount( &self, @@ -210,6 +222,8 @@ impl NymEcashContract { Ok(deposit_amount) } + /// Enumerate every reduced-deposit whitelist entry. Unpaginated - the + /// whitelist is expected to stay small. #[sv::msg(query)] pub fn get_all_whitelisted_accounts( &self, @@ -229,6 +243,8 @@ impl NymEcashContract { }) } + /// Look up a deposit by id. Returns `{ id, deposit: None }` when the id + /// has not yet been assigned. #[sv::msg(query)] pub fn get_deposit( &self, @@ -241,6 +257,8 @@ impl NymEcashContract { }) } + /// Most recently assigned deposit, or `{ deposit: None }` on a fresh + /// contract. See `DepositStorage::latest_deposit`. #[sv::msg(query)] pub fn get_latest_deposit( &self, @@ -260,6 +278,8 @@ impl NymEcashContract { }) } + /// Paginated listing of deposits in ascending id order. Defaults to a + /// limit of 50, clamped at 100. #[sv::msg(query)] pub fn get_deposits_paged( &self, @@ -288,6 +308,8 @@ impl NymEcashContract { }) } + /// Aggregate statistics - global totals plus per-account custom-price + /// breakdowns. Single read pass over `PoolCounters` and the stats storage. #[sv::msg(query)] pub fn get_deposits_statistics( &self, @@ -326,6 +348,9 @@ impl NymEcashContract { ======EXECUTIONS======= =====================*/ + /// Submit a deposit. Classifies the sent amount (default → reduced → + /// `WrongAmount`), bumps the relevant counters, persists the deposit, and + /// emits a `deposited-funds` event with the assigned id. #[sv::msg(exec)] pub fn deposit_ticket_book_funds( &self, @@ -390,6 +415,10 @@ impl NymEcashContract { .set_data(deposit_id.to_be_bytes())) } + /// Dispatch a multisig `Propose` SubMsg for batch ticket redemption. + /// Validates `commitment_bs58` decodes to a 32-byte sha256 digest; the + /// actual transfer happens (via the embedded `RedeemTickets`) only after + /// the multisig approves. #[sv::msg(exec)] pub fn request_redemption( &self, @@ -409,8 +438,10 @@ 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 + /// **Dead code.** Legacy multisig-gated redemption that bumps a counter + /// and emits a `ticket_redemption` event with `moved_to_holding_account = + /// "false"`. No known consumer depends on the side effects; candidate for + /// removal in a follow-on breaking-schema change. #[sv::msg(exec)] pub fn redeem_tickets( &self, @@ -438,6 +469,10 @@ impl NymEcashContract { )) } + /// Transfer the contract admin role. Dispatches via the cw_controllers + /// `execute_update_admin` handshake; the sender-equality check happens + /// inside that call. The handler always forwards `Some(new_admin)` - + /// admin renunciation is not exposed. #[sv::msg(exec)] pub fn update_admin( &self, @@ -452,6 +487,10 @@ impl NymEcashContract { .execute_update_admin(ctx.deps, ctx.info, Some(new_admin))?) } + /// Overwrite `Config::deposit_amount`. Admin-gated; trips + /// `TicketBookSizeChanged` if the snapshotted invariant diverged from the + /// crate constant, and `DepositBelowTicketBookSize` if the new amount is + /// below `TICKETBOOK_SIZE`. #[sv::msg(exec)] pub fn update_default_deposit_value( &self, @@ -479,6 +518,10 @@ impl NymEcashContract { Ok(Response::new().add_attribute("updated_deposit", deposit_str)) } + /// Validate and persist a reduced-deposit entry. Shared between + /// `SetReducedDepositPrice` and migration whitelist seeding; enforces + /// matching denom, strictly-less-than-default amount, and amount at least + /// the snapshotted ticketbook size. pub(crate) fn add_reduced_deposit_address( &self, deps: DepsMut, @@ -513,6 +556,8 @@ impl NymEcashContract { Ok(()) } + /// Set or overwrite the reduced-deposit price for a single address. + /// Admin-gated; delegates validation to `add_reduced_deposit_address`. #[sv::msg(exec)] pub fn set_reduced_deposit_price( &self, @@ -534,7 +579,7 @@ impl NymEcashContract { /// 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 + /// 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( @@ -558,6 +603,9 @@ impl NymEcashContract { .add_attribute("address", address)) } + /// **Stubbed.** Always returns `UnimplementedBlacklisting`. The + /// commented-out body shows the intended group-gated propose flow for the + /// blacklist redesign. #[sv::msg(exec)] pub fn propose_to_blacklist( &self, @@ -587,6 +635,9 @@ impl NymEcashContract { // } } + /// **Stubbed.** Always returns `UnimplementedBlacklisting`. The + /// commented-out body shows the intended multisig-gated finalisation for + /// the blacklist redesign. #[sv::msg(exec)] pub fn add_to_blacklist( &self, @@ -611,6 +662,9 @@ impl NymEcashContract { /*===================== =========REPLY========= =====================*/ + /// Dispatch reply messages by id. Surfaces `InvalidReplyId` for any id + /// not matching `BLACKLIST_PROPOSAL_REPLY_ID` or + /// `REDEMPTION_PROPOSAL_REPLY_ID`. #[sv::msg(reply)] #[allow(deprecated)] pub fn reply( @@ -627,6 +681,9 @@ impl NymEcashContract { } } + /// Reply handler for the (currently dead) blacklist propose flow. + /// Reachable from the dispatcher in theory but no public ExecuteMsg path + /// dispatches a SubMsg with `BLACKLIST_PROPOSAL_REPLY_ID` today. #[allow(deprecated)] fn handle_blacklist_proposal_reply( &self, @@ -647,6 +704,9 @@ impl NymEcashContract { Ok(Response::new().add_attribute(PROPOSAL_ID_ATTRIBUTE_NAME, proposal_id.to_string())) } + /// Reply handler for the redemption propose flow. Captures the multisig + /// `proposal_id` from the SubMsg result and re-exposes it as the response + /// `set_data` payload (big-endian `u64`). #[allow(deprecated)] fn handle_redemption_proposal_reply( &self, @@ -664,6 +724,10 @@ impl NymEcashContract { /*===================== =======MIGRATION======= =====================*/ + /// Migration entry point. Refreshes build metadata, gates against + /// downgrades via `cw2::ensure_from_older_version`, and runs + /// `add_tiered_pricing` to backfill the default-tier stats and seed the + /// whitelist atomically. #[sv::msg(migrate)] pub fn migrate( &self, diff --git a/contracts/ecash/src/contract/queued_migrations.rs b/contracts/ecash/src/contract/queued_migrations.rs index 284b8bf81c..5e88b39d43 100644 --- a/contracts/ecash/src/contract/queued_migrations.rs +++ b/contracts/ecash/src/contract/queued_migrations.rs @@ -6,6 +6,11 @@ use cosmwasm_std::DepsMut; use nym_ecash_contract_common::msg::WhitelistedDeposit; use nym_ecash_contract_common::EcashContractError; +/// One-way migration that introduces tiered pricing. Backfills the +/// default-tier stats accumulators from the pre-migration totals (since every +/// pre-migration deposit was at the single default price) and seeds the +/// whitelist. Re-running on already-migrated state would clobber the default +/// counters with figures that include custom-price deposits. pub fn add_tiered_pricing( mut deps: DepsMut, initial_whitelist: Vec, @@ -101,7 +106,7 @@ mod tests { 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. + // 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(); @@ -249,7 +254,7 @@ mod tests { save_pool_counters(deps.as_mut().storage, 0); save_config_and_invariants(&mut deps); - // Equal to default — should fail + // Equal to default - should fail let whitelist = vec![WhitelistedDeposit { address: deps.api.addr_make("alice").to_string(), deposit: coin(DEFAULT_DEPOSIT, DENOM), @@ -264,7 +269,7 @@ mod tests { } ); - // Greater than default — should also fail + // Greater than default - should also fail let whitelist = vec![WhitelistedDeposit { address: deps.api.addr_make("alice").to_string(), deposit: coin(DEFAULT_DEPOSIT + 1, DENOM), diff --git a/contracts/ecash/src/contract/test.rs b/contracts/ecash/src/contract/test.rs index cdf79f6195..847ba7a7eb 100644 --- a/contracts/ecash/src/contract/test.rs +++ b/contracts/ecash/src/contract/test.rs @@ -475,7 +475,7 @@ mod tests { "GLdR2NRVZBiCoCbv4fNqt9wUJZAnNjGXHkx3TjVAUzrK".to_string(), )?; - // alice deposits at the default price — should be treated as a normal deposit + // 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), diff --git a/contracts/ecash/src/deposit.rs b/contracts/ecash/src/deposit.rs index f71b23a55d..62ce15a689 100644 --- a/contracts/ecash/src/deposit.rs +++ b/contracts/ecash/src/deposit.rs @@ -7,6 +7,10 @@ use nym_ecash_contract_common::deposit::DepositId; use nym_ecash_contract_common::{deposit::Deposit, EcashContractError}; use std::ops::Deref; +/// Sequential-id-keyed deposit store. Deposits live under the `"deposit"` +/// raw-bytes namespace (32-byte ed25519 pubkeys, not a `Map`); the +/// `"deposit_ids"` `Item` holds the **next** free id, which after `N` +/// deposits equals `N`. pub(crate) struct DepositStorage { pub(crate) deposit_id_counter: Item, pub(crate) deposits: StoredDeposits, @@ -20,13 +24,17 @@ impl DepositStorage { } } + /// Returns the id of the most recently assigned deposit, or `None` if no deposit has ever been made. + /// + /// The counter stores the *next* available id, so the latest assigned id is `counter - 1`. pub fn latest_deposit( &self, storage: &dyn Storage, ) -> Result, EcashContractError> { - self.deposit_id_counter - .may_load(storage) - .map_err(Into::into) + let Some(counter) = self.deposit_id_counter.may_load(storage)? else { + return Ok(None); + }; + Ok(counter.checked_sub(1)) } /// Returns the total number of deposits ever made. @@ -44,6 +52,10 @@ impl DepositStorage { Ok(id) } + /// Assign the next sequential id, persist the deposit's 32-byte raw ed25519 + /// pubkey under the `"deposit"` namespace, and return the new id. Surfaces + /// `MalformedEd25519Identity` when the supplied bs58 string does not + /// decode to exactly 32 bytes. pub fn save_deposit( &self, storage: &mut dyn Storage, @@ -61,6 +73,8 @@ impl DepositStorage { Ok(id) } + /// Load the deposit at `id`. Returns `None` when the id has not yet been + /// assigned. pub fn try_load_by_id( &self, storage: &dyn Storage, @@ -94,7 +108,9 @@ impl DepositStorage { } } -// a helper structure for storing deposits to bypass json serialisation and use more efficient and compact representation +/// Raw-bytes reader/writer for the `"deposit"` storage namespace. Bypasses +/// `cw_storage_plus::Map` to store deposits as exactly 32 raw bytes per +/// entry (vs. ~44 bytes for the JSON-serialised bs58 representation). pub(crate) struct StoredDeposits; impl StoredDeposits { @@ -138,14 +154,14 @@ mod tests { let _ = storage.next_id(deps.as_mut().storage)?; - // is correctly incremented for each subsequent id + // first deposit got id 0; latest_deposit returns Some(0) let second = storage.latest_deposit(deps.as_ref().storage)?; - assert_eq!(second, Some(1)); + assert_eq!(second, Some(0)); let _ = storage.next_id(deps.as_mut().storage)?; let third = storage.latest_deposit(deps.as_ref().storage)?; - assert_eq!(third, Some(2)); + assert_eq!(third, Some(1)); Ok(()) } diff --git a/contracts/ecash/src/deposit_stats.rs b/contracts/ecash/src/deposit_stats.rs index 3dcc935c28..da022d7c3e 100644 --- a/contracts/ecash/src/deposit_stats.rs +++ b/contracts/ecash/src/deposit_stats.rs @@ -6,6 +6,10 @@ use cw_storage_plus::{Item, Map}; use nym_ecash_contract_common::EcashContractError; use std::collections::HashMap; +/// Tier-stratified deposit accounting. Maintains the invariant +/// `default_count + sum(custom_count_per_account) == total_deposits_made`. +/// Any code that writes to the `"deposit"` namespace MUST go through +/// `new_default_deposit` / `new_reduced_deposit` or it breaks the invariant. pub(crate) struct DepositStatsStorage { /// Total deposits performed with the default price pub(crate) deposits_with_default_price: Item, @@ -30,6 +34,9 @@ impl DepositStatsStorage { } } + /// Bump the global default-price counter + amount accumulator. Called by + /// `DepositTicketBookFunds` whenever the sender paid the default amount + /// (regardless of whether they are also whitelisted for a reduced price). pub(crate) fn new_default_deposit( &self, store: &mut dyn Storage, @@ -47,6 +54,9 @@ impl DepositStatsStorage { Ok(()) } + /// Bump the per-account custom-price counter + amount accumulator. Called + /// by `DepositTicketBookFunds` when a whitelisted sender paid their + /// configured reduced amount. pub(crate) fn new_reduced_deposit( &self, store: &mut dyn Storage, diff --git a/contracts/ecash/src/helpers.rs b/contracts/ecash/src/helpers.rs index ec313af2a6..60b2d35610 100644 --- a/contracts/ecash/src/helpers.rs +++ b/contracts/ecash/src/helpers.rs @@ -18,28 +18,43 @@ use serde::{Deserialize, Serialize}; pub(crate) const CONTRACT_NAME: &str = "crate:nym-ecash"; pub(crate) const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); +/// Runtime configuration persisted at the `"config"` storage key. Set on +/// instantiation; the only field mutable through an execute path is +/// `deposit_amount` (via `UpdateDefaultDepositValue`). #[derive(Serialize, Deserialize, Clone, Debug)] pub struct Config { + /// cw4 group contract referenced by the (stubbed) blacklist proposal flow. pub group_addr: Cw4Contract, + + /// Cosmos SDK address reserved for the future pool-contract transition. + /// Currently never debited. pub holding_account: Addr, - /// Specifies the expected default deposit amount if the sender is not in the whitelisted set. + /// Default per-deposit price. Whitelisted senders may pay this *or* their + /// per-account reduced amount; everyone else must pay this exact value. #[serde(alias = "default_deposit_amount")] pub deposit_amount: Coin, } -//type aliases for easier reasoning +/// Blacklist storage key - a bs58-encoded ed25519 public key. pub(crate) type BlacklistKey = String; + +/// Multisig-issued `proposal_id` returned via the reply pipeline. pub(crate) type ProposalId = u64; -// paged retrieval limits for all blacklist queries and transactions +/// Hard upper bound on the `limit` accepted by paginated blacklist queries. pub(crate) const BLACKLIST_PAGE_MAX_LIMIT: u32 = 75; +/// Default `limit` for paginated blacklist queries when the caller passes None. pub(crate) const BLACKLIST_PAGE_DEFAULT_LIMIT: u32 = 50; -// paged retrieval limits for all deposit queries and transactions +/// Hard upper bound on the `limit` accepted by paginated deposit queries. pub(crate) const DEPOSITS_PAGE_MAX_LIMIT: u32 = 100; +/// Default `limit` for paginated deposit queries when the caller passes None. pub(crate) const DEPOSITS_PAGE_DEFAULT_LIMIT: u32 = 50; +/// Build the cw3 `Propose` SubMsg dispatched by `RequestRedemption`. The +/// embedded message is a self-targeted `RedeemTickets` call that the multisig +/// will execute once the proposal passes. pub(crate) fn create_batch_redemption_proposal( tickets_digest: String, gw: String, @@ -70,6 +85,8 @@ pub(crate) fn create_batch_redemption_proposal( Ok(submsg) } +/// Build the cw3 `Propose` SubMsg for the blacklist flow. **Dead path**: not +/// reachable from any public ExecuteMsg today; preserved for the redesign. pub(crate) fn create_blacklist_proposal( public_key: String, ecash_bandwidth_address: String, @@ -100,6 +117,9 @@ pub(crate) fn create_blacklist_proposal( Ok(submsg) } +/// Extract the multisig-issued `proposal_id` from a cw3 `Propose` reply. +/// Surfaces `MissingProposalId` / `MalformedProposalId` for the typed-failure +/// cases the reply handler distinguishes. pub(crate) trait MultisigReply { fn multisig_proposal_id(&self) -> Result; } diff --git a/contracts/ecash/src/lib.rs b/contracts/ecash/src/lib.rs index 83359af34b..63c9f94dc4 100644 --- a/contracts/ecash/src/lib.rs +++ b/contracts/ecash/src/lib.rs @@ -1,6 +1,16 @@ // Copyright 2024 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 +//! On-chain anchor of the ticketbook credential pipeline. +//! +//! Clients escrow funds via `ExecuteMsg::DepositTicketBookFunds`, which mints a +//! sequential `deposit_id` and persists the depositor-claimed ed25519 identity +//! key for off-chain nym-api signers to read at blind-sign time. The contract +//! does **not** verify control of the ed25519 key - that proof is enforced +//! off-chain by `nym-api/src/ecash/deposit.rs::validate_deposit`. +//! +//! See `openspec/specs/ecash-contract/spec.md` for the normative interface. + mod constants; pub mod contract; mod deposit; diff --git a/contracts/ecash/src/multitest.rs b/contracts/ecash/src/multitest.rs index e157eeda00..f48350d795 100644 --- a/contracts/ecash/src/multitest.rs +++ b/contracts/ecash/src/multitest.rs @@ -211,7 +211,7 @@ fn reduced_price_deposit_end_to_end() { .call(&whitelisted) .unwrap(); - // whitelisted address can also deposit at the default price — + // 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()) diff --git a/contracts/mixnet-vesting-integration-tests/Cargo.toml b/contracts/mixnet-vesting-integration-tests/Cargo.toml deleted file mode 100644 index 361885e353..0000000000 --- a/contracts/mixnet-vesting-integration-tests/Cargo.toml +++ /dev/null @@ -1,34 +0,0 @@ -[package] -name = "mixnet-vesting-integration-tests" -version = "0.1.0" -authors = { workspace = true } -edition = { workspace = true } -license = { workspace = true } -repository = { workspace = true } -publish = false - -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html - -[dependencies] - -# cosmwasm dependencies -cosmwasm-std = { workspace = true } -cw-multi-test = { workspace = true } - -# contracts dependencies -nym-mixnet-contract-common = { workspace = true } -nym-vesting-contract-common = { workspace = true } -nym-contracts-common = { workspace = true } - -nym-mixnet-contract = { workspace = true } -nym-vesting-contract = { workspace = true } - -# other local dependencies -nym-crypto = { workspace = true, features = ["asymmetric", "rand"] } - -# external dependencies -rand_chacha = "0.3" - -[[test]] -name = "mixnet-vesting-test" -path = "src/tests.rs" diff --git a/contracts/mixnet-vesting-integration-tests/src/support/fixtures.rs b/contracts/mixnet-vesting-integration-tests/src/support/fixtures.rs deleted file mode 100644 index 2df1a87e35..0000000000 --- a/contracts/mixnet-vesting-integration-tests/src/support/fixtures.rs +++ /dev/null @@ -1,39 +0,0 @@ -// Copyright 2023 - Nym Technologies SA -// SPDX-License-Identifier: Apache-2.0 - -use crate::support::setup::{MIX_DENOM, REWARDING_VALIDATOR}; -use cosmwasm_std::Decimal; -use nym_contracts_common::Percent; -use nym_mixnet_contract_common::reward_params::RewardedSetParams; -use nym_mixnet_contract_common::InitialRewardingParams; -use std::time::Duration; - -pub fn default_mixnet_init_msg() -> nym_mixnet_contract_common::InstantiateMsg { - nym_mixnet_contract_common::InstantiateMsg { - rewarding_validator_address: REWARDING_VALIDATOR.to_string(), - vesting_contract_address: "placeholder".to_string(), - rewarding_denom: MIX_DENOM.to_string(), - epochs_in_interval: 720, - epoch_duration: Duration::from_secs(60 * 60), - initial_rewarding_params: InitialRewardingParams { - initial_reward_pool: Decimal::from_atomics(250_000_000_000_000u128, 0).unwrap(), - initial_staking_supply: Decimal::from_atomics(223_000_000_000_000u128, 0).unwrap(), - staking_supply_scale_factor: Percent::hundred(), - sybil_resistance: Percent::from_percentage_value(30).unwrap(), - active_set_work_factor: Decimal::from_atomics(10u32, 0).unwrap(), - interval_pool_emission: Percent::from_percentage_value(2).unwrap(), - rewarded_set_params: RewardedSetParams { - entry_gateways: 70, - exit_gateways: 50, - mixnodes: 120, - standby: 0, - }, - }, - current_nym_node_version: "1.1.10".to_string(), - version_score_weights: Default::default(), - version_score_params: Default::default(), - profit_margin: Default::default(), - interval_operating_cost: Default::default(), - key_validity_in_epochs: None, - } -} diff --git a/contracts/mixnet-vesting-integration-tests/src/support/helpers.rs b/contracts/mixnet-vesting-integration-tests/src/support/helpers.rs deleted file mode 100644 index a714ccf493..0000000000 --- a/contracts/mixnet-vesting-integration-tests/src/support/helpers.rs +++ /dev/null @@ -1,62 +0,0 @@ -// Copyright 2023 - Nym Technologies SA -// SPDX-License-Identifier: Apache-2.0 - -use crate::support::setup::{MIXNET_OWNER, MIX_DENOM, REWARDING_VALIDATOR, VESTING_OWNER}; -use cosmwasm_std::{coin, coins, Addr, Coin, Empty}; -use cw_multi_test::{Contract, ContractWrapper}; -use rand_chacha::rand_core::SeedableRng; -use rand_chacha::ChaCha20Rng; - -#[allow(unused)] -pub fn mixnet_owner() -> Addr { - Addr::unchecked(MIXNET_OWNER) -} - -#[allow(unused)] -pub fn vesting_owner() -> Addr { - Addr::unchecked(VESTING_OWNER) -} - -#[allow(unused)] -pub fn rewarding_validator() -> Addr { - Addr::unchecked(REWARDING_VALIDATOR) -} - -#[allow(unused)] -pub fn mix_coins(amount: u128) -> Vec { - coins(amount, MIX_DENOM) -} - -#[allow(unused)] -pub fn mix_coin(amount: u128) -> Coin { - coin(amount, MIX_DENOM) -} - -#[allow(unused)] -pub fn test_rng() -> ChaCha20Rng { - let dummy_seed = [42u8; 32]; - ChaCha20Rng::from_seed(dummy_seed) -} - -#[allow(unused)] -pub fn mixnet_contract_wrapper() -> Box> { - Box::new( - ContractWrapper::new( - mixnet_contract::contract::execute, - mixnet_contract::contract::instantiate, - mixnet_contract::contract::query, - ) - .with_migrate(mixnet_contract::contract::migrate), - ) -} - -pub fn vesting_contract_wrapper() -> Box> { - Box::new( - ContractWrapper::new( - vesting_contract::contract::execute, - vesting_contract::contract::instantiate, - vesting_contract::contract::query, - ) - .with_migrate(vesting_contract::contract::migrate), - ) -} diff --git a/contracts/mixnet-vesting-integration-tests/src/support/mod.rs b/contracts/mixnet-vesting-integration-tests/src/support/mod.rs deleted file mode 100644 index ea9fb30983..0000000000 --- a/contracts/mixnet-vesting-integration-tests/src/support/mod.rs +++ /dev/null @@ -1,6 +0,0 @@ -// Copyright 2023 - Nym Technologies SA -// SPDX-License-Identifier: Apache-2.0 - -pub mod fixtures; -pub mod helpers; -pub mod setup; diff --git a/contracts/mixnet-vesting-integration-tests/src/support/setup.rs b/contracts/mixnet-vesting-integration-tests/src/support/setup.rs deleted file mode 100644 index 90aa13526f..0000000000 --- a/contracts/mixnet-vesting-integration-tests/src/support/setup.rs +++ /dev/null @@ -1,480 +0,0 @@ -// Copyright 2023 - Nym Technologies SA -// SPDX-License-Identifier: Apache-2.0 - -use crate::support::fixtures; -use crate::support::helpers::{ - mixnet_contract_wrapper, rewarding_validator, test_rng, vesting_contract_wrapper, -}; -use cosmwasm_std::{coins, Addr, Coin, Decimal, Timestamp}; -use cw_multi_test::{App, AppBuilder, Executor}; -use nym_contracts_common::signing::{ContractMessageContent, MessageSignature, Nonce}; -use nym_crypto::asymmetric::ed25519; -use nym_mixnet_contract_common::nym_node::{EpochAssignmentResponse, Role, RolesMetadataResponse}; -use nym_mixnet_contract_common::reward_params::{NodeRewardingParameters, Performance}; -use nym_mixnet_contract_common::{ - CurrentIntervalResponse, MixnodeBondingPayload, NodeCostParams, RewardedSet, RewardingParams, - RoleAssignment, SignableMixNodeBondingMsg, -}; -use nym_mixnet_contract_common::{ - ExecuteMsg as MixnetExecuteMsg, MixNode, QueryMsg as MixnetQueryMsg, -}; -use rand_chacha::ChaCha20Rng; -use std::collections::HashMap; - -// our global accounts that should always get some coins at the start -pub const MIXNET_OWNER: &str = "mixnet-owner"; -pub const VESTING_OWNER: &str = "vesting-owner"; -pub const REWARDING_VALIDATOR: &str = "rewarding-validator"; -pub const MIX_DENOM: &str = "unym"; - -#[allow(unused)] -pub struct ContractInstantiationResult { - mixnet_contract_address: Addr, - vesting_contract_address: Addr, -} - -#[allow(unused)] -pub struct TestSetupBuilder { - mixnet_init_msg: nym_mixnet_contract_common::InstantiateMsg, - initial_balances: HashMap>, -} - -#[allow(unused)] -impl TestSetupBuilder { - pub fn new() -> Self { - TestSetupBuilder { - mixnet_init_msg: fixtures::default_mixnet_init_msg(), - initial_balances: Default::default(), - } - } - - pub fn with_mixnet_init_msg( - mut self, - mixnet_init_msg: nym_mixnet_contract_common::InstantiateMsg, - ) -> Self { - self.mixnet_init_msg = mixnet_init_msg; - self - } - - pub fn with_initial_balances(mut self, initial_balances: HashMap>) -> Self { - self.initial_balances = initial_balances; - self - } - - pub fn with_initial_balance(mut self, addr: impl Into, balance: Vec) -> Self { - self.initial_balances.insert(Addr::unchecked(addr), balance); - self - } - - pub fn build(self) -> TestSetup { - TestSetup::new(self.initial_balances, self.mixnet_init_msg) - } -} - -#[allow(unused)] -pub struct TestSetup { - pub app: App, - pub rng: ChaCha20Rng, - - pub mixnet_contract: Addr, -} - -#[allow(unused)] -impl TestSetup { - pub fn new_simple() -> Self { - TestSetup::new(Default::default(), fixtures::default_mixnet_init_msg()) - } - - pub fn new( - initial_balances: HashMap>, - custom_mixnet_init: nym_mixnet_contract_common::InstantiateMsg, - ) -> Self { - let (app, contracts) = instantiate_contracts(initial_balances, Some(custom_mixnet_init)); - TestSetup { - app, - rng: test_rng(), - mixnet_contract: contracts.mixnet_contract_address, - } - } - - pub fn mixnet_contract(&self) -> Addr { - self.mixnet_contract.clone() - } - - pub fn skip_to_current_epoch_end(&mut self) { - let current_interval: CurrentIntervalResponse = self - .app - .wrap() - .query_wasm_smart( - self.mixnet_contract(), - &MixnetQueryMsg::GetCurrentIntervalDetails {}, - ) - .unwrap(); - let epoch_end = current_interval.interval.current_epoch_end_unix_timestamp(); - - self.app.update_block(|current_block| { - // skip few blocks just in case - current_block.height += 10; - current_block.time = Timestamp::from_seconds(epoch_end as u64) - }) - } - - fn get_rewarded_set(&self) -> RewardedSet { - let metadata: RolesMetadataResponse = self - .app - .wrap() - .query_wasm_smart( - self.mixnet_contract(), - &MixnetQueryMsg::GetRewardedSetMetadata {}, - ) - .unwrap(); - - let entry: EpochAssignmentResponse = self - .app - .wrap() - .query_wasm_smart( - self.mixnet_contract(), - &MixnetQueryMsg::GetRoleAssignment { - role: Role::EntryGateway, - }, - ) - .unwrap(); - assert_eq!(entry.epoch_id, metadata.metadata.epoch_id); - - let exit: EpochAssignmentResponse = self - .app - .wrap() - .query_wasm_smart( - self.mixnet_contract(), - &MixnetQueryMsg::GetRoleAssignment { - role: Role::ExitGateway, - }, - ) - .unwrap(); - assert_eq!(exit.epoch_id, metadata.metadata.epoch_id); - - let layer1: EpochAssignmentResponse = self - .app - .wrap() - .query_wasm_smart( - self.mixnet_contract(), - &MixnetQueryMsg::GetRoleAssignment { role: Role::Layer1 }, - ) - .unwrap(); - assert_eq!(layer1.epoch_id, metadata.metadata.epoch_id); - - let layer2: EpochAssignmentResponse = self - .app - .wrap() - .query_wasm_smart( - self.mixnet_contract(), - &MixnetQueryMsg::GetRoleAssignment { role: Role::Layer2 }, - ) - .unwrap(); - assert_eq!(layer2.epoch_id, metadata.metadata.epoch_id); - - let layer3: EpochAssignmentResponse = self - .app - .wrap() - .query_wasm_smart( - self.mixnet_contract(), - &MixnetQueryMsg::GetRoleAssignment { role: Role::Layer3 }, - ) - .unwrap(); - assert_eq!(layer3.epoch_id, metadata.metadata.epoch_id); - - let standby: EpochAssignmentResponse = self - .app - .wrap() - .query_wasm_smart( - self.mixnet_contract(), - &MixnetQueryMsg::GetRoleAssignment { - role: Role::Standby, - }, - ) - .unwrap(); - assert_eq!(standby.epoch_id, metadata.metadata.epoch_id); - - RewardedSet { - entry_gateways: entry.nodes, - exit_gateways: exit.nodes, - layer1: layer1.nodes, - layer2: layer2.nodes, - layer3: layer3.nodes, - standby: standby.nodes, - } - } - - pub fn full_mixnet_epoch_operations(&mut self) { - let rewarded_set = self.get_rewarded_set(); - - let current_params: RewardingParams = self - .app - .wrap() - .query_wasm_smart( - self.mixnet_contract(), - &MixnetQueryMsg::GetRewardingParams {}, - ) - .unwrap(); - // TODO: handle paging - - // begin epoch transition - self.app - .execute_contract( - rewarding_validator(), - self.mixnet_contract(), - &MixnetExecuteMsg::BeginEpochTransition {}, - &[], - ) - .unwrap(); - - let work = - Decimal::one() / Decimal::from_ratio(rewarded_set.rewarded_set_size() as u64, 1u64); - let params = NodeRewardingParameters::new(Performance::hundred(), work); - - let mut nodes = rewarded_set - .layer1 - .iter() - .chain(rewarded_set.layer2.iter()) - .chain(rewarded_set.layer3.iter()) - .chain(rewarded_set.entry_gateways.iter()) - .chain(rewarded_set.exit_gateways.iter()) - .chain(rewarded_set.standby.iter()) - .copied() - .collect::>(); - - nodes.sort(); - - // reward - for (node_id) in nodes { - self.app - .execute_contract( - rewarding_validator(), - self.mixnet_contract(), - &MixnetExecuteMsg::RewardNode { node_id, params }, - &[], - ) - .unwrap(); - } - - // events - self.app - .execute_contract( - rewarding_validator(), - self.mixnet_contract(), - &MixnetExecuteMsg::ReconcileEpochEvents { limit: None }, - &[], - ) - .unwrap(); - - // don't bother changing the active set, use the same node for update and advance - - self.app - .execute_contract( - rewarding_validator(), - self.mixnet_contract(), - &MixnetExecuteMsg::AssignRoles { - assignment: RoleAssignment { - role: Role::EntryGateway, - nodes: rewarded_set.entry_gateways, - }, - }, - &[], - ) - .unwrap(); - - self.app - .execute_contract( - rewarding_validator(), - self.mixnet_contract(), - &MixnetExecuteMsg::AssignRoles { - assignment: RoleAssignment { - role: Role::ExitGateway, - nodes: rewarded_set.exit_gateways, - }, - }, - &[], - ) - .unwrap(); - - self.app - .execute_contract( - rewarding_validator(), - self.mixnet_contract(), - &MixnetExecuteMsg::AssignRoles { - assignment: RoleAssignment { - role: Role::Layer1, - nodes: rewarded_set.layer1, - }, - }, - &[], - ) - .unwrap(); - - self.app - .execute_contract( - rewarding_validator(), - self.mixnet_contract(), - &MixnetExecuteMsg::AssignRoles { - assignment: RoleAssignment { - role: Role::Layer2, - nodes: rewarded_set.layer2, - }, - }, - &[], - ) - .unwrap(); - - self.app - .execute_contract( - rewarding_validator(), - self.mixnet_contract(), - &MixnetExecuteMsg::AssignRoles { - assignment: RoleAssignment { - role: Role::Layer3, - nodes: rewarded_set.layer3, - }, - }, - &[], - ) - .unwrap(); - - self.app - .execute_contract( - rewarding_validator(), - self.mixnet_contract(), - &MixnetExecuteMsg::AssignRoles { - assignment: RoleAssignment { - role: Role::Standby, - nodes: rewarded_set.standby, - }, - }, - &[], - ) - .unwrap(); - } - - pub fn advance_mixnet_epoch(&mut self) { - self.skip_to_current_epoch_end(); - self.full_mixnet_epoch_operations(); - } - - pub fn valid_mixnode_with_sig( - &mut self, - owner: &str, - cost_params: NodeCostParams, - stake: Coin, - ) -> (MixNode, MessageSignature) { - let signing_nonce: Nonce = self - .app - .wrap() - .query_wasm_smart( - self.mixnet_contract(), - &MixnetQueryMsg::GetSigningNonce { - address: owner.to_string(), - }, - ) - .unwrap(); - - let keypair = ed25519::KeyPair::new(&mut self.rng); - let identity_key = keypair.public_key().to_base58_string(); - let legit_sphinx_keys = nym_crypto::asymmetric::x25519::KeyPair::new(&mut self.rng); - - let mixnode = MixNode { - identity_key, - sphinx_key: legit_sphinx_keys.public_key().to_base58_string(), - host: "mix.node.org".to_string(), - mix_port: 1789, - verloc_port: 1790, - http_api_port: 8000, - version: "1.1.14".to_string(), - }; - - let payload = MixnodeBondingPayload::new(mixnode.clone(), cost_params); - let content = ContractMessageContent::new(Addr::unchecked(owner), vec![stake], payload); - let sign_payload = SignableMixNodeBondingMsg::new(signing_nonce, content); - let plaintext = sign_payload.to_plaintext().unwrap(); - let signature = keypair.private_key().sign(plaintext); - let msg_signature = MessageSignature::from(signature.to_bytes().as_ref()); - - (mixnode, msg_signature) - } -} - -pub fn instantiate_contracts( - mut initial_funds: HashMap>, - custom_mixnet_init: Option, -) -> (App, ContractInstantiationResult) { - // add our global addresses to the map - initial_funds.insert( - Addr::unchecked(MIXNET_OWNER), - coins(100_000_000_000, MIX_DENOM), - ); - - initial_funds.insert( - Addr::unchecked(VESTING_OWNER), - coins(100_000_000_000, MIX_DENOM), - ); - - initial_funds.insert( - Addr::unchecked(REWARDING_VALIDATOR), - coins(1_000_000_000_000, MIX_DENOM), - ); - - let mut app = AppBuilder::new().build(|router, _api, storage| { - for (addr, funds) in initial_funds { - router - .bank - .init_balance(storage, &addr, funds.clone()) - .unwrap() - } - }); - - let mixnet_code_id = app.store_code(mixnet_contract_wrapper()); - let vesting_code_id = app.store_code(vesting_contract_wrapper()); - - let mixnet_contract_address = app - .instantiate_contract( - mixnet_code_id, - Addr::unchecked(MIXNET_OWNER), - &custom_mixnet_init.unwrap_or(fixtures::default_mixnet_init_msg()), - &[], - "mixnet-contract", - Some(MIXNET_OWNER.to_string()), - ) - .unwrap(); - - let vesting_contract_address = app - .instantiate_contract( - vesting_code_id, - Addr::unchecked(VESTING_OWNER), - &nym_vesting_contract_common::InitMsg { - mixnet_contract_address: mixnet_contract_address.to_string(), - mix_denom: MIX_DENOM.to_string(), - }, - &[], - "vesting-contract", - Some(VESTING_OWNER.to_string()), - ) - .unwrap(); - - // now fix up vesting contract address... - app.migrate_contract( - Addr::unchecked(MIXNET_OWNER), - mixnet_contract_address.clone(), - &nym_mixnet_contract_common::MigrateMsg { - vesting_contract_address: Some(vesting_contract_address.to_string()), - unsafe_skip_state_updates: Some(true), - }, - mixnet_code_id, - ) - .unwrap(); - - ( - app, - ContractInstantiationResult { - mixnet_contract_address, - vesting_contract_address, - }, - ) -} diff --git a/contracts/mixnet-vesting-integration-tests/src/tests.rs b/contracts/mixnet-vesting-integration-tests/src/tests.rs deleted file mode 100644 index 826adcfd53..0000000000 --- a/contracts/mixnet-vesting-integration-tests/src/tests.rs +++ /dev/null @@ -1,4 +0,0 @@ -// Copyright 2023 - Nym Technologies SA -// SPDX-License-Identifier: Apache-2.0 - -mod support; diff --git a/contracts/mixnet/Cargo.toml b/contracts/mixnet/Cargo.toml index 20222063f6..88212ffa27 100644 --- a/contracts/mixnet/Cargo.toml +++ b/contracts/mixnet/Cargo.toml @@ -28,6 +28,7 @@ crate-type = ["cdylib", "rlib"] [dependencies] mixnet-contract-common = { workspace = true } vesting-contract-common = { workspace = true } +nym-node-families-contract-common = { workspace = true } nym-contracts-common = { workspace = true } nym-contracts-common-testing = { workspace = true, optional = true } @@ -41,6 +42,8 @@ bs58 = { workspace = true } serde = { workspace = true, default-features = false, features = ["derive"] } semver = { workspace = true } +nym-crypto = { workspace = true, optional = true } + [dev-dependencies] anyhow.workspace = true @@ -56,7 +59,7 @@ nym-contracts-common-testing = { workspace = true } [features] default = [] contract-testing = ["mixnet-contract-common/contract-testing"] -testable-mixnet-contract = ["nym-contracts-common-testing"] +testable-mixnet-contract = ["nym-contracts-common-testing", "nym-crypto", "nym-crypto/asymmetric", "nym-crypto/rand"] schema-gen = ["mixnet-contract-common/schema", "cosmwasm-schema"] [lints] diff --git a/contracts/mixnet/schema/nym-mixnet-contract.json b/contracts/mixnet/schema/nym-mixnet-contract.json index cf36563949..1c41e08746 100644 --- a/contracts/mixnet/schema/nym-mixnet-contract.json +++ b/contracts/mixnet/schema/nym-mixnet-contract.json @@ -11,6 +11,7 @@ "epoch_duration", "epochs_in_interval", "initial_rewarding_params", + "node_families_contract_address", "rewarding_denom", "rewarding_validator_address", "vesting_contract_address" @@ -50,6 +51,9 @@ "format": "uint32", "minimum": 0.0 }, + "node_families_contract_address": { + "type": "string" + }, "profit_margin": { "default": { "maximum": "1", @@ -1267,6 +1271,81 @@ } }, "additionalProperties": false + }, + { + "description": "Admin-only: forcibly migrate the vested mixnode owned by `owner`. Used to drain the last vested entries so the mixnet contract can drop its dependency on the vesting contract.", + "type": "object", + "required": [ + "admin_migrate_vested_mix_node" + ], + "properties": { + "admin_migrate_vested_mix_node": { + "type": "object", + "required": [ + "owner" + ], + "properties": { + "owner": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Admin-only: forcibly migrate the vested delegation `(mix_id, owner)`. Used to drain the last vested entries so the mixnet contract can drop its dependency on the vesting contract.", + "type": "object", + "required": [ + "admin_migrate_vested_delegation" + ], + "properties": { + "admin_migrate_vested_delegation": { + "type": "object", + "required": [ + "mix_id", + "owner" + ], + "properties": { + "mix_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "owner": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Admin-only: batch variant of [`ExecuteMsg::AdminMigrateVestedDelegation`]. Reverts the entire batch on the first error, so callers should treat it as all-or-nothing.", + "type": "object", + "required": [ + "admin_batch_migrate_vested_delegations" + ], + "properties": { + "admin_batch_migrate_vested_delegations": { + "type": "object", + "required": [ + "entries" + ], + "properties": { + "entries": { + "type": "array", + "items": { + "$ref": "#/definitions/VestedDelegationMigrationEntry" + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false } ], "definitions": { @@ -1958,6 +2037,24 @@ } }, "additionalProperties": false + }, + "VestedDelegationMigrationEntry": { + "type": "object", + "required": [ + "mix_id", + "owner" + ], + "properties": { + "mix_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "owner": { + "type": "string" + } + }, + "additionalProperties": false } } }, @@ -3509,7 +3606,13 @@ "$schema": "http://json-schema.org/draft-07/schema#", "title": "MigrateMsg", "type": "object", + "required": [ + "node_families_contract_address" + ], "properties": { + "node_families_contract_address": { + "type": "string" + }, "unsafe_skip_state_updates": { "type": [ "boolean", @@ -10774,12 +10877,21 @@ "description": "The current state of the mixnet contract.", "type": "object", "required": [ + "node_families_contract_address", "params", "rewarding_denom", "rewarding_validator_address", "vesting_contract_address" ], "properties": { + "node_families_contract_address": { + "description": "Address of the node families contract. It is called whenever nym-node unbonds so that it could be removed from any family it belongs to.", + "allOf": [ + { + "$ref": "#/definitions/Addr" + } + ] + }, "owner": { "description": "Address of the contract owner.", "default": null, diff --git a/contracts/mixnet/schema/raw/execute.json b/contracts/mixnet/schema/raw/execute.json index 8884a73158..8e4daa0564 100644 --- a/contracts/mixnet/schema/raw/execute.json +++ b/contracts/mixnet/schema/raw/execute.json @@ -976,6 +976,81 @@ } }, "additionalProperties": false + }, + { + "description": "Admin-only: forcibly migrate the vested mixnode owned by `owner`. Used to drain the last vested entries so the mixnet contract can drop its dependency on the vesting contract.", + "type": "object", + "required": [ + "admin_migrate_vested_mix_node" + ], + "properties": { + "admin_migrate_vested_mix_node": { + "type": "object", + "required": [ + "owner" + ], + "properties": { + "owner": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Admin-only: forcibly migrate the vested delegation `(mix_id, owner)`. Used to drain the last vested entries so the mixnet contract can drop its dependency on the vesting contract.", + "type": "object", + "required": [ + "admin_migrate_vested_delegation" + ], + "properties": { + "admin_migrate_vested_delegation": { + "type": "object", + "required": [ + "mix_id", + "owner" + ], + "properties": { + "mix_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "owner": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Admin-only: batch variant of [`ExecuteMsg::AdminMigrateVestedDelegation`]. Reverts the entire batch on the first error, so callers should treat it as all-or-nothing.", + "type": "object", + "required": [ + "admin_batch_migrate_vested_delegations" + ], + "properties": { + "admin_batch_migrate_vested_delegations": { + "type": "object", + "required": [ + "entries" + ], + "properties": { + "entries": { + "type": "array", + "items": { + "$ref": "#/definitions/VestedDelegationMigrationEntry" + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false } ], "definitions": { @@ -1667,6 +1742,24 @@ } }, "additionalProperties": false + }, + "VestedDelegationMigrationEntry": { + "type": "object", + "required": [ + "mix_id", + "owner" + ], + "properties": { + "mix_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "owner": { + "type": "string" + } + }, + "additionalProperties": false } } } diff --git a/contracts/mixnet/schema/raw/instantiate.json b/contracts/mixnet/schema/raw/instantiate.json index b17e1ae6bf..52caa8a639 100644 --- a/contracts/mixnet/schema/raw/instantiate.json +++ b/contracts/mixnet/schema/raw/instantiate.json @@ -7,6 +7,7 @@ "epoch_duration", "epochs_in_interval", "initial_rewarding_params", + "node_families_contract_address", "rewarding_denom", "rewarding_validator_address", "vesting_contract_address" @@ -46,6 +47,9 @@ "format": "uint32", "minimum": 0.0 }, + "node_families_contract_address": { + "type": "string" + }, "profit_margin": { "default": { "maximum": "1", diff --git a/contracts/mixnet/schema/raw/migrate.json b/contracts/mixnet/schema/raw/migrate.json index 57f0d2acdb..757f74d146 100644 --- a/contracts/mixnet/schema/raw/migrate.json +++ b/contracts/mixnet/schema/raw/migrate.json @@ -2,7 +2,13 @@ "$schema": "http://json-schema.org/draft-07/schema#", "title": "MigrateMsg", "type": "object", + "required": [ + "node_families_contract_address" + ], "properties": { + "node_families_contract_address": { + "type": "string" + }, "unsafe_skip_state_updates": { "type": [ "boolean", diff --git a/contracts/mixnet/schema/raw/response_to_get_state.json b/contracts/mixnet/schema/raw/response_to_get_state.json index c4a10bdb80..35501057aa 100644 --- a/contracts/mixnet/schema/raw/response_to_get_state.json +++ b/contracts/mixnet/schema/raw/response_to_get_state.json @@ -4,12 +4,21 @@ "description": "The current state of the mixnet contract.", "type": "object", "required": [ + "node_families_contract_address", "params", "rewarding_denom", "rewarding_validator_address", "vesting_contract_address" ], "properties": { + "node_families_contract_address": { + "description": "Address of the node families contract. It is called whenever nym-node unbonds so that it could be removed from any family it belongs to.", + "allOf": [ + { + "$ref": "#/definitions/Addr" + } + ] + }, "owner": { "description": "Address of the contract owner.", "default": null, diff --git a/contracts/mixnet/src/contract.rs b/contracts/mixnet/src/contract.rs index 0703e6dc23..f86b9a49f8 100644 --- a/contracts/mixnet/src/contract.rs +++ b/contracts/mixnet/src/contract.rs @@ -5,7 +5,7 @@ use crate::constants::INITIAL_PLEDGE_AMOUNT; use crate::interval::storage as interval_storage; use crate::mixnet_contract_settings::storage as mixnet_params_storage; use crate::nodes::storage as nymnodes_storage; -use crate::queued_migrations::introduce_key_rotation_id; +use crate::queued_migrations::introduce_node_families_contract; use crate::rewards::storage::RewardingStorage; use cosmwasm_std::{ entry_point, to_json_binary, Addr, Coin, Deps, DepsMut, Env, MessageInfo, QueryResponse, @@ -28,6 +28,7 @@ fn default_initial_state( owner: Addr, rewarding_validator_address: Addr, vesting_contract_address: Addr, + node_families_contract_address: Addr, ) -> ContractState { // we have to temporarily preserve this functionalities until it can be removed #[allow(deprecated)] @@ -35,6 +36,7 @@ fn default_initial_state( owner: Some(owner), rewarding_validator_address, vesting_contract_address, + node_families_contract_address, rewarding_denom: msg.rewarding_denom.clone(), params: ContractStateParams { delegations_params: DelegationsParams { @@ -90,11 +92,15 @@ pub fn instantiate( let rewarding_validator_address = deps.api.addr_validate(&msg.rewarding_validator_address)?; let vesting_contract_address = deps.api.addr_validate(&msg.vesting_contract_address)?; + let node_families_contract_address = deps + .api + .addr_validate(&msg.node_families_contract_address)?; let state = default_initial_state( &msg, info.sender.clone(), rewarding_validator_address.clone(), vesting_contract_address, + node_families_contract_address, ); let starting_interval = Interval::init_interval(msg.epochs_in_interval, msg.epoch_duration, &env); @@ -301,6 +307,19 @@ pub fn execute( ExecuteMsg::MigrateVestedDelegation { mix_id } => { crate::vesting_migration::try_migrate_vested_delegation(deps, env, info, mix_id) } + ExecuteMsg::AdminMigrateVestedMixNode { owner } => { + crate::vesting_migration::try_admin_migrate_vested_mixnode(deps, info, owner) + } + ExecuteMsg::AdminMigrateVestedDelegation { mix_id, owner } => { + crate::vesting_migration::try_admin_migrate_vested_delegation( + deps, env, info, mix_id, owner, + ) + } + ExecuteMsg::AdminBatchMigrateVestedDelegations { entries } => { + crate::vesting_migration::try_admin_batch_migrate_vested_delegations( + deps, env, info, entries, + ) + } // legacy vesting ExecuteMsg::BondMixnodeOnBehalf { .. } @@ -629,7 +648,10 @@ pub fn migrate( let skip_state_updates = msg.unsafe_skip_state_updates.unwrap_or(false); if !skip_state_updates { - introduce_key_rotation_id(deps.branch())?; + let addr = deps + .api + .addr_validate(&msg.node_families_contract_address)?; + introduce_node_families_contract(deps.branch(), addr)?; } // due to circular dependency on contract addresses (i.e. mixnet contract requiring vesting contract address @@ -668,6 +690,7 @@ mod tests { let init_msg = InstantiateMsg { rewarding_validator_address: deps.api.addr_make("foomp123").to_string(), vesting_contract_address: deps.api.addr_make("bar456").to_string(), + node_families_contract_address: deps.api.addr_make("baz789").to_string(), rewarding_denom: "uatom".to_string(), epochs_in_interval: 1234, epoch_duration: Duration::from_secs(4321), @@ -708,6 +731,7 @@ mod tests { owner: Some(deps.api.addr_make("sender")), rewarding_validator_address: deps.api.addr_make("foomp123"), vesting_contract_address: deps.api.addr_make("bar456"), + node_families_contract_address: deps.api.addr_make("baz789"), rewarding_denom: "uatom".into(), params: ContractStateParams { delegations_params: DelegationsParams { diff --git a/contracts/mixnet/src/mixnet_contract_settings/queries.rs b/contracts/mixnet/src/mixnet_contract_settings/queries.rs index 5963dbf0a2..5ea711a1d0 100644 --- a/contracts/mixnet/src/mixnet_contract_settings/queries.rs +++ b/contracts/mixnet/src/mixnet_contract_settings/queries.rs @@ -71,7 +71,7 @@ pub(crate) fn query_current_nym_node_version( pub(crate) mod tests { use super::*; use crate::support::tests::test_helpers; - use cosmwasm_std::{coin, Addr}; + use cosmwasm_std::coin; use mixnet_contract_common::{ConfigScoreParams, DelegationsParams, OperatorsParams}; #[test] @@ -80,9 +80,10 @@ pub(crate) mod tests { #[allow(deprecated)] let dummy_state = ContractState { - owner: Some(Addr::unchecked("foomp")), - rewarding_validator_address: Addr::unchecked("monitor"), - vesting_contract_address: Addr::unchecked("foomp"), + owner: Some(deps.api.addr_make("foomp")), + rewarding_validator_address: deps.api.addr_make("monitor"), + vesting_contract_address: deps.api.addr_make("foomp"), + node_families_contract_address: deps.api.addr_make("bar"), rewarding_denom: "unym".to_string(), params: ContractStateParams { delegations_params: DelegationsParams { diff --git a/contracts/mixnet/src/mixnet_contract_settings/storage.rs b/contracts/mixnet/src/mixnet_contract_settings/storage.rs index edf511e3cd..98fb47c68b 100644 --- a/contracts/mixnet/src/mixnet_contract_settings/storage.rs +++ b/contracts/mixnet/src/mixnet_contract_settings/storage.rs @@ -156,6 +156,14 @@ pub(crate) fn vesting_contract_address(storage: &dyn Storage) -> Result Result { + Ok(CONTRACT_STATE + .load(storage) + .map(|state| state.node_families_contract_address)?) +} + pub(crate) fn state_params( storage: &dyn Storage, ) -> Result { diff --git a/contracts/mixnet/src/nodes/transactions.rs b/contracts/mixnet/src/nodes/transactions.rs index 646bd48dd9..96b043935f 100644 --- a/contracts/mixnet/src/nodes/transactions.rs +++ b/contracts/mixnet/src/nodes/transactions.rs @@ -16,7 +16,7 @@ use crate::support::helpers::{ ensure_epoch_in_progress_state, ensure_no_existing_bond, ensure_operating_cost_within_range, ensure_profit_margin_within_range, validate_pledge, }; -use cosmwasm_std::{coin, Coin, DepsMut, Env, MessageInfo, Response}; +use cosmwasm_std::{coin, wasm_execute, Coin, DepsMut, Env, MessageInfo, Response}; use mixnet_contract_common::error::MixnetContractError; use mixnet_contract_common::events::{ new_nym_node_bonding_event, new_pending_cost_params_update_event, @@ -29,6 +29,7 @@ use mixnet_contract_common::{ PendingIntervalEventKind, }; use nym_contracts_common::signing::{MessageSignature, SigningPurpose}; +use nym_node_families_contract_common::msg::ExecuteMsg as NodeFamiliesExecuteMsg; use serde::Serialize; pub fn try_add_nym_node( @@ -147,13 +148,24 @@ pub(crate) fn try_remove_nym_node( }; interval_storage::push_new_epoch_event(deps.storage, &env, epoch_event)?; - Ok( - Response::new().add_event(new_pending_nym_node_unbonding_event( + // send message to the node families contract to remove this node from any family it might be a member of + let node_families_contract_addr = + mixnet_params_storage::node_families_contract_address(deps.storage)?; + let remove_from_family_exec = wasm_execute( + node_families_contract_addr, + &NodeFamiliesExecuteMsg::OnNymNodeUnbond { + node_id: existing_bond.node_id, + }, + vec![], + )?; + + Ok(Response::new() + .add_message(remove_from_family_exec) + .add_event(new_pending_nym_node_unbonding_event( &existing_bond.owner, existing_bond.identity(), existing_bond.node_id, - )), - ) + ))) } pub(crate) fn try_update_node_config( diff --git a/contracts/mixnet/src/queued_migrations.rs b/contracts/mixnet/src/queued_migrations.rs index 4f9690a87e..32d87c1ab7 100644 --- a/contracts/mixnet/src/queued_migrations.rs +++ b/contracts/mixnet/src/queued_migrations.rs @@ -1,21 +1,39 @@ // Copyright 2022-2025 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 -use crate::interval::storage as interval_storage; -use crate::nodes::storage as nymnodes_storage; -use cosmwasm_std::DepsMut; +use crate::constants::CONTRACT_STATE_KEY; +use crate::mixnet_contract_settings::storage as mixnet_params_storage; +use cosmwasm_std::{Addr, DepsMut}; +use cw_storage_plus::Item; use mixnet_contract_common::error::MixnetContractError; -use mixnet_contract_common::KeyRotationState; +use mixnet_contract_common::{ContractState, ContractStateParams}; + +pub fn introduce_node_families_contract( + deps: DepsMut, + node_families_contract_address: Addr, +) -> Result<(), MixnetContractError> { + #[derive(serde::Serialize, serde::Deserialize)] + struct OldContractState { + owner: Option, + rewarding_validator_address: Addr, + vesting_contract_address: Addr, + rewarding_denom: String, + params: ContractStateParams, + } + + const OLD_CONTRACT_STATE: Item = Item::new(CONTRACT_STATE_KEY); + let old = OLD_CONTRACT_STATE.load(deps.storage)?; + + #[allow(deprecated)] + let updated = ContractState { + owner: old.owner, + rewarding_validator_address: old.rewarding_validator_address, + vesting_contract_address: old.vesting_contract_address, + rewarding_denom: old.rewarding_denom, + params: old.params, + node_families_contract_address, + }; + mixnet_params_storage::CONTRACT_STATE.save(deps.storage, &updated)?; -pub fn introduce_key_rotation_id(deps: DepsMut) -> Result<(), MixnetContractError> { - let current_epoch_id = - interval_storage::current_interval(deps.storage)?.current_epoch_absolute_id(); - nymnodes_storage::KEY_ROTATION_STATE.save( - deps.storage, - &KeyRotationState { - validity_epochs: 24, - initial_epoch_id: current_epoch_id, - }, - )?; Ok(()) } diff --git a/contracts/mixnet/src/testable_mixnet_contract.rs b/contracts/mixnet/src/testable_mixnet_contract.rs index 498e121222..d829105f0c 100644 --- a/contracts/mixnet/src/testable_mixnet_contract.rs +++ b/contracts/mixnet/src/testable_mixnet_contract.rs @@ -5,16 +5,26 @@ #![allow(clippy::unwrap_used)] use crate::contract::{execute, instantiate, migrate, query}; -use cosmwasm_std::Decimal; +use cosmwasm_std::testing::{message_info, mock_env}; +use cosmwasm_std::{coin, coins, Addr, Decimal, MessageInfo, StdError, StdResult}; use mixnet_contract_common::error::MixnetContractError; +use mixnet_contract_common::nym_node::{NodeDetailsResponse, NodeOwnershipResponse, Role}; use mixnet_contract_common::reward_params::RewardedSetParams; use mixnet_contract_common::{ - ExecuteMsg, InitialRewardingParams, InstantiateMsg, MigrateMsg, QueryMsg, + CurrentIntervalResponse, EpochId, ExecuteMsg, InitialRewardingParams, InstantiateMsg, Interval, + MigrateMsg, MixnetContractQuerier, NodeCostParams, NodeId, NymNode, NymNodeBondingPayload, + QueryMsg, RoleAssignment, SignableNymNodeBondingMsg, DEFAULT_INTERVAL_OPERATING_COST_AMOUNT, + DEFAULT_PROFIT_MARGIN_PERCENT, }; +use nym_contracts_common::signing::{ContractMessageContent, MessageSignature}; use nym_contracts_common::Percent; use nym_contracts_common_testing::{ - mock_dependencies, ContractFn, PermissionedFn, QueryFn, TEST_DENOM, + mock_dependencies, ArbitraryContractStorageReader, ArbitraryContractStorageWriter, BankExt, + ChainOpts, ContractFn, ContractTester, PermissionedFn, QueryFn, RandExt, TEST_DENOM, }; +use nym_crypto::asymmetric::ed25519; +use serde::de::DeserializeOwned; +use serde::Serialize; use std::time::Duration; pub use nym_contracts_common_testing::TestableNymContract; @@ -74,6 +84,10 @@ impl TestableNymContract for MixnetContract { InstantiateMsg { rewarding_validator_address: deps.api.addr_make("rewarder").to_string(), vesting_contract_address: deps.api.addr_make("vesting-contract").to_string(), + node_families_contract_address: deps + .api + .addr_make("node-families-contract") + .to_string(), rewarding_denom: TEST_DENOM.to_string(), epochs_in_interval: 720, epoch_duration: Duration::from_secs(60 * 60), @@ -87,3 +101,203 @@ impl TestableNymContract for MixnetContract { } } } + +pub trait EmbeddedMixnetContractExt: + ChainOpts + ArbitraryContractStorageWriter + ArbitraryContractStorageReader + RandExt + BankExt +{ + fn mixnet_contract_address(&self) -> StdResult; + + fn execute_mixnet_contract(&mut self, sender: MessageInfo, msg: &ExecuteMsg) -> StdResult<()> { + let address = self.mixnet_contract_address()?; + + self.execute_arbitrary_contract(address, sender, msg) + .map_err(|err| { + StdError::generic_err(format!("mixnet contract execution failure: {err}")) + })?; + Ok(()) + } + + fn read_from_mixnet_contract_storage( + &self, + key: impl AsRef<[u8]>, + ) -> StdResult { + let address = self.mixnet_contract_address()?; + + self.must_read_value_from_contract_storage(address, key) + } + + fn write_to_mixnet_contract_storage( + &mut self, + key: impl AsRef<[u8]>, + value: impl AsRef<[u8]>, + ) -> StdResult<()> { + let address = self.mixnet_contract_address()?; + + ::set_contract_storage(self, address, key, value); + Ok(()) + } + + fn write_to_mixnet_contract_storage_value( + &mut self, + key: impl AsRef<[u8]>, + value: &T, + ) -> StdResult<()> { + let address = self.mixnet_contract_address()?; + + self.set_contract_storage_value(address, key, value) + } + + fn current_mixnet_epoch(&self) -> StdResult { + let address = self.mixnet_contract_address()?; + + Ok(self + .deps() + .querier + .query_current_mixnet_interval(address.clone())? + .current_epoch_absolute_id()) + } + + fn advance_mixnet_epoch(&mut self) -> StdResult<()> { + let interval_details: CurrentIntervalResponse = self.query_arbitrary_contract( + self.mixnet_contract_address()?, + &QueryMsg::GetCurrentIntervalDetails {}, + )?; + let until_end = interval_details.time_until_current_epoch_end().as_secs(); + let timestamp = self.env().block.time.plus_seconds(until_end + 1); + self.set_block_time(timestamp); + self.next_block(); + + // this was hardcoded in mixnet init + let mixnet_rewarder = self.addr_make("rewarder"); + let rewarder = message_info(&mixnet_rewarder, &[]); + self.execute_mixnet_contract(rewarder.clone(), &ExecuteMsg::BeginEpochTransition {})?; + self.execute_mixnet_contract( + rewarder.clone(), + &ExecuteMsg::ReconcileEpochEvents { limit: None }, + )?; + + for role in [ + Role::ExitGateway, + Role::EntryGateway, + Role::Layer1, + Role::Layer2, + Role::Layer3, + Role::Standby, + ] { + self.execute_mixnet_contract( + rewarder.clone(), + &ExecuteMsg::AssignRoles { + assignment: RoleAssignment { + role, + nodes: vec![], + }, + }, + )?; + } + Ok(()) + } + + fn set_mixnet_epoch(&mut self, epoch_id: EpochId) -> StdResult<()> { + let address = self.mixnet_contract_address()?; + + let interval = self + .deps() + .querier + .query_current_mixnet_interval(address.clone())?; + + let mut to_update = if interval.current_epoch_absolute_id() <= epoch_id { + interval + } else { + Interval::init_interval( + interval.epochs_in_interval(), + interval.epoch_length(), + &mock_env(), + ) + }; + + let current = to_update.current_epoch_absolute_id(); + let diff = epoch_id - current; + for _ in 0..diff { + to_update = to_update.advance_epoch(); + } + self.set_contract_storage_value(&address, b"ci", &to_update) + } + + fn bond_dummy_nymnode_for(&mut self, node_owner: &Addr) -> Result { + let pledge = coins(100_000000, TEST_DENOM); + let keypair = ed25519::KeyPair::new(self.raw_rng()); + let identity_key = keypair.public_key().to_base58_string(); + + let node = NymNode { + host: "1.2.3.4".to_string(), + custom_http_port: None, + identity_key, + }; + let cost_params = NodeCostParams { + profit_margin_percent: Percent::from_percentage_value(DEFAULT_PROFIT_MARGIN_PERCENT) + .unwrap(), + interval_operating_cost: coin(DEFAULT_INTERVAL_OPERATING_COST_AMOUNT, TEST_DENOM), + }; + // initial signing nonce is 0 for a new address + let signing_nonce = 0; + + let payload = NymNodeBondingPayload::new(node.clone(), cost_params.clone()); + let content = ContractMessageContent::new(node_owner.clone(), pledge.clone(), payload); + let msg = SignableNymNodeBondingMsg::new(signing_nonce, content); + + let owner_signature = keypair.private_key().sign(msg.to_plaintext()?); + let owner_signature = MessageSignature::from(owner_signature.to_bytes().as_ref()); + + self.execute_mixnet_contract( + message_info(node_owner, &pledge), + &ExecuteMsg::BondNymNode { + node, + cost_params, + owner_signature, + }, + )?; + + let bond: NodeOwnershipResponse = self.query_arbitrary_contract( + self.mixnet_contract_address()?, + &QueryMsg::GetOwnedNymNode { + address: node_owner.to_string(), + }, + )?; + + Ok(bond.details.unwrap().bond_information.node_id) + } + + fn bond_dummy_nymnode(&mut self) -> Result { + let node_owner = self.generate_account_with_balance(); + self.bond_dummy_nymnode_for(&node_owner) + } + + fn unbond_nymnode(&mut self, node_id: NodeId) -> Result<(), StdError> { + let bond: NodeDetailsResponse = self.query_arbitrary_contract( + self.mixnet_contract_address()?, + &QueryMsg::GetNymNodeDetails { node_id }, + )?; + + let node_owner = bond.details.unwrap().bond_information.owner; + + self.execute_mixnet_contract( + message_info(&node_owner, &[]), + &ExecuteMsg::UnbondNymNode {}, + )?; + + self.advance_mixnet_epoch()?; + Ok(()) + } +} + +impl EmbeddedMixnetContractExt for ContractTester +where + C: TestableNymContract, +{ + fn mixnet_contract_address(&self) -> StdResult { + self.well_known_contracts + .get(MixnetContract::NAME) + .ok_or_else(|| StdError::generic_err("mixnet contract not part of the tester")) + .cloned() + } +} diff --git a/contracts/mixnet/src/vesting_migration.rs b/contracts/mixnet/src/vesting_migration.rs index 683b7a6102..89c0274e65 100644 --- a/contracts/mixnet/src/vesting_migration.rs +++ b/contracts/mixnet/src/vesting_migration.rs @@ -3,24 +3,42 @@ use crate::delegations::storage as delegations_storage; use crate::mixnet_contract_settings::storage as mixnet_params_storage; +use crate::mixnet_contract_settings::storage::ADMIN; use crate::mixnodes::helpers::get_mixnode_details_by_owner; use crate::mixnodes::storage as mixnodes_storage; use crate::rewards::storage as rewards_storage; use crate::support::helpers::{ ensure_bonded, ensure_epoch_in_progress_state, ensure_no_pending_pledge_changes, }; -use cosmwasm_std::{wasm_execute, DepsMut, Env, Event, MessageInfo, Response}; +use cosmwasm_std::{wasm_execute, Addr, DepsMut, Env, Event, MessageInfo, Response}; use mixnet_contract_common::error::MixnetContractError; -use mixnet_contract_common::{Delegation, NodeId}; +use mixnet_contract_common::{Delegation, NodeId, VestedDelegationMigrationEntry}; use vesting_contract_common::messages::ExecuteMsg as VestingExecuteMsg; pub(crate) fn try_migrate_vested_mixnode( deps: DepsMut<'_>, info: MessageInfo, ) -> Result { - let mix_details = get_mixnode_details_by_owner(deps.storage, info.sender.clone())?.ok_or( + migrate_vested_mixnode_for_owner(deps, info.sender) +} + +pub(crate) fn try_admin_migrate_vested_mixnode( + deps: DepsMut<'_>, + info: MessageInfo, + owner: String, +) -> Result { + ADMIN.assert_admin(deps.as_ref(), &info.sender)?; + let owner = deps.api.addr_validate(&owner)?; + migrate_vested_mixnode_for_owner(deps, owner) +} + +fn migrate_vested_mixnode_for_owner( + deps: DepsMut<'_>, + owner: Addr, +) -> Result { + let mix_details = get_mixnode_details_by_owner(deps.storage, owner.clone())?.ok_or( MixnetContractError::NoAssociatedMixNodeBond { - owner: info.sender.clone(), + owner: owner.clone(), }, )?; let mix_id = mix_details.mix_id(); @@ -55,7 +73,7 @@ pub(crate) fn try_migrate_vested_mixnode( .add_message(wasm_execute( vesting_contract, &VestingExecuteMsg::TrackMigratedMixnode { - owner: info.sender.into_string(), + owner: owner.into_string(), }, vec![], )?)) @@ -66,6 +84,46 @@ pub(crate) fn try_migrate_vested_delegation( env: Env, info: MessageInfo, mix_id: NodeId, +) -> Result { + migrate_vested_delegation_for_owner(deps, env, info.sender, mix_id) +} + +pub(crate) fn try_admin_migrate_vested_delegation( + deps: DepsMut<'_>, + env: Env, + info: MessageInfo, + mix_id: NodeId, + owner: String, +) -> Result { + ADMIN.assert_admin(deps.as_ref(), &info.sender)?; + let owner = deps.api.addr_validate(&owner)?; + migrate_vested_delegation_for_owner(deps, env, owner, mix_id) +} + +pub(crate) fn try_admin_batch_migrate_vested_delegations( + mut deps: DepsMut<'_>, + env: Env, + info: MessageInfo, + entries: Vec, +) -> Result { + ADMIN.assert_admin(deps.as_ref(), &info.sender)?; + + let mut response = Response::new(); + for VestedDelegationMigrationEntry { mix_id, owner } in entries { + let owner = deps.api.addr_validate(&owner)?; + let sub = migrate_vested_delegation_for_owner(deps.branch(), env.clone(), owner, mix_id)?; + response = response + .add_submessages(sub.messages) + .add_events(sub.events); + } + Ok(response) +} + +fn migrate_vested_delegation_for_owner( + deps: DepsMut<'_>, + env: Env, + owner: Addr, + mix_id: NodeId, ) -> Result { let mut response = Response::new(); @@ -73,12 +131,16 @@ pub(crate) fn try_migrate_vested_delegation( let vesting_contract = mixnet_params_storage::vesting_contract_address(deps.storage)?; - let storage_key = - Delegation::generate_storage_key(mix_id, &info.sender, Some(&vesting_contract)); + let storage_key = Delegation::generate_storage_key(mix_id, &owner, Some(&vesting_contract)); let Some(vested_delegation) = delegations_storage::delegations().may_load(deps.storage, storage_key.clone())? else { - return Err(MixnetContractError::NotAVestingDelegation); + return Ok(Response::new().add_event( + Event::new("migrate-vested-delegation-noop") + .add_attribute("owner", owner.as_str()) + .add_attribute("mix_id", mix_id.to_string()) + .add_attribute("reason", "no_vested_delegation"), + )); }; // sanity check that's meant to blow up the contract @@ -88,7 +150,7 @@ pub(crate) fn try_migrate_vested_delegation( let mut updated_delegation = vested_delegation.clone(); updated_delegation.proxy = None; - let new_storage_key = Delegation::generate_storage_key(mix_id, &info.sender, None); + let new_storage_key = Delegation::generate_storage_key(mix_id, &owner, None); // remove the old (vested) delegation delegations_storage::delegations().remove(deps.storage, storage_key)?; @@ -205,7 +267,7 @@ pub(crate) fn try_migrate_vested_delegation( Ok(response.add_message(wasm_execute( vesting_contract, &VestingExecuteMsg::TrackMigratedDelegation { - owner: info.sender.into_string(), + owner: owner.into_string(), mix_id, }, vec![], @@ -307,9 +369,10 @@ mod tests { let sender = test.make_sender("owner-without-any-delegations"); - // it simply fails for there is nothing to migrate - let res = try_migrate_vested_delegation(test.deps_mut(), env, sender, 42).unwrap_err(); - assert_eq!(res, MixnetContractError::NotAVestingDelegation); + // nothing to migrate -> idempotent no-op (so admin batches don't fail on stale entries) + let res = try_migrate_vested_delegation(test.deps_mut(), env, sender, 42).unwrap(); + assert!(res.messages.is_empty()); + assert_eq!(res.events[0].ty, "migrate-vested-delegation-noop"); } #[test] @@ -333,10 +396,10 @@ mod tests { let sender = message_info(&delegation.owner, &[]); let mix_id = delegation.node_id; - // it also fails because the method is only allowed for vested delegations - let res = - try_migrate_vested_delegation(test.deps_mut(), env, sender, mix_id).unwrap_err(); - assert_eq!(res, MixnetContractError::NotAVestingDelegation); + // liquid-only delegations are no-ops (nothing vested to migrate) + let res = try_migrate_vested_delegation(test.deps_mut(), env, sender, mix_id).unwrap(); + assert!(res.messages.is_empty()); + assert_eq!(res.events[0].ty, "migrate-vested-delegation-noop"); } #[test] @@ -566,4 +629,329 @@ mod tests { compare_decimals(rewards, new_rewards_twin, Some("0.01".parse().unwrap())) } } + + #[cfg(test)] + mod admin_migrating_vested_mixnode { + use super::*; + use crate::mixnodes::helpers::get_mixnode_details_by_id; + use crate::support::tests::test_helpers::TestSetup; + use cosmwasm_std::testing::message_info; + use cosmwasm_std::{from_json, CosmosMsg, WasmMsg}; + + #[test] + fn rejects_non_admin_caller() { + let mut test = TestSetup::new(); + let owner = test.make_addr("owner"); + let mix_id = test.add_legacy_mixnode_with_legal_proxy(&owner, None); + + let intruder = message_info(&test.make_addr("not-admin"), &[]); + let owner_str = owner.to_string(); + let res = + try_admin_migrate_vested_mixnode(test.deps_mut(), intruder, owner_str).unwrap_err(); + assert!(matches!(res, MixnetContractError::Admin(_))); + + // bond is untouched + let existing = get_mixnode_details_by_id(test.deps().storage, mix_id) + .unwrap() + .unwrap(); + assert!(existing.bond_information.proxy.is_some()); + } + + #[test] + fn admin_can_migrate_someone_elses_vested_node() { + let mut test = TestSetup::new(); + let owner = test.make_addr("owner"); + let mix_id = test.add_legacy_mixnode_with_legal_proxy(&owner, None); + let admin = test.admin(); + + let info = message_info(&admin, &[]); + let owner_str = owner.to_string(); + let res = + try_admin_migrate_vested_mixnode(test.deps_mut(), info, owner_str.clone()).unwrap(); + + let CosmosMsg::Wasm(WasmMsg::Execute { msg, .. }) = &res.messages[0].msg else { + panic!("no track message present") + }; + assert_eq!( + from_json::(msg).unwrap(), + VestingExecuteMsg::TrackMigratedMixnode { owner: owner_str } + ); + + // proxy was cleared on the bond owned by `owner` (not by the admin) + let migrated = get_mixnode_details_by_id(test.deps().storage, mix_id) + .unwrap() + .unwrap(); + assert!(migrated.bond_information.proxy.is_none()); + } + + #[test] + fn rejects_invalid_owner_address() { + let mut test = TestSetup::new(); + let admin = test.admin(); + + let info = message_info(&admin, &[]); + let res = + try_admin_migrate_vested_mixnode(test.deps_mut(), info, "not a bech32".to_string()) + .unwrap_err(); + assert!(matches!(res, MixnetContractError::StdErr { .. })); + } + } + + #[cfg(test)] + mod admin_migrating_vested_delegation { + use super::*; + use crate::delegations::storage::delegations; + use crate::support::tests::test_helpers::TestSetup; + use cosmwasm_std::testing::message_info; + use cosmwasm_std::{from_json, CosmosMsg, Order, WasmMsg}; + + #[test] + fn rejects_non_admin_caller() { + let mut test = TestSetup::new_complex(); + let env = test.env(); + + let vested = delegations() + .range(test.deps().storage, None, None, Order::Ascending) + .filter_map(|d| d.map(|(_, del)| del).ok()) + .find(|d| d.proxy.is_some()) + .unwrap(); + + let intruder = message_info(&test.make_addr("not-admin"), &[]); + let res = try_admin_migrate_vested_delegation( + test.deps_mut(), + env, + intruder, + vested.node_id, + vested.owner.to_string(), + ) + .unwrap_err(); + assert!(matches!(res, MixnetContractError::Admin(_))); + + // delegation is untouched + assert!(delegations() + .may_load(test.deps().storage, vested.storage_key()) + .unwrap() + .is_some()); + } + + #[test] + fn admin_can_migrate_someone_elses_vested_delegation() { + let mut test = TestSetup::new_complex(); + let env = test.env(); + let admin = test.admin(); + + let vested = delegations() + .range(test.deps().storage, None, None, Order::Ascending) + .filter_map(|d| d.map(|(_, del)| del).ok()) + .find(|d| d.proxy.is_some()) + .unwrap(); + + // pick an owner that has no liquid twin so we exercise the simple branch + let has_liquid_twin = delegations() + .range(test.deps().storage, None, None, Order::Ascending) + .filter_map(|d| d.map(|(_, del)| del).ok()) + .any(|d| { + d.proxy.is_none() + && d.owner.as_str() == vested.owner.as_str() + && d.node_id == vested.node_id + }); + assert!(!has_liquid_twin); + + let old_key = vested.storage_key(); + let mut expected_liquid = vested.clone(); + expected_liquid.proxy = None; + let new_key = expected_liquid.storage_key(); + + let info = message_info(&admin, &[]); + let res = try_admin_migrate_vested_delegation( + test.deps_mut(), + env, + info, + vested.node_id, + vested.owner.to_string(), + ) + .unwrap(); + + let CosmosMsg::Wasm(WasmMsg::Execute { msg, .. }) = &res.messages[0].msg else { + panic!("no track message present") + }; + assert_eq!( + from_json::(msg).unwrap(), + VestingExecuteMsg::TrackMigratedDelegation { + owner: vested.owner.to_string(), + mix_id: vested.node_id, + } + ); + + assert!(delegations() + .may_load(test.deps().storage, old_key) + .unwrap() + .is_none()); + assert_eq!( + expected_liquid, + delegations().load(test.deps().storage, new_key).unwrap() + ); + } + } + + #[cfg(test)] + mod admin_batch_migrating_vested_delegations { + use super::*; + use crate::delegations::storage::delegations; + use crate::support::tests::test_helpers::TestSetup; + use cosmwasm_std::testing::message_info; + use cosmwasm_std::{CosmosMsg, Order, WasmMsg}; + + fn collect_vested(test: &TestSetup, n: usize) -> Vec { + delegations() + .range(test.deps().storage, None, None, Order::Ascending) + .filter_map(|d| d.map(|(_, del)| del).ok()) + .filter(|d| d.proxy.is_some()) + .take(n) + .collect() + } + + #[test] + fn rejects_non_admin_caller() { + let mut test = TestSetup::new_complex(); + let env = test.env(); + let vested = collect_vested(&test, 2); + let entries = vested + .iter() + .map(|d| VestedDelegationMigrationEntry { + mix_id: d.node_id, + owner: d.owner.to_string(), + }) + .collect(); + + let intruder = message_info(&test.make_addr("not-admin"), &[]); + let res = + try_admin_batch_migrate_vested_delegations(test.deps_mut(), env, intruder, entries) + .unwrap_err(); + assert!(matches!(res, MixnetContractError::Admin(_))); + + // nothing was touched + for d in &vested { + assert!(delegations() + .may_load(test.deps().storage, d.storage_key()) + .unwrap() + .is_some()); + } + } + + #[test] + fn admin_can_migrate_multiple_entries_in_one_call() { + let mut test = TestSetup::new_complex(); + let env = test.env(); + let admin = test.admin(); + let vested = collect_vested(&test, 3); + assert_eq!(vested.len(), 3, "fixture must have ≥3 vested delegations"); + + let entries: Vec<_> = vested + .iter() + .map(|d| VestedDelegationMigrationEntry { + mix_id: d.node_id, + owner: d.owner.to_string(), + }) + .collect(); + + let info = message_info(&admin, &[]); + let res = + try_admin_batch_migrate_vested_delegations(test.deps_mut(), env, info, entries) + .unwrap(); + + // one TrackMigratedDelegation WasmMsg per entry + let track_msgs: Vec<_> = res + .messages + .iter() + .filter(|sub| matches!(sub.msg, CosmosMsg::Wasm(WasmMsg::Execute { .. }))) + .collect(); + assert_eq!(track_msgs.len(), 3); + + // each vested entry was removed and re-saved under the liquid key + for d in &vested { + assert!(delegations() + .may_load(test.deps().storage, d.storage_key()) + .unwrap() + .is_none()); + let liquid_key = Delegation::generate_storage_key(d.node_id, &d.owner, None); + let liquid = delegations().load(test.deps().storage, liquid_key).unwrap(); + assert!(liquid.proxy.is_none()); + } + } + + #[test] + fn batch_with_noop_entries_still_succeeds() { + // batch contains: 1 valid vested, 1 stale (non-existent) — the stale one is a noop + let mut test = TestSetup::new_complex(); + let env = test.env(); + let admin = test.admin(); + let vested = collect_vested(&test, 1); + let real = &vested[0]; + + let entries = vec![ + VestedDelegationMigrationEntry { + mix_id: real.node_id, + owner: real.owner.to_string(), + }, + VestedDelegationMigrationEntry { + mix_id: 999_999, + owner: test.make_addr("ghost").to_string(), + }, + ]; + + let info = message_info(&admin, &[]); + let res = + try_admin_batch_migrate_vested_delegations(test.deps_mut(), env, info, entries) + .unwrap(); + + // only the real entry dispatched a track message; the ghost was a noop + let track_msgs: Vec<_> = res + .messages + .iter() + .filter(|sub| matches!(sub.msg, CosmosMsg::Wasm(WasmMsg::Execute { .. }))) + .collect(); + assert_eq!(track_msgs.len(), 1); + assert!(res + .events + .iter() + .any(|e| e.ty == "migrate-vested-delegation-noop")); + } + + #[test] + fn bad_owner_address_propagates_as_error() { + // a malformed entry causes the handler to return Err; on-chain this reverts + // the entire batch atomically. (Unit tests use a raw `DepsMut` that does not + // simulate chain-level rollback, so we only assert the error and ensure no + // entry after the bad one was processed.) + let mut test = TestSetup::new_complex(); + let env = test.env(); + let admin = test.admin(); + let vested = collect_vested(&test, 1); + + let entries = vec![ + VestedDelegationMigrationEntry { + mix_id: vested[0].node_id, + owner: "not-a-bech32".to_string(), + }, + VestedDelegationMigrationEntry { + mix_id: vested[0].node_id, + owner: vested[0].owner.to_string(), + }, + ]; + + let info = message_info(&admin, &[]); + let err = + try_admin_batch_migrate_vested_delegations(test.deps_mut(), env, info, entries) + .unwrap_err(); + assert!(matches!(err, MixnetContractError::StdErr { .. })); + + // bailed out before reaching the second (valid) entry, so the vested record + // is still in storage + assert!(delegations() + .may_load(test.deps().storage, vested[0].storage_key()) + .unwrap() + .is_some()); + } + } } diff --git a/contracts/node-families/.cargo/config b/contracts/node-families/.cargo/config new file mode 100644 index 0000000000..2fb2c1afdb --- /dev/null +++ b/contracts/node-families/.cargo/config @@ -0,0 +1,4 @@ +[alias] +wasm = "build --release --lib --target wasm32-unknown-unknown" +unit-test = "test --lib" +schema = "run --bin schema --features=schema-gen" \ No newline at end of file diff --git a/contracts/node-families/Cargo.toml b/contracts/node-families/Cargo.toml new file mode 100644 index 0000000000..289db1eae2 --- /dev/null +++ b/contracts/node-families/Cargo.toml @@ -0,0 +1,62 @@ +[package] +name = "node-families" +description = "Nym Node Families contract" +version = "0.1.0" +authors.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +documentation.workspace = true +rust-version.workspace = true +readme.workspace = true +publish = false + +[[bin]] +name = "schema" +required-features = ["schema-gen"] + +[lib] +name = "node_families_contract" +crate-type = ["cdylib", "rlib"] + +[dependencies] +cosmwasm-std = { workspace = true } +cw2 = { workspace = true } +cw-storage-plus = { workspace = true } +cw-controllers = { workspace = true } +serde = { workspace = true } +cosmwasm-schema = { workspace = true, optional = true } +cw-utils = { workspace = true } + +nym-contracts-common = { workspace = true } +nym-node-families-contract-common = { workspace = true } +nym-mixnet-contract-common = { workspace = true } + +# Optional deps activated by the `testable-node-families-contract` feature so +# downstream crates can pull in `crate::testing` for their own test harnesses. +nym-contracts-common-testing = { workspace = true, optional = true } +nym-mixnet-contract = { workspace = true, optional = true, features = ["testable-mixnet-contract"] } +nym-crypto = { workspace = true, optional = true } + +[dev-dependencies] +anyhow = { workspace = true } +# make the testing helpers available for this crate's own unit tests via +# `cfg(test)`; downstream crates instead pull these in through the +# `testable-node-families-contract` feature. +nym-contracts-common-testing = { workspace = true } +nym-mixnet-contract = { workspace = true, features = ["testable-mixnet-contract"] } +nym-crypto = { workspace = true, features = ["asymmetric"] } + +[features] +schema-gen = ["nym-node-families-contract-common/schema", "cosmwasm-schema"] +testable-node-families-contract = [ + "nym-contracts-common-testing", + "nym-mixnet-contract", + "nym-crypto", + "nym-crypto/asymmetric", +] + + +[lints] +workspace = true diff --git a/contracts/node-families/Makefile b/contracts/node-families/Makefile new file mode 100644 index 0000000000..086fa71ad3 --- /dev/null +++ b/contracts/node-families/Makefile @@ -0,0 +1,5 @@ +wasm: + RUSTFLAGS='-C link-arg=-s' cargo build --release --target wasm32-unknown-unknown + +generate-schema: + cargo schema diff --git a/contracts/node-families/schema/node-families.json b/contracts/node-families/schema/node-families.json new file mode 100644 index 0000000000..b9165c7546 --- /dev/null +++ b/contracts/node-families/schema/node-families.json @@ -0,0 +1,2844 @@ +{ + "contract_name": "node-families", + "contract_version": "0.1.0", + "idl_version": "1.0.0", + "instantiate": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "InstantiateMsg", + "description": "Message used to instantiate the node families contract.", + "type": "object", + "required": [ + "config", + "mixnet_contract_address" + ], + "properties": { + "config": { + "$ref": "#/definitions/Config" + }, + "mixnet_contract_address": { + "type": "string" + } + }, + "additionalProperties": false, + "definitions": { + "Coin": { + "type": "object", + "required": [ + "amount", + "denom" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "denom": { + "type": "string" + } + }, + "additionalProperties": false + }, + "Config": { + "description": "Runtime configuration of the node families contract.", + "type": "object", + "required": [ + "create_family_fee", + "default_invitation_validity_secs", + "family_description_length_limit", + "family_name_length_limit" + ], + "properties": { + "create_family_fee": { + "description": "Fee charged on each successful `create_family` execution.", + "allOf": [ + { + "$ref": "#/definitions/Coin" + } + ] + }, + "default_invitation_validity_secs": { + "description": "Default lifetime, in seconds, used by `invite_to_family` when the sender doesn't supply an explicit value. Senders may override this per-invitation via the optional `validity_secs` argument.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "family_description_length_limit": { + "description": "Maximum allowed length, in characters, of a family description.", + "type": "integer", + "format": "uint", + "minimum": 0.0 + }, + "family_name_length_limit": { + "description": "Maximum allowed length, in characters, of a family name.", + "type": "integer", + "format": "uint", + "minimum": 0.0 + } + }, + "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" + } + } + }, + "execute": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ExecuteMsg", + "description": "Execute messages accepted by the contract.", + "oneOf": [ + { + "description": "Replace the contract's runtime [`Config`]. Restricted to the contract admin.", + "type": "object", + "required": [ + "update_config" + ], + "properties": { + "update_config": { + "type": "object", + "required": [ + "config" + ], + "properties": { + "config": { + "$ref": "#/definitions/Config" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Create a new family owned by the message sender. The configured `create_family_fee` must be attached as funds.", + "type": "object", + "required": [ + "create_family" + ], + "properties": { + "create_family": { + "type": "object", + "required": [ + "description", + "name" + ], + "properties": { + "description": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Disband the family owned by the message sender. The family must have no current members; any still-pending invitations are revoked.", + "type": "object", + "required": [ + "disband_family" + ], + "properties": { + "disband_family": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Invite a node to the family owned by the message sender. If `validity_secs` is omitted the invitation expires `default_invitation_validity_secs` seconds (from [`Config`]) after the current block time.", + "type": "object", + "required": [ + "invite_to_family" + ], + "properties": { + "invite_to_family": { + "type": "object", + "required": [ + "node_id" + ], + "properties": { + "node_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "validity_secs": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Revoke a still-pending invitation previously issued by the sender's family.", + "type": "object", + "required": [ + "revoke_family_invitation" + ], + "properties": { + "revoke_family_invitation": { + "type": "object", + "required": [ + "node_id" + ], + "properties": { + "node_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Accept a pending invitation. The sender must control `node_id`.", + "type": "object", + "required": [ + "accept_family_invitation" + ], + "properties": { + "accept_family_invitation": { + "type": "object", + "required": [ + "family_id", + "node_id" + ], + "properties": { + "family_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "node_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Reject a pending invitation. The sender must control `node_id`.", + "type": "object", + "required": [ + "reject_family_invitation" + ], + "properties": { + "reject_family_invitation": { + "type": "object", + "required": [ + "family_id", + "node_id" + ], + "properties": { + "family_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "node_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Leave the family `node_id` currently belongs to. The sender must control `node_id`.", + "type": "object", + "required": [ + "leave_family" + ], + "properties": { + "leave_family": { + "type": "object", + "required": [ + "node_id" + ], + "properties": { + "node_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Remove `node_id` from the family owned by the message sender.", + "type": "object", + "required": [ + "kick_from_family" + ], + "properties": { + "kick_from_family": { + "type": "object", + "required": [ + "node_id" + ], + "properties": { + "node_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Cross-contract callback fired by the mixnet contract the moment node with `node_id` initiates unbonding. Removes the node from any family it currently belongs to and rejects every pending invitation issued to it. Sender must be the configured mixnet contract address.", + "type": "object", + "required": [ + "on_nym_node_unbond" + ], + "properties": { + "on_nym_node_unbond": { + "type": "object", + "required": [ + "node_id" + ], + "properties": { + "node_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ], + "definitions": { + "Coin": { + "type": "object", + "required": [ + "amount", + "denom" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "denom": { + "type": "string" + } + }, + "additionalProperties": false + }, + "Config": { + "description": "Runtime configuration of the node families contract.", + "type": "object", + "required": [ + "create_family_fee", + "default_invitation_validity_secs", + "family_description_length_limit", + "family_name_length_limit" + ], + "properties": { + "create_family_fee": { + "description": "Fee charged on each successful `create_family` execution.", + "allOf": [ + { + "$ref": "#/definitions/Coin" + } + ] + }, + "default_invitation_validity_secs": { + "description": "Default lifetime, in seconds, used by `invite_to_family` when the sender doesn't supply an explicit value. Senders may override this per-invitation via the optional `validity_secs` argument.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "family_description_length_limit": { + "description": "Maximum allowed length, in characters, of a family description.", + "type": "integer", + "format": "uint", + "minimum": 0.0 + }, + "family_name_length_limit": { + "description": "Maximum allowed length, in characters, of a family name.", + "type": "integer", + "format": "uint", + "minimum": 0.0 + } + }, + "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" + } + } + }, + "query": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "QueryMsg", + "description": "Query messages accepted by the contract.", + "oneOf": [ + { + "description": "Look up a single family by its id.", + "type": "object", + "required": [ + "get_family_by_id" + ], + "properties": { + "get_family_by_id": { + "type": "object", + "required": [ + "family_id" + ], + "properties": { + "family_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Look up the (at most one) family owned by a given address.", + "type": "object", + "required": [ + "get_family_by_owner" + ], + "properties": { + "get_family_by_owner": { + "type": "object", + "required": [ + "owner" + ], + "properties": { + "owner": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Look up a single family by its name. The lookup is normalised contract-side (lowercased, non-alphanumerics stripped), so equivalent inputs resolve to the same family.", + "type": "object", + "required": [ + "get_family_by_name" + ], + "properties": { + "get_family_by_name": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "get_families_paged" + ], + "properties": { + "get_families_paged": { + "type": "object", + "properties": { + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Look up which family — if any — a node currently belongs to.", + "type": "object", + "required": [ + "get_family_membership" + ], + "properties": { + "get_family_membership": { + "type": "object", + "required": [ + "node_id" + ], + "properties": { + "node_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Page through every node currently in a given family.", + "type": "object", + "required": [ + "get_family_members_paged" + ], + "properties": { + "get_family_members_paged": { + "type": "object", + "required": [ + "family_id" + ], + "properties": { + "family_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Page through every current family member across all families, in ascending [`NodeId`] order. Each entry carries the membership record (which in turn names the family the node belongs to).", + "type": "object", + "required": [ + "get_all_family_members_paged" + ], + "properties": { + "get_all_family_members_paged": { + "type": "object", + "properties": { + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Look up the pending invitation for a specific `(family_id, node_id)` pair.", + "type": "object", + "required": [ + "get_pending_invitation" + ], + "properties": { + "get_pending_invitation": { + "type": "object", + "required": [ + "family_id", + "node_id" + ], + "properties": { + "family_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "node_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Page through every pending invitation issued by a given family.", + "type": "object", + "required": [ + "get_pending_invitations_for_family_paged" + ], + "properties": { + "get_pending_invitations_for_family_paged": { + "type": "object", + "required": [ + "family_id" + ], + "properties": { + "family_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Page through every pending invitation issued for a given node.", + "type": "object", + "required": [ + "get_pending_invitations_for_node_paged" + ], + "properties": { + "get_pending_invitations_for_node_paged": { + "type": "object", + "required": [ + "node_id" + ], + "properties": { + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "node_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Page through every pending invitation across all families.", + "type": "object", + "required": [ + "get_all_pending_invitations_paged" + ], + "properties": { + "get_all_pending_invitations_paged": { + "type": "object", + "properties": { + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "type": [ + "array", + "null" + ], + "items": [ + { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + ], + "maxItems": 2, + "minItems": 2 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Page through every archived (terminal-state) invitation issued by a given family.", + "type": "object", + "required": [ + "get_past_invitations_for_family_paged" + ], + "properties": { + "get_past_invitations_for_family_paged": { + "type": "object", + "required": [ + "family_id" + ], + "properties": { + "family_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "type": [ + "array", + "null" + ], + "items": [ + { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + ], + "maxItems": 2, + "minItems": 2 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Page through every archived (terminal-state) invitation issued to a given node.", + "type": "object", + "required": [ + "get_past_invitations_for_node_paged" + ], + "properties": { + "get_past_invitations_for_node_paged": { + "type": "object", + "required": [ + "node_id" + ], + "properties": { + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "node_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "type": [ + "array", + "null" + ], + "items": [ + { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + ], + "maxItems": 2, + "minItems": 2 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Page through every archived (terminal-state) invitation across all families.", + "type": "object", + "required": [ + "get_all_past_invitations_paged" + ], + "properties": { + "get_all_past_invitations_paged": { + "type": "object", + "properties": { + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "type": [ + "array", + "null" + ], + "items": [ + { + "type": "array", + "items": [ + { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + ], + "maxItems": 2, + "minItems": 2 + }, + { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + ], + "maxItems": 2, + "minItems": 2 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Page through every archived membership record for a given family (nodes that used to belong to it but have since been removed).", + "type": "object", + "required": [ + "get_past_members_for_family_paged" + ], + "properties": { + "get_past_members_for_family_paged": { + "type": "object", + "required": [ + "family_id" + ], + "properties": { + "family_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "type": [ + "array", + "null" + ], + "items": [ + { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + ], + "maxItems": 2, + "minItems": 2 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Page through every archived membership record for a given node (every family the node used to belong to but has since been removed from), across all families.", + "type": "object", + "required": [ + "get_past_members_for_node_paged" + ], + "properties": { + "get_past_members_for_node_paged": { + "type": "object", + "required": [ + "node_id" + ], + "properties": { + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "node_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "type": [ + "array", + "null" + ], + "items": [ + { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + ], + "maxItems": 2, + "minItems": 2 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "migrate": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "MigrateMsg", + "description": "Message passed to the contract's `migrate` entry point.", + "type": "object", + "additionalProperties": false + }, + "sudo": null, + "responses": { + "get_all_family_members_paged": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "AllFamilyMembersPagedResponse", + "description": "Response to [`QueryMsg::GetAllFamilyMembersPaged`](crate::QueryMsg::GetAllFamilyMembersPaged).", + "type": "object", + "required": [ + "members" + ], + "properties": { + "members": { + "description": "The members on this page, in ascending [`NodeId`] order across every family.", + "type": "array", + "items": { + "$ref": "#/definitions/FamilyMemberRecord" + } + }, + "start_next_after": { + "description": "Cursor (last `node_id`) to pass as `start_after` on the next call, or `None` if this page is empty (treat as end-of-list).", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false, + "definitions": { + "FamilyMemberRecord": { + "description": "One entry in a [`FamilyMembersPagedResponse`] page — pairs a node id with its [`FamilyMembership`] record (notably its `joined_at` timestamp).", + "type": "object", + "required": [ + "membership", + "node_id" + ], + "properties": { + "membership": { + "description": "The membership record (carries `family_id` and `joined_at`).", + "allOf": [ + { + "$ref": "#/definitions/FamilyMembership" + } + ] + }, + "node_id": { + "description": "The node currently in the family.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + "FamilyMembership": { + "description": "On-chain record of a node's current family membership.\n\nA node belongs to at most one family at a time, so this is keyed by `NodeId` alone — `family_id` is carried in the value to support reverse lookups (all nodes in a given family) via a secondary index.", + "type": "object", + "required": [ + "family_id", + "joined_at" + ], + "properties": { + "family_id": { + "description": "The family the node is currently a member of.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "joined_at": { + "description": "Block timestamp (unix seconds) at which the node accepted its invitation and joined the family.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + } + }, + "get_all_past_invitations_paged": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "AllPastFamilyInvitationsPagedResponse", + "description": "Response to [`QueryMsg::GetAllPastInvitationsPaged`](crate::QueryMsg::GetAllPastInvitationsPaged).", + "type": "object", + "required": [ + "invitations" + ], + "properties": { + "invitations": { + "description": "The archived invitations on this page, in ascending `((family_id, node_id), counter)` order across all terminal statuses.", + "type": "array", + "items": { + "$ref": "#/definitions/PastFamilyInvitation" + } + }, + "start_next_after": { + "description": "Cursor to pass as `start_after` on the next call, or `None` if this page is empty (treat as end-of-list).", + "type": [ + "array", + "null" + ], + "items": [ + { + "type": "array", + "items": [ + { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + ], + "maxItems": 2, + "minItems": 2 + }, + { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + ], + "maxItems": 2, + "minItems": 2 + } + }, + "additionalProperties": false, + "definitions": { + "FamilyInvitation": { + "description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, revoked, or until the chain advances past `expires_at` (in which case they remain in storage but are treated as inert — there is no background process clearing expired invitations).", + "type": "object", + "required": [ + "expires_at", + "family_id", + "node_id" + ], + "properties": { + "expires_at": { + "description": "Block timestamp (unix seconds) after which the invitation is no longer valid.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "family_id": { + "description": "The family that issued the invitation.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "node_id": { + "description": "The node being invited.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + "FamilyInvitationStatus": { + "description": "Terminal status for an invitation that has been moved out of the pending set.\n\nNote: timed-out invitations are not represented here — they are simply left in the pending set (see `FamilyInvitation::expires_at`).", + "oneOf": [ + { + "description": "Still awaiting a response. Recorded with a timestamp for completeness even though pending invitations live in a separate map.", + "type": "object", + "required": [ + "pending" + ], + "properties": { + "pending": { + "type": "object", + "required": [ + "at" + ], + "properties": { + "at": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "The invitee accepted and joined the family at the given timestamp.", + "type": "object", + "required": [ + "accepted" + ], + "properties": { + "accepted": { + "type": "object", + "required": [ + "at" + ], + "properties": { + "at": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "The invitee explicitly rejected the invitation at the given timestamp.", + "type": "object", + "required": [ + "rejected" + ], + "properties": { + "rejected": { + "type": "object", + "required": [ + "at" + ], + "properties": { + "at": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "The family revoked the invitation at the given timestamp before it could be accepted or rejected.", + "type": "object", + "required": [ + "revoked" + ], + "properties": { + "revoked": { + "type": "object", + "required": [ + "at" + ], + "properties": { + "at": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "PastFamilyInvitation": { + "description": "Historical record of an invitation that has reached a terminal state (`Accepted`, `Rejected`, or `Revoked`). Timed-out invitations are **not** archived here — they remain in the pending map until explicitly cleared.", + "type": "object", + "required": [ + "invitation", + "status" + ], + "properties": { + "invitation": { + "description": "The original invitation as it was issued.", + "allOf": [ + { + "$ref": "#/definitions/FamilyInvitation" + } + ] + }, + "status": { + "description": "What ultimately happened to it.", + "allOf": [ + { + "$ref": "#/definitions/FamilyInvitationStatus" + } + ] + } + }, + "additionalProperties": false + } + } + }, + "get_all_pending_invitations_paged": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PendingInvitationsPagedResponse", + "description": "Response to [`QueryMsg::GetAllPendingInvitationsPaged`](crate::QueryMsg::GetAllPendingInvitationsPaged).", + "type": "object", + "required": [ + "invitations" + ], + "properties": { + "invitations": { + "description": "The pending invitations on this page, in ascending `(family_id, node_id)` order, each stamped with whether it had already timed out at the time the query was served.", + "type": "array", + "items": { + "$ref": "#/definitions/PendingFamilyInvitationDetails" + } + }, + "start_next_after": { + "description": "Cursor (last `(family_id, node_id)` pair) to pass as `start_after` on the next call, or `None` if this page is empty (treat as end-of-list).", + "type": [ + "array", + "null" + ], + "items": [ + { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + ], + "maxItems": 2, + "minItems": 2 + } + }, + "additionalProperties": false, + "definitions": { + "FamilyInvitation": { + "description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, revoked, or until the chain advances past `expires_at` (in which case they remain in storage but are treated as inert — there is no background process clearing expired invitations).", + "type": "object", + "required": [ + "expires_at", + "family_id", + "node_id" + ], + "properties": { + "expires_at": { + "description": "Block timestamp (unix seconds) after which the invitation is no longer valid.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "family_id": { + "description": "The family that issued the invitation.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "node_id": { + "description": "The node being invited.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + "PendingFamilyInvitationDetails": { + "description": "A pending [`FamilyInvitation`] paired with whether it has already timed out at the time the query was served.", + "type": "object", + "required": [ + "expired", + "invitation" + ], + "properties": { + "expired": { + "description": "`true` iff `now >= invitation.expires_at` at query time, i.e. the invitation is still in the pending map but can no longer be acted on.", + "type": "boolean" + }, + "invitation": { + "description": "The stored invitation as it was issued.", + "allOf": [ + { + "$ref": "#/definitions/FamilyInvitation" + } + ] + } + }, + "additionalProperties": false + } + } + }, + "get_families_paged": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "FamiliesPagedResponse", + "description": "Response to [`QueryMsg::GetFamiliesPaged`](crate::QueryMsg::GetFamiliesPaged).", + "type": "object", + "required": [ + "families" + ], + "properties": { + "families": { + "description": "The families on this page, in ascending [`NodeFamilyId`] order.", + "type": "array", + "items": { + "$ref": "#/definitions/NodeFamily" + } + }, + "start_next_after": { + "description": "Cursor to pass as `start_after` on the next call, or `None` if this page is empty (which the caller should treat as end-of-list).", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + } + }, + "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 + }, + "NodeFamily": { + "description": "On-chain representation of a node family.", + "type": "object", + "required": [ + "created_at", + "description", + "id", + "members", + "name", + "normalised_name", + "owner", + "paid_fee" + ], + "properties": { + "created_at": { + "description": "Timestamp of the creation of the node family", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "description": { + "description": "The optional description of the node family", + "type": "string" + }, + "id": { + "description": "The id of the node family", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "members": { + "description": "Memoized value of the current number of members in the node family Used to detect if the family is empty", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "name": { + "description": "The name of the node family", + "type": "string" + }, + "normalised_name": { + "description": "Normalised name of the node family used for uniqueness checks", + "type": "string" + }, + "owner": { + "description": "The owner of the node family", + "allOf": [ + { + "$ref": "#/definitions/Addr" + } + ] + }, + "paid_fee": { + "description": "Records the fee paid when the family was created, so that the appropriate amount could be returned upon it getting disbanded.", + "allOf": [ + { + "$ref": "#/definitions/Coin" + } + ] + } + }, + "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_family_by_id": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "NodeFamilyResponse", + "description": "Response to [`QueryMsg::GetFamilyById`](crate::QueryMsg::GetFamilyById).", + "type": "object", + "required": [ + "family_id" + ], + "properties": { + "family": { + "description": "The matching family, or `None` if no family with `family_id` exists.", + "anyOf": [ + { + "$ref": "#/definitions/NodeFamily" + }, + { + "type": "null" + } + ] + }, + "family_id": { + "description": "The id that was queried, echoed back so paginated callers can correlate.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "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 + }, + "NodeFamily": { + "description": "On-chain representation of a node family.", + "type": "object", + "required": [ + "created_at", + "description", + "id", + "members", + "name", + "normalised_name", + "owner", + "paid_fee" + ], + "properties": { + "created_at": { + "description": "Timestamp of the creation of the node family", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "description": { + "description": "The optional description of the node family", + "type": "string" + }, + "id": { + "description": "The id of the node family", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "members": { + "description": "Memoized value of the current number of members in the node family Used to detect if the family is empty", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "name": { + "description": "The name of the node family", + "type": "string" + }, + "normalised_name": { + "description": "Normalised name of the node family used for uniqueness checks", + "type": "string" + }, + "owner": { + "description": "The owner of the node family", + "allOf": [ + { + "$ref": "#/definitions/Addr" + } + ] + }, + "paid_fee": { + "description": "Records the fee paid when the family was created, so that the appropriate amount could be returned upon it getting disbanded.", + "allOf": [ + { + "$ref": "#/definitions/Coin" + } + ] + } + }, + "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_family_by_name": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "NodeFamilyByNameResponse", + "description": "Response to [`QueryMsg::GetFamilyByName`](crate::QueryMsg::GetFamilyByName).", + "type": "object", + "required": [ + "name" + ], + "properties": { + "family": { + "description": "The matching family, or `None` if no family with that name exists.", + "anyOf": [ + { + "$ref": "#/definitions/NodeFamily" + }, + { + "type": "null" + } + ] + }, + "name": { + "description": "The name that was queried, echoed back so callers can correlate.", + "type": "string" + } + }, + "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 + }, + "NodeFamily": { + "description": "On-chain representation of a node family.", + "type": "object", + "required": [ + "created_at", + "description", + "id", + "members", + "name", + "normalised_name", + "owner", + "paid_fee" + ], + "properties": { + "created_at": { + "description": "Timestamp of the creation of the node family", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "description": { + "description": "The optional description of the node family", + "type": "string" + }, + "id": { + "description": "The id of the node family", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "members": { + "description": "Memoized value of the current number of members in the node family Used to detect if the family is empty", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "name": { + "description": "The name of the node family", + "type": "string" + }, + "normalised_name": { + "description": "Normalised name of the node family used for uniqueness checks", + "type": "string" + }, + "owner": { + "description": "The owner of the node family", + "allOf": [ + { + "$ref": "#/definitions/Addr" + } + ] + }, + "paid_fee": { + "description": "Records the fee paid when the family was created, so that the appropriate amount could be returned upon it getting disbanded.", + "allOf": [ + { + "$ref": "#/definitions/Coin" + } + ] + } + }, + "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_family_by_owner": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "NodeFamilyByOwnerResponse", + "description": "Response to [`QueryMsg::GetFamilyByOwner`](crate::QueryMsg::GetFamilyByOwner).", + "type": "object", + "required": [ + "owner" + ], + "properties": { + "family": { + "description": "The matching family, or `None` if `owner` does not currently own one.", + "anyOf": [ + { + "$ref": "#/definitions/NodeFamily" + }, + { + "type": "null" + } + ] + }, + "owner": { + "description": "The (validated) owner address that was queried, echoed back so callers can correlate.", + "allOf": [ + { + "$ref": "#/definitions/Addr" + } + ] + } + }, + "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 + }, + "NodeFamily": { + "description": "On-chain representation of a node family.", + "type": "object", + "required": [ + "created_at", + "description", + "id", + "members", + "name", + "normalised_name", + "owner", + "paid_fee" + ], + "properties": { + "created_at": { + "description": "Timestamp of the creation of the node family", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "description": { + "description": "The optional description of the node family", + "type": "string" + }, + "id": { + "description": "The id of the node family", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "members": { + "description": "Memoized value of the current number of members in the node family Used to detect if the family is empty", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "name": { + "description": "The name of the node family", + "type": "string" + }, + "normalised_name": { + "description": "Normalised name of the node family used for uniqueness checks", + "type": "string" + }, + "owner": { + "description": "The owner of the node family", + "allOf": [ + { + "$ref": "#/definitions/Addr" + } + ] + }, + "paid_fee": { + "description": "Records the fee paid when the family was created, so that the appropriate amount could be returned upon it getting disbanded.", + "allOf": [ + { + "$ref": "#/definitions/Coin" + } + ] + } + }, + "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_family_members_paged": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "FamilyMembersPagedResponse", + "description": "Response to [`QueryMsg::GetFamilyMembersPaged`](crate::QueryMsg::GetFamilyMembersPaged).", + "type": "object", + "required": [ + "family_id", + "members" + ], + "properties": { + "family_id": { + "description": "The family whose members were queried, echoed back so paginated callers can correlate.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "members": { + "description": "The members on this page, in ascending [`NodeId`] order.", + "type": "array", + "items": { + "$ref": "#/definitions/FamilyMemberRecord" + } + }, + "start_next_after": { + "description": "Cursor to pass as `start_after` on the next call, or `None` if this page is empty (which the caller should treat as end-of-list).", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false, + "definitions": { + "FamilyMemberRecord": { + "description": "One entry in a [`FamilyMembersPagedResponse`] page — pairs a node id with its [`FamilyMembership`] record (notably its `joined_at` timestamp).", + "type": "object", + "required": [ + "membership", + "node_id" + ], + "properties": { + "membership": { + "description": "The membership record (carries `family_id` and `joined_at`).", + "allOf": [ + { + "$ref": "#/definitions/FamilyMembership" + } + ] + }, + "node_id": { + "description": "The node currently in the family.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + "FamilyMembership": { + "description": "On-chain record of a node's current family membership.\n\nA node belongs to at most one family at a time, so this is keyed by `NodeId` alone — `family_id` is carried in the value to support reverse lookups (all nodes in a given family) via a secondary index.", + "type": "object", + "required": [ + "family_id", + "joined_at" + ], + "properties": { + "family_id": { + "description": "The family the node is currently a member of.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "joined_at": { + "description": "Block timestamp (unix seconds) at which the node accepted its invitation and joined the family.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + } + }, + "get_family_membership": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "NodeFamilyMembershipResponse", + "description": "Response to [`QueryMsg::GetFamilyMembership`](crate::QueryMsg::GetFamilyMembership).", + "type": "object", + "required": [ + "node_id" + ], + "properties": { + "family_id": { + "description": "The id of the family the node currently belongs to, or `None` if the node is not currently a member of any family.", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "node_id": { + "description": "The node that was queried.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + "get_past_invitations_for_family_paged": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PastFamilyInvitationsPagedResponse", + "description": "Response to [`QueryMsg::GetPastInvitationsForFamilyPaged`](crate::QueryMsg::GetPastInvitationsForFamilyPaged).", + "type": "object", + "required": [ + "family_id", + "invitations" + ], + "properties": { + "family_id": { + "description": "The family whose archived invitations were queried, echoed back so paginated callers can correlate.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "invitations": { + "description": "The archived invitations on this page, in ascending `(node_id, counter)` order across all terminal statuses.", + "type": "array", + "items": { + "$ref": "#/definitions/PastFamilyInvitation" + } + }, + "start_next_after": { + "description": "Cursor to pass as `start_after` on the next call, or `None` if this page is empty (treat as end-of-list).", + "type": [ + "array", + "null" + ], + "items": [ + { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + ], + "maxItems": 2, + "minItems": 2 + } + }, + "additionalProperties": false, + "definitions": { + "FamilyInvitation": { + "description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, revoked, or until the chain advances past `expires_at` (in which case they remain in storage but are treated as inert — there is no background process clearing expired invitations).", + "type": "object", + "required": [ + "expires_at", + "family_id", + "node_id" + ], + "properties": { + "expires_at": { + "description": "Block timestamp (unix seconds) after which the invitation is no longer valid.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "family_id": { + "description": "The family that issued the invitation.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "node_id": { + "description": "The node being invited.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + "FamilyInvitationStatus": { + "description": "Terminal status for an invitation that has been moved out of the pending set.\n\nNote: timed-out invitations are not represented here — they are simply left in the pending set (see `FamilyInvitation::expires_at`).", + "oneOf": [ + { + "description": "Still awaiting a response. Recorded with a timestamp for completeness even though pending invitations live in a separate map.", + "type": "object", + "required": [ + "pending" + ], + "properties": { + "pending": { + "type": "object", + "required": [ + "at" + ], + "properties": { + "at": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "The invitee accepted and joined the family at the given timestamp.", + "type": "object", + "required": [ + "accepted" + ], + "properties": { + "accepted": { + "type": "object", + "required": [ + "at" + ], + "properties": { + "at": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "The invitee explicitly rejected the invitation at the given timestamp.", + "type": "object", + "required": [ + "rejected" + ], + "properties": { + "rejected": { + "type": "object", + "required": [ + "at" + ], + "properties": { + "at": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "The family revoked the invitation at the given timestamp before it could be accepted or rejected.", + "type": "object", + "required": [ + "revoked" + ], + "properties": { + "revoked": { + "type": "object", + "required": [ + "at" + ], + "properties": { + "at": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "PastFamilyInvitation": { + "description": "Historical record of an invitation that has reached a terminal state (`Accepted`, `Rejected`, or `Revoked`). Timed-out invitations are **not** archived here — they remain in the pending map until explicitly cleared.", + "type": "object", + "required": [ + "invitation", + "status" + ], + "properties": { + "invitation": { + "description": "The original invitation as it was issued.", + "allOf": [ + { + "$ref": "#/definitions/FamilyInvitation" + } + ] + }, + "status": { + "description": "What ultimately happened to it.", + "allOf": [ + { + "$ref": "#/definitions/FamilyInvitationStatus" + } + ] + } + }, + "additionalProperties": false + } + } + }, + "get_past_invitations_for_node_paged": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PastFamilyInvitationsForNodePagedResponse", + "description": "Response to [`QueryMsg::GetPastInvitationsForNodePaged`](crate::QueryMsg::GetPastInvitationsForNodePaged).", + "type": "object", + "required": [ + "invitations", + "node_id" + ], + "properties": { + "invitations": { + "description": "The archived invitations addressed to this node on this page, in ascending `(family_id, counter)` order across all terminal statuses.", + "type": "array", + "items": { + "$ref": "#/definitions/PastFamilyInvitation" + } + }, + "node_id": { + "description": "The node whose past invitations were queried, echoed back so paginated callers can correlate.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "start_next_after": { + "description": "Cursor to pass as `start_after` on the next call, or `None` if this page is empty (treat as end-of-list).", + "type": [ + "array", + "null" + ], + "items": [ + { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + ], + "maxItems": 2, + "minItems": 2 + } + }, + "additionalProperties": false, + "definitions": { + "FamilyInvitation": { + "description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, revoked, or until the chain advances past `expires_at` (in which case they remain in storage but are treated as inert — there is no background process clearing expired invitations).", + "type": "object", + "required": [ + "expires_at", + "family_id", + "node_id" + ], + "properties": { + "expires_at": { + "description": "Block timestamp (unix seconds) after which the invitation is no longer valid.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "family_id": { + "description": "The family that issued the invitation.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "node_id": { + "description": "The node being invited.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + "FamilyInvitationStatus": { + "description": "Terminal status for an invitation that has been moved out of the pending set.\n\nNote: timed-out invitations are not represented here — they are simply left in the pending set (see `FamilyInvitation::expires_at`).", + "oneOf": [ + { + "description": "Still awaiting a response. Recorded with a timestamp for completeness even though pending invitations live in a separate map.", + "type": "object", + "required": [ + "pending" + ], + "properties": { + "pending": { + "type": "object", + "required": [ + "at" + ], + "properties": { + "at": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "The invitee accepted and joined the family at the given timestamp.", + "type": "object", + "required": [ + "accepted" + ], + "properties": { + "accepted": { + "type": "object", + "required": [ + "at" + ], + "properties": { + "at": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "The invitee explicitly rejected the invitation at the given timestamp.", + "type": "object", + "required": [ + "rejected" + ], + "properties": { + "rejected": { + "type": "object", + "required": [ + "at" + ], + "properties": { + "at": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "The family revoked the invitation at the given timestamp before it could be accepted or rejected.", + "type": "object", + "required": [ + "revoked" + ], + "properties": { + "revoked": { + "type": "object", + "required": [ + "at" + ], + "properties": { + "at": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "PastFamilyInvitation": { + "description": "Historical record of an invitation that has reached a terminal state (`Accepted`, `Rejected`, or `Revoked`). Timed-out invitations are **not** archived here — they remain in the pending map until explicitly cleared.", + "type": "object", + "required": [ + "invitation", + "status" + ], + "properties": { + "invitation": { + "description": "The original invitation as it was issued.", + "allOf": [ + { + "$ref": "#/definitions/FamilyInvitation" + } + ] + }, + "status": { + "description": "What ultimately happened to it.", + "allOf": [ + { + "$ref": "#/definitions/FamilyInvitationStatus" + } + ] + } + }, + "additionalProperties": false + } + } + }, + "get_past_members_for_family_paged": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PastFamilyMembersPagedResponse", + "description": "Response to [`QueryMsg::GetPastMembersForFamilyPaged`](crate::QueryMsg::GetPastMembersForFamilyPaged).", + "type": "object", + "required": [ + "family_id", + "members" + ], + "properties": { + "family_id": { + "description": "The family whose archived memberships were queried, echoed back so paginated callers can correlate.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "members": { + "description": "The archived membership records on this page, in ascending `(node_id, counter)` order.", + "type": "array", + "items": { + "$ref": "#/definitions/PastFamilyMember" + } + }, + "start_next_after": { + "description": "Cursor to pass as `start_after` on the next call, or `None` if this page is empty (treat as end-of-list).", + "type": [ + "array", + "null" + ], + "items": [ + { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + ], + "maxItems": 2, + "minItems": 2 + } + }, + "additionalProperties": false, + "definitions": { + "PastFamilyMember": { + "description": "Historical record of a node that used to be part of a family but has since been removed (kicked, left voluntarily, or because the family was disbanded).", + "type": "object", + "required": [ + "family_id", + "node_id", + "removed_at" + ], + "properties": { + "family_id": { + "description": "The family the node used to belong to.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "node_id": { + "description": "The node that was removed.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "removed_at": { + "description": "Block timestamp (unix seconds) at which the membership was terminated.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + } + }, + "get_past_members_for_node_paged": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PastFamilyMembersForNodePagedResponse", + "description": "Response to [`QueryMsg::GetPastMembersForNodePaged`](crate::QueryMsg::GetPastMembersForNodePaged).", + "type": "object", + "required": [ + "members", + "node_id" + ], + "properties": { + "members": { + "description": "The archived membership records for this node on this page, in ascending `(family_id, counter)` order.", + "type": "array", + "items": { + "$ref": "#/definitions/PastFamilyMember" + } + }, + "node_id": { + "description": "The node whose archived memberships were queried, echoed back so paginated callers can correlate.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "start_next_after": { + "description": "Cursor to pass as `start_after` on the next call, or `None` if this page is empty (treat as end-of-list).", + "type": [ + "array", + "null" + ], + "items": [ + { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + ], + "maxItems": 2, + "minItems": 2 + } + }, + "additionalProperties": false, + "definitions": { + "PastFamilyMember": { + "description": "Historical record of a node that used to be part of a family but has since been removed (kicked, left voluntarily, or because the family was disbanded).", + "type": "object", + "required": [ + "family_id", + "node_id", + "removed_at" + ], + "properties": { + "family_id": { + "description": "The family the node used to belong to.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "node_id": { + "description": "The node that was removed.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "removed_at": { + "description": "Block timestamp (unix seconds) at which the membership was terminated.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + } + }, + "get_pending_invitation": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PendingFamilyInvitationResponse", + "description": "Response to [`QueryMsg::GetPendingInvitation`](crate::QueryMsg::GetPendingInvitation).", + "type": "object", + "required": [ + "family_id", + "node_id" + ], + "properties": { + "family_id": { + "description": "The family component of the queried `(family_id, node_id)` key.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "invitation": { + "description": "The matching pending invitation along with an explicit expiry flag, or `None` if no such invitation exists.", + "anyOf": [ + { + "$ref": "#/definitions/PendingFamilyInvitationDetails" + }, + { + "type": "null" + } + ] + }, + "node_id": { + "description": "The node component of the queried `(family_id, node_id)` key.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false, + "definitions": { + "FamilyInvitation": { + "description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, revoked, or until the chain advances past `expires_at` (in which case they remain in storage but are treated as inert — there is no background process clearing expired invitations).", + "type": "object", + "required": [ + "expires_at", + "family_id", + "node_id" + ], + "properties": { + "expires_at": { + "description": "Block timestamp (unix seconds) after which the invitation is no longer valid.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "family_id": { + "description": "The family that issued the invitation.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "node_id": { + "description": "The node being invited.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + "PendingFamilyInvitationDetails": { + "description": "A pending [`FamilyInvitation`] paired with whether it has already timed out at the time the query was served.", + "type": "object", + "required": [ + "expired", + "invitation" + ], + "properties": { + "expired": { + "description": "`true` iff `now >= invitation.expires_at` at query time, i.e. the invitation is still in the pending map but can no longer be acted on.", + "type": "boolean" + }, + "invitation": { + "description": "The stored invitation as it was issued.", + "allOf": [ + { + "$ref": "#/definitions/FamilyInvitation" + } + ] + } + }, + "additionalProperties": false + } + } + }, + "get_pending_invitations_for_family_paged": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PendingFamilyInvitationsPagedResponse", + "description": "Response to [`QueryMsg::GetPendingInvitationsForFamilyPaged`](crate::QueryMsg::GetPendingInvitationsForFamilyPaged).", + "type": "object", + "required": [ + "family_id", + "invitations" + ], + "properties": { + "family_id": { + "description": "The family whose pending invitations were queried, echoed back so paginated callers can correlate.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "invitations": { + "description": "The pending invitations on this page, in ascending invitee [`NodeId`] order, each stamped with whether it had already timed out at the time the query was served.", + "type": "array", + "items": { + "$ref": "#/definitions/PendingFamilyInvitationDetails" + } + }, + "start_next_after": { + "description": "Cursor (last invitee node id) to pass as `start_after` on the next call, or `None` if this page is empty (treat as end-of-list).", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false, + "definitions": { + "FamilyInvitation": { + "description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, revoked, or until the chain advances past `expires_at` (in which case they remain in storage but are treated as inert — there is no background process clearing expired invitations).", + "type": "object", + "required": [ + "expires_at", + "family_id", + "node_id" + ], + "properties": { + "expires_at": { + "description": "Block timestamp (unix seconds) after which the invitation is no longer valid.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "family_id": { + "description": "The family that issued the invitation.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "node_id": { + "description": "The node being invited.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + "PendingFamilyInvitationDetails": { + "description": "A pending [`FamilyInvitation`] paired with whether it has already timed out at the time the query was served.", + "type": "object", + "required": [ + "expired", + "invitation" + ], + "properties": { + "expired": { + "description": "`true` iff `now >= invitation.expires_at` at query time, i.e. the invitation is still in the pending map but can no longer be acted on.", + "type": "boolean" + }, + "invitation": { + "description": "The stored invitation as it was issued.", + "allOf": [ + { + "$ref": "#/definitions/FamilyInvitation" + } + ] + } + }, + "additionalProperties": false + } + } + }, + "get_pending_invitations_for_node_paged": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PendingInvitationsForNodePagedResponse", + "description": "Response to [`QueryMsg::GetPendingInvitationsForNodePaged`](crate::QueryMsg::GetPendingInvitationsForNodePaged).", + "type": "object", + "required": [ + "invitations", + "node_id" + ], + "properties": { + "invitations": { + "description": "The pending invitations addressed to this node on this page, in ascending [`NodeFamilyId`] order, each stamped with whether it had already timed out at the time the query was served.", + "type": "array", + "items": { + "$ref": "#/definitions/PendingFamilyInvitationDetails" + } + }, + "node_id": { + "description": "The node whose pending invitations were queried, echoed back so paginated callers can correlate.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "start_next_after": { + "description": "Cursor (last issuing family id) to pass as `start_after` on the next call, or `None` if this page is empty (treat as end-of-list).", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false, + "definitions": { + "FamilyInvitation": { + "description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, revoked, or until the chain advances past `expires_at` (in which case they remain in storage but are treated as inert — there is no background process clearing expired invitations).", + "type": "object", + "required": [ + "expires_at", + "family_id", + "node_id" + ], + "properties": { + "expires_at": { + "description": "Block timestamp (unix seconds) after which the invitation is no longer valid.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "family_id": { + "description": "The family that issued the invitation.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "node_id": { + "description": "The node being invited.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + "PendingFamilyInvitationDetails": { + "description": "A pending [`FamilyInvitation`] paired with whether it has already timed out at the time the query was served.", + "type": "object", + "required": [ + "expired", + "invitation" + ], + "properties": { + "expired": { + "description": "`true` iff `now >= invitation.expires_at` at query time, i.e. the invitation is still in the pending map but can no longer be acted on.", + "type": "boolean" + }, + "invitation": { + "description": "The stored invitation as it was issued.", + "allOf": [ + { + "$ref": "#/definitions/FamilyInvitation" + } + ] + } + }, + "additionalProperties": false + } + } + } + } +} diff --git a/contracts/node-families/schema/raw/execute.json b/contracts/node-families/schema/raw/execute.json new file mode 100644 index 0000000000..7b06d071f0 --- /dev/null +++ b/contracts/node-families/schema/raw/execute.json @@ -0,0 +1,318 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ExecuteMsg", + "description": "Execute messages accepted by the contract.", + "oneOf": [ + { + "description": "Replace the contract's runtime [`Config`]. Restricted to the contract admin.", + "type": "object", + "required": [ + "update_config" + ], + "properties": { + "update_config": { + "type": "object", + "required": [ + "config" + ], + "properties": { + "config": { + "$ref": "#/definitions/Config" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Create a new family owned by the message sender. The configured `create_family_fee` must be attached as funds.", + "type": "object", + "required": [ + "create_family" + ], + "properties": { + "create_family": { + "type": "object", + "required": [ + "description", + "name" + ], + "properties": { + "description": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Disband the family owned by the message sender. The family must have no current members; any still-pending invitations are revoked.", + "type": "object", + "required": [ + "disband_family" + ], + "properties": { + "disband_family": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Invite a node to the family owned by the message sender. If `validity_secs` is omitted the invitation expires `default_invitation_validity_secs` seconds (from [`Config`]) after the current block time.", + "type": "object", + "required": [ + "invite_to_family" + ], + "properties": { + "invite_to_family": { + "type": "object", + "required": [ + "node_id" + ], + "properties": { + "node_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "validity_secs": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Revoke a still-pending invitation previously issued by the sender's family.", + "type": "object", + "required": [ + "revoke_family_invitation" + ], + "properties": { + "revoke_family_invitation": { + "type": "object", + "required": [ + "node_id" + ], + "properties": { + "node_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Accept a pending invitation. The sender must control `node_id`.", + "type": "object", + "required": [ + "accept_family_invitation" + ], + "properties": { + "accept_family_invitation": { + "type": "object", + "required": [ + "family_id", + "node_id" + ], + "properties": { + "family_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "node_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Reject a pending invitation. The sender must control `node_id`.", + "type": "object", + "required": [ + "reject_family_invitation" + ], + "properties": { + "reject_family_invitation": { + "type": "object", + "required": [ + "family_id", + "node_id" + ], + "properties": { + "family_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "node_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Leave the family `node_id` currently belongs to. The sender must control `node_id`.", + "type": "object", + "required": [ + "leave_family" + ], + "properties": { + "leave_family": { + "type": "object", + "required": [ + "node_id" + ], + "properties": { + "node_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Remove `node_id` from the family owned by the message sender.", + "type": "object", + "required": [ + "kick_from_family" + ], + "properties": { + "kick_from_family": { + "type": "object", + "required": [ + "node_id" + ], + "properties": { + "node_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Cross-contract callback fired by the mixnet contract the moment node with `node_id` initiates unbonding. Removes the node from any family it currently belongs to and rejects every pending invitation issued to it. Sender must be the configured mixnet contract address.", + "type": "object", + "required": [ + "on_nym_node_unbond" + ], + "properties": { + "on_nym_node_unbond": { + "type": "object", + "required": [ + "node_id" + ], + "properties": { + "node_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ], + "definitions": { + "Coin": { + "type": "object", + "required": [ + "amount", + "denom" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "denom": { + "type": "string" + } + }, + "additionalProperties": false + }, + "Config": { + "description": "Runtime configuration of the node families contract.", + "type": "object", + "required": [ + "create_family_fee", + "default_invitation_validity_secs", + "family_description_length_limit", + "family_name_length_limit" + ], + "properties": { + "create_family_fee": { + "description": "Fee charged on each successful `create_family` execution.", + "allOf": [ + { + "$ref": "#/definitions/Coin" + } + ] + }, + "default_invitation_validity_secs": { + "description": "Default lifetime, in seconds, used by `invite_to_family` when the sender doesn't supply an explicit value. Senders may override this per-invitation via the optional `validity_secs` argument.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "family_description_length_limit": { + "description": "Maximum allowed length, in characters, of a family description.", + "type": "integer", + "format": "uint", + "minimum": 0.0 + }, + "family_name_length_limit": { + "description": "Maximum allowed length, in characters, of a family name.", + "type": "integer", + "format": "uint", + "minimum": 0.0 + } + }, + "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/node-families/schema/raw/instantiate.json b/contracts/node-families/schema/raw/instantiate.json new file mode 100644 index 0000000000..596ec07212 --- /dev/null +++ b/contracts/node-families/schema/raw/instantiate.json @@ -0,0 +1,80 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "InstantiateMsg", + "description": "Message used to instantiate the node families contract.", + "type": "object", + "required": [ + "config", + "mixnet_contract_address" + ], + "properties": { + "config": { + "$ref": "#/definitions/Config" + }, + "mixnet_contract_address": { + "type": "string" + } + }, + "additionalProperties": false, + "definitions": { + "Coin": { + "type": "object", + "required": [ + "amount", + "denom" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "denom": { + "type": "string" + } + }, + "additionalProperties": false + }, + "Config": { + "description": "Runtime configuration of the node families contract.", + "type": "object", + "required": [ + "create_family_fee", + "default_invitation_validity_secs", + "family_description_length_limit", + "family_name_length_limit" + ], + "properties": { + "create_family_fee": { + "description": "Fee charged on each successful `create_family` execution.", + "allOf": [ + { + "$ref": "#/definitions/Coin" + } + ] + }, + "default_invitation_validity_secs": { + "description": "Default lifetime, in seconds, used by `invite_to_family` when the sender doesn't supply an explicit value. Senders may override this per-invitation via the optional `validity_secs` argument.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "family_description_length_limit": { + "description": "Maximum allowed length, in characters, of a family description.", + "type": "integer", + "format": "uint", + "minimum": 0.0 + }, + "family_name_length_limit": { + "description": "Maximum allowed length, in characters, of a family name.", + "type": "integer", + "format": "uint", + "minimum": 0.0 + } + }, + "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/node-families/schema/raw/migrate.json b/contracts/node-families/schema/raw/migrate.json new file mode 100644 index 0000000000..a02787c6a5 --- /dev/null +++ b/contracts/node-families/schema/raw/migrate.json @@ -0,0 +1,7 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "MigrateMsg", + "description": "Message passed to the contract's `migrate` entry point.", + "type": "object", + "additionalProperties": false +} diff --git a/contracts/node-families/schema/raw/query.json b/contracts/node-families/schema/raw/query.json new file mode 100644 index 0000000000..f03b673f0d --- /dev/null +++ b/contracts/node-families/schema/raw/query.json @@ -0,0 +1,620 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "QueryMsg", + "description": "Query messages accepted by the contract.", + "oneOf": [ + { + "description": "Look up a single family by its id.", + "type": "object", + "required": [ + "get_family_by_id" + ], + "properties": { + "get_family_by_id": { + "type": "object", + "required": [ + "family_id" + ], + "properties": { + "family_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Look up the (at most one) family owned by a given address.", + "type": "object", + "required": [ + "get_family_by_owner" + ], + "properties": { + "get_family_by_owner": { + "type": "object", + "required": [ + "owner" + ], + "properties": { + "owner": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Look up a single family by its name. The lookup is normalised contract-side (lowercased, non-alphanumerics stripped), so equivalent inputs resolve to the same family.", + "type": "object", + "required": [ + "get_family_by_name" + ], + "properties": { + "get_family_by_name": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "get_families_paged" + ], + "properties": { + "get_families_paged": { + "type": "object", + "properties": { + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Look up which family — if any — a node currently belongs to.", + "type": "object", + "required": [ + "get_family_membership" + ], + "properties": { + "get_family_membership": { + "type": "object", + "required": [ + "node_id" + ], + "properties": { + "node_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Page through every node currently in a given family.", + "type": "object", + "required": [ + "get_family_members_paged" + ], + "properties": { + "get_family_members_paged": { + "type": "object", + "required": [ + "family_id" + ], + "properties": { + "family_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Page through every current family member across all families, in ascending [`NodeId`] order. Each entry carries the membership record (which in turn names the family the node belongs to).", + "type": "object", + "required": [ + "get_all_family_members_paged" + ], + "properties": { + "get_all_family_members_paged": { + "type": "object", + "properties": { + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Look up the pending invitation for a specific `(family_id, node_id)` pair.", + "type": "object", + "required": [ + "get_pending_invitation" + ], + "properties": { + "get_pending_invitation": { + "type": "object", + "required": [ + "family_id", + "node_id" + ], + "properties": { + "family_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "node_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Page through every pending invitation issued by a given family.", + "type": "object", + "required": [ + "get_pending_invitations_for_family_paged" + ], + "properties": { + "get_pending_invitations_for_family_paged": { + "type": "object", + "required": [ + "family_id" + ], + "properties": { + "family_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Page through every pending invitation issued for a given node.", + "type": "object", + "required": [ + "get_pending_invitations_for_node_paged" + ], + "properties": { + "get_pending_invitations_for_node_paged": { + "type": "object", + "required": [ + "node_id" + ], + "properties": { + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "node_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Page through every pending invitation across all families.", + "type": "object", + "required": [ + "get_all_pending_invitations_paged" + ], + "properties": { + "get_all_pending_invitations_paged": { + "type": "object", + "properties": { + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "type": [ + "array", + "null" + ], + "items": [ + { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + ], + "maxItems": 2, + "minItems": 2 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Page through every archived (terminal-state) invitation issued by a given family.", + "type": "object", + "required": [ + "get_past_invitations_for_family_paged" + ], + "properties": { + "get_past_invitations_for_family_paged": { + "type": "object", + "required": [ + "family_id" + ], + "properties": { + "family_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "type": [ + "array", + "null" + ], + "items": [ + { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + ], + "maxItems": 2, + "minItems": 2 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Page through every archived (terminal-state) invitation issued to a given node.", + "type": "object", + "required": [ + "get_past_invitations_for_node_paged" + ], + "properties": { + "get_past_invitations_for_node_paged": { + "type": "object", + "required": [ + "node_id" + ], + "properties": { + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "node_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "type": [ + "array", + "null" + ], + "items": [ + { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + ], + "maxItems": 2, + "minItems": 2 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Page through every archived (terminal-state) invitation across all families.", + "type": "object", + "required": [ + "get_all_past_invitations_paged" + ], + "properties": { + "get_all_past_invitations_paged": { + "type": "object", + "properties": { + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "type": [ + "array", + "null" + ], + "items": [ + { + "type": "array", + "items": [ + { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + ], + "maxItems": 2, + "minItems": 2 + }, + { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + ], + "maxItems": 2, + "minItems": 2 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Page through every archived membership record for a given family (nodes that used to belong to it but have since been removed).", + "type": "object", + "required": [ + "get_past_members_for_family_paged" + ], + "properties": { + "get_past_members_for_family_paged": { + "type": "object", + "required": [ + "family_id" + ], + "properties": { + "family_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "type": [ + "array", + "null" + ], + "items": [ + { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + ], + "maxItems": 2, + "minItems": 2 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Page through every archived membership record for a given node (every family the node used to belong to but has since been removed from), across all families.", + "type": "object", + "required": [ + "get_past_members_for_node_paged" + ], + "properties": { + "get_past_members_for_node_paged": { + "type": "object", + "required": [ + "node_id" + ], + "properties": { + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "node_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "type": [ + "array", + "null" + ], + "items": [ + { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + ], + "maxItems": 2, + "minItems": 2 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] +} diff --git a/contracts/node-families/schema/raw/response_to_get_all_family_members_paged.json b/contracts/node-families/schema/raw/response_to_get_all_family_members_paged.json new file mode 100644 index 0000000000..d6d4baaf5c --- /dev/null +++ b/contracts/node-families/schema/raw/response_to_get_all_family_members_paged.json @@ -0,0 +1,78 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "AllFamilyMembersPagedResponse", + "description": "Response to [`QueryMsg::GetAllFamilyMembersPaged`](crate::QueryMsg::GetAllFamilyMembersPaged).", + "type": "object", + "required": [ + "members" + ], + "properties": { + "members": { + "description": "The members on this page, in ascending [`NodeId`] order across every family.", + "type": "array", + "items": { + "$ref": "#/definitions/FamilyMemberRecord" + } + }, + "start_next_after": { + "description": "Cursor (last `node_id`) to pass as `start_after` on the next call, or `None` if this page is empty (treat as end-of-list).", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false, + "definitions": { + "FamilyMemberRecord": { + "description": "One entry in a [`FamilyMembersPagedResponse`] page — pairs a node id with its [`FamilyMembership`] record (notably its `joined_at` timestamp).", + "type": "object", + "required": [ + "membership", + "node_id" + ], + "properties": { + "membership": { + "description": "The membership record (carries `family_id` and `joined_at`).", + "allOf": [ + { + "$ref": "#/definitions/FamilyMembership" + } + ] + }, + "node_id": { + "description": "The node currently in the family.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + "FamilyMembership": { + "description": "On-chain record of a node's current family membership.\n\nA node belongs to at most one family at a time, so this is keyed by `NodeId` alone — `family_id` is carried in the value to support reverse lookups (all nodes in a given family) via a secondary index.", + "type": "object", + "required": [ + "family_id", + "joined_at" + ], + "properties": { + "family_id": { + "description": "The family the node is currently a member of.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "joined_at": { + "description": "Block timestamp (unix seconds) at which the node accepted its invitation and joined the family.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + } +} diff --git a/contracts/node-families/schema/raw/response_to_get_all_past_invitations_paged.json b/contracts/node-families/schema/raw/response_to_get_all_past_invitations_paged.json new file mode 100644 index 0000000000..dcbb67974b --- /dev/null +++ b/contracts/node-families/schema/raw/response_to_get_all_past_invitations_paged.json @@ -0,0 +1,212 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "AllPastFamilyInvitationsPagedResponse", + "description": "Response to [`QueryMsg::GetAllPastInvitationsPaged`](crate::QueryMsg::GetAllPastInvitationsPaged).", + "type": "object", + "required": [ + "invitations" + ], + "properties": { + "invitations": { + "description": "The archived invitations on this page, in ascending `((family_id, node_id), counter)` order across all terminal statuses.", + "type": "array", + "items": { + "$ref": "#/definitions/PastFamilyInvitation" + } + }, + "start_next_after": { + "description": "Cursor to pass as `start_after` on the next call, or `None` if this page is empty (treat as end-of-list).", + "type": [ + "array", + "null" + ], + "items": [ + { + "type": "array", + "items": [ + { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + ], + "maxItems": 2, + "minItems": 2 + }, + { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + ], + "maxItems": 2, + "minItems": 2 + } + }, + "additionalProperties": false, + "definitions": { + "FamilyInvitation": { + "description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, revoked, or until the chain advances past `expires_at` (in which case they remain in storage but are treated as inert — there is no background process clearing expired invitations).", + "type": "object", + "required": [ + "expires_at", + "family_id", + "node_id" + ], + "properties": { + "expires_at": { + "description": "Block timestamp (unix seconds) after which the invitation is no longer valid.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "family_id": { + "description": "The family that issued the invitation.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "node_id": { + "description": "The node being invited.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + "FamilyInvitationStatus": { + "description": "Terminal status for an invitation that has been moved out of the pending set.\n\nNote: timed-out invitations are not represented here — they are simply left in the pending set (see `FamilyInvitation::expires_at`).", + "oneOf": [ + { + "description": "Still awaiting a response. Recorded with a timestamp for completeness even though pending invitations live in a separate map.", + "type": "object", + "required": [ + "pending" + ], + "properties": { + "pending": { + "type": "object", + "required": [ + "at" + ], + "properties": { + "at": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "The invitee accepted and joined the family at the given timestamp.", + "type": "object", + "required": [ + "accepted" + ], + "properties": { + "accepted": { + "type": "object", + "required": [ + "at" + ], + "properties": { + "at": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "The invitee explicitly rejected the invitation at the given timestamp.", + "type": "object", + "required": [ + "rejected" + ], + "properties": { + "rejected": { + "type": "object", + "required": [ + "at" + ], + "properties": { + "at": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "The family revoked the invitation at the given timestamp before it could be accepted or rejected.", + "type": "object", + "required": [ + "revoked" + ], + "properties": { + "revoked": { + "type": "object", + "required": [ + "at" + ], + "properties": { + "at": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "PastFamilyInvitation": { + "description": "Historical record of an invitation that has reached a terminal state (`Accepted`, `Rejected`, or `Revoked`). Timed-out invitations are **not** archived here — they remain in the pending map until explicitly cleared.", + "type": "object", + "required": [ + "invitation", + "status" + ], + "properties": { + "invitation": { + "description": "The original invitation as it was issued.", + "allOf": [ + { + "$ref": "#/definitions/FamilyInvitation" + } + ] + }, + "status": { + "description": "What ultimately happened to it.", + "allOf": [ + { + "$ref": "#/definitions/FamilyInvitationStatus" + } + ] + } + }, + "additionalProperties": false + } + } +} diff --git a/contracts/node-families/schema/raw/response_to_get_all_pending_invitations_paged.json b/contracts/node-families/schema/raw/response_to_get_all_pending_invitations_paged.json new file mode 100644 index 0000000000..90bffdabc9 --- /dev/null +++ b/contracts/node-families/schema/raw/response_to_get_all_pending_invitations_paged.json @@ -0,0 +1,95 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PendingInvitationsPagedResponse", + "description": "Response to [`QueryMsg::GetAllPendingInvitationsPaged`](crate::QueryMsg::GetAllPendingInvitationsPaged).", + "type": "object", + "required": [ + "invitations" + ], + "properties": { + "invitations": { + "description": "The pending invitations on this page, in ascending `(family_id, node_id)` order, each stamped with whether it had already timed out at the time the query was served.", + "type": "array", + "items": { + "$ref": "#/definitions/PendingFamilyInvitationDetails" + } + }, + "start_next_after": { + "description": "Cursor (last `(family_id, node_id)` pair) to pass as `start_after` on the next call, or `None` if this page is empty (treat as end-of-list).", + "type": [ + "array", + "null" + ], + "items": [ + { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + ], + "maxItems": 2, + "minItems": 2 + } + }, + "additionalProperties": false, + "definitions": { + "FamilyInvitation": { + "description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, revoked, or until the chain advances past `expires_at` (in which case they remain in storage but are treated as inert — there is no background process clearing expired invitations).", + "type": "object", + "required": [ + "expires_at", + "family_id", + "node_id" + ], + "properties": { + "expires_at": { + "description": "Block timestamp (unix seconds) after which the invitation is no longer valid.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "family_id": { + "description": "The family that issued the invitation.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "node_id": { + "description": "The node being invited.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + "PendingFamilyInvitationDetails": { + "description": "A pending [`FamilyInvitation`] paired with whether it has already timed out at the time the query was served.", + "type": "object", + "required": [ + "expired", + "invitation" + ], + "properties": { + "expired": { + "description": "`true` iff `now >= invitation.expires_at` at query time, i.e. the invitation is still in the pending map but can no longer be acted on.", + "type": "boolean" + }, + "invitation": { + "description": "The stored invitation as it was issued.", + "allOf": [ + { + "$ref": "#/definitions/FamilyInvitation" + } + ] + } + }, + "additionalProperties": false + } + } +} diff --git a/contracts/node-families/schema/raw/response_to_get_families_paged.json b/contracts/node-families/schema/raw/response_to_get_families_paged.json new file mode 100644 index 0000000000..459247a149 --- /dev/null +++ b/contracts/node-families/schema/raw/response_to_get_families_paged.json @@ -0,0 +1,117 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "FamiliesPagedResponse", + "description": "Response to [`QueryMsg::GetFamiliesPaged`](crate::QueryMsg::GetFamiliesPaged).", + "type": "object", + "required": [ + "families" + ], + "properties": { + "families": { + "description": "The families on this page, in ascending [`NodeFamilyId`] order.", + "type": "array", + "items": { + "$ref": "#/definitions/NodeFamily" + } + }, + "start_next_after": { + "description": "Cursor to pass as `start_after` on the next call, or `None` if this page is empty (which the caller should treat as end-of-list).", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + } + }, + "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 + }, + "NodeFamily": { + "description": "On-chain representation of a node family.", + "type": "object", + "required": [ + "created_at", + "description", + "id", + "members", + "name", + "normalised_name", + "owner", + "paid_fee" + ], + "properties": { + "created_at": { + "description": "Timestamp of the creation of the node family", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "description": { + "description": "The optional description of the node family", + "type": "string" + }, + "id": { + "description": "The id of the node family", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "members": { + "description": "Memoized value of the current number of members in the node family Used to detect if the family is empty", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "name": { + "description": "The name of the node family", + "type": "string" + }, + "normalised_name": { + "description": "Normalised name of the node family used for uniqueness checks", + "type": "string" + }, + "owner": { + "description": "The owner of the node family", + "allOf": [ + { + "$ref": "#/definitions/Addr" + } + ] + }, + "paid_fee": { + "description": "Records the fee paid when the family was created, so that the appropriate amount could be returned upon it getting disbanded.", + "allOf": [ + { + "$ref": "#/definitions/Coin" + } + ] + } + }, + "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/node-families/schema/raw/response_to_get_family_by_id.json b/contracts/node-families/schema/raw/response_to_get_family_by_id.json new file mode 100644 index 0000000000..072a160145 --- /dev/null +++ b/contracts/node-families/schema/raw/response_to_get_family_by_id.json @@ -0,0 +1,118 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "NodeFamilyResponse", + "description": "Response to [`QueryMsg::GetFamilyById`](crate::QueryMsg::GetFamilyById).", + "type": "object", + "required": [ + "family_id" + ], + "properties": { + "family": { + "description": "The matching family, or `None` if no family with `family_id` exists.", + "anyOf": [ + { + "$ref": "#/definitions/NodeFamily" + }, + { + "type": "null" + } + ] + }, + "family_id": { + "description": "The id that was queried, echoed back so paginated callers can correlate.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "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 + }, + "NodeFamily": { + "description": "On-chain representation of a node family.", + "type": "object", + "required": [ + "created_at", + "description", + "id", + "members", + "name", + "normalised_name", + "owner", + "paid_fee" + ], + "properties": { + "created_at": { + "description": "Timestamp of the creation of the node family", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "description": { + "description": "The optional description of the node family", + "type": "string" + }, + "id": { + "description": "The id of the node family", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "members": { + "description": "Memoized value of the current number of members in the node family Used to detect if the family is empty", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "name": { + "description": "The name of the node family", + "type": "string" + }, + "normalised_name": { + "description": "Normalised name of the node family used for uniqueness checks", + "type": "string" + }, + "owner": { + "description": "The owner of the node family", + "allOf": [ + { + "$ref": "#/definitions/Addr" + } + ] + }, + "paid_fee": { + "description": "Records the fee paid when the family was created, so that the appropriate amount could be returned upon it getting disbanded.", + "allOf": [ + { + "$ref": "#/definitions/Coin" + } + ] + } + }, + "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/node-families/schema/raw/response_to_get_family_by_name.json b/contracts/node-families/schema/raw/response_to_get_family_by_name.json new file mode 100644 index 0000000000..dec7aea652 --- /dev/null +++ b/contracts/node-families/schema/raw/response_to_get_family_by_name.json @@ -0,0 +1,116 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "NodeFamilyByNameResponse", + "description": "Response to [`QueryMsg::GetFamilyByName`](crate::QueryMsg::GetFamilyByName).", + "type": "object", + "required": [ + "name" + ], + "properties": { + "family": { + "description": "The matching family, or `None` if no family with that name exists.", + "anyOf": [ + { + "$ref": "#/definitions/NodeFamily" + }, + { + "type": "null" + } + ] + }, + "name": { + "description": "The name that was queried, echoed back so callers can correlate.", + "type": "string" + } + }, + "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 + }, + "NodeFamily": { + "description": "On-chain representation of a node family.", + "type": "object", + "required": [ + "created_at", + "description", + "id", + "members", + "name", + "normalised_name", + "owner", + "paid_fee" + ], + "properties": { + "created_at": { + "description": "Timestamp of the creation of the node family", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "description": { + "description": "The optional description of the node family", + "type": "string" + }, + "id": { + "description": "The id of the node family", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "members": { + "description": "Memoized value of the current number of members in the node family Used to detect if the family is empty", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "name": { + "description": "The name of the node family", + "type": "string" + }, + "normalised_name": { + "description": "Normalised name of the node family used for uniqueness checks", + "type": "string" + }, + "owner": { + "description": "The owner of the node family", + "allOf": [ + { + "$ref": "#/definitions/Addr" + } + ] + }, + "paid_fee": { + "description": "Records the fee paid when the family was created, so that the appropriate amount could be returned upon it getting disbanded.", + "allOf": [ + { + "$ref": "#/definitions/Coin" + } + ] + } + }, + "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/node-families/schema/raw/response_to_get_family_by_owner.json b/contracts/node-families/schema/raw/response_to_get_family_by_owner.json new file mode 100644 index 0000000000..74b0412a9d --- /dev/null +++ b/contracts/node-families/schema/raw/response_to_get_family_by_owner.json @@ -0,0 +1,120 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "NodeFamilyByOwnerResponse", + "description": "Response to [`QueryMsg::GetFamilyByOwner`](crate::QueryMsg::GetFamilyByOwner).", + "type": "object", + "required": [ + "owner" + ], + "properties": { + "family": { + "description": "The matching family, or `None` if `owner` does not currently own one.", + "anyOf": [ + { + "$ref": "#/definitions/NodeFamily" + }, + { + "type": "null" + } + ] + }, + "owner": { + "description": "The (validated) owner address that was queried, echoed back so callers can correlate.", + "allOf": [ + { + "$ref": "#/definitions/Addr" + } + ] + } + }, + "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 + }, + "NodeFamily": { + "description": "On-chain representation of a node family.", + "type": "object", + "required": [ + "created_at", + "description", + "id", + "members", + "name", + "normalised_name", + "owner", + "paid_fee" + ], + "properties": { + "created_at": { + "description": "Timestamp of the creation of the node family", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "description": { + "description": "The optional description of the node family", + "type": "string" + }, + "id": { + "description": "The id of the node family", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "members": { + "description": "Memoized value of the current number of members in the node family Used to detect if the family is empty", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "name": { + "description": "The name of the node family", + "type": "string" + }, + "normalised_name": { + "description": "Normalised name of the node family used for uniqueness checks", + "type": "string" + }, + "owner": { + "description": "The owner of the node family", + "allOf": [ + { + "$ref": "#/definitions/Addr" + } + ] + }, + "paid_fee": { + "description": "Records the fee paid when the family was created, so that the appropriate amount could be returned upon it getting disbanded.", + "allOf": [ + { + "$ref": "#/definitions/Coin" + } + ] + } + }, + "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/node-families/schema/raw/response_to_get_family_members_paged.json b/contracts/node-families/schema/raw/response_to_get_family_members_paged.json new file mode 100644 index 0000000000..7dbb1c64a4 --- /dev/null +++ b/contracts/node-families/schema/raw/response_to_get_family_members_paged.json @@ -0,0 +1,85 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "FamilyMembersPagedResponse", + "description": "Response to [`QueryMsg::GetFamilyMembersPaged`](crate::QueryMsg::GetFamilyMembersPaged).", + "type": "object", + "required": [ + "family_id", + "members" + ], + "properties": { + "family_id": { + "description": "The family whose members were queried, echoed back so paginated callers can correlate.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "members": { + "description": "The members on this page, in ascending [`NodeId`] order.", + "type": "array", + "items": { + "$ref": "#/definitions/FamilyMemberRecord" + } + }, + "start_next_after": { + "description": "Cursor to pass as `start_after` on the next call, or `None` if this page is empty (which the caller should treat as end-of-list).", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false, + "definitions": { + "FamilyMemberRecord": { + "description": "One entry in a [`FamilyMembersPagedResponse`] page — pairs a node id with its [`FamilyMembership`] record (notably its `joined_at` timestamp).", + "type": "object", + "required": [ + "membership", + "node_id" + ], + "properties": { + "membership": { + "description": "The membership record (carries `family_id` and `joined_at`).", + "allOf": [ + { + "$ref": "#/definitions/FamilyMembership" + } + ] + }, + "node_id": { + "description": "The node currently in the family.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + "FamilyMembership": { + "description": "On-chain record of a node's current family membership.\n\nA node belongs to at most one family at a time, so this is keyed by `NodeId` alone — `family_id` is carried in the value to support reverse lookups (all nodes in a given family) via a secondary index.", + "type": "object", + "required": [ + "family_id", + "joined_at" + ], + "properties": { + "family_id": { + "description": "The family the node is currently a member of.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "joined_at": { + "description": "Block timestamp (unix seconds) at which the node accepted its invitation and joined the family.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + } +} diff --git a/contracts/node-families/schema/raw/response_to_get_family_membership.json b/contracts/node-families/schema/raw/response_to_get_family_membership.json new file mode 100644 index 0000000000..838d916547 --- /dev/null +++ b/contracts/node-families/schema/raw/response_to_get_family_membership.json @@ -0,0 +1,27 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "NodeFamilyMembershipResponse", + "description": "Response to [`QueryMsg::GetFamilyMembership`](crate::QueryMsg::GetFamilyMembership).", + "type": "object", + "required": [ + "node_id" + ], + "properties": { + "family_id": { + "description": "The id of the family the node currently belongs to, or `None` if the node is not currently a member of any family.", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "node_id": { + "description": "The node that was queried.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false +} diff --git a/contracts/node-families/schema/raw/response_to_get_past_invitations_for_family_paged.json b/contracts/node-families/schema/raw/response_to_get_past_invitations_for_family_paged.json new file mode 100644 index 0000000000..15e2271e61 --- /dev/null +++ b/contracts/node-families/schema/raw/response_to_get_past_invitations_for_family_paged.json @@ -0,0 +1,207 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PastFamilyInvitationsPagedResponse", + "description": "Response to [`QueryMsg::GetPastInvitationsForFamilyPaged`](crate::QueryMsg::GetPastInvitationsForFamilyPaged).", + "type": "object", + "required": [ + "family_id", + "invitations" + ], + "properties": { + "family_id": { + "description": "The family whose archived invitations were queried, echoed back so paginated callers can correlate.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "invitations": { + "description": "The archived invitations on this page, in ascending `(node_id, counter)` order across all terminal statuses.", + "type": "array", + "items": { + "$ref": "#/definitions/PastFamilyInvitation" + } + }, + "start_next_after": { + "description": "Cursor to pass as `start_after` on the next call, or `None` if this page is empty (treat as end-of-list).", + "type": [ + "array", + "null" + ], + "items": [ + { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + ], + "maxItems": 2, + "minItems": 2 + } + }, + "additionalProperties": false, + "definitions": { + "FamilyInvitation": { + "description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, revoked, or until the chain advances past `expires_at` (in which case they remain in storage but are treated as inert — there is no background process clearing expired invitations).", + "type": "object", + "required": [ + "expires_at", + "family_id", + "node_id" + ], + "properties": { + "expires_at": { + "description": "Block timestamp (unix seconds) after which the invitation is no longer valid.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "family_id": { + "description": "The family that issued the invitation.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "node_id": { + "description": "The node being invited.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + "FamilyInvitationStatus": { + "description": "Terminal status for an invitation that has been moved out of the pending set.\n\nNote: timed-out invitations are not represented here — they are simply left in the pending set (see `FamilyInvitation::expires_at`).", + "oneOf": [ + { + "description": "Still awaiting a response. Recorded with a timestamp for completeness even though pending invitations live in a separate map.", + "type": "object", + "required": [ + "pending" + ], + "properties": { + "pending": { + "type": "object", + "required": [ + "at" + ], + "properties": { + "at": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "The invitee accepted and joined the family at the given timestamp.", + "type": "object", + "required": [ + "accepted" + ], + "properties": { + "accepted": { + "type": "object", + "required": [ + "at" + ], + "properties": { + "at": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "The invitee explicitly rejected the invitation at the given timestamp.", + "type": "object", + "required": [ + "rejected" + ], + "properties": { + "rejected": { + "type": "object", + "required": [ + "at" + ], + "properties": { + "at": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "The family revoked the invitation at the given timestamp before it could be accepted or rejected.", + "type": "object", + "required": [ + "revoked" + ], + "properties": { + "revoked": { + "type": "object", + "required": [ + "at" + ], + "properties": { + "at": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "PastFamilyInvitation": { + "description": "Historical record of an invitation that has reached a terminal state (`Accepted`, `Rejected`, or `Revoked`). Timed-out invitations are **not** archived here — they remain in the pending map until explicitly cleared.", + "type": "object", + "required": [ + "invitation", + "status" + ], + "properties": { + "invitation": { + "description": "The original invitation as it was issued.", + "allOf": [ + { + "$ref": "#/definitions/FamilyInvitation" + } + ] + }, + "status": { + "description": "What ultimately happened to it.", + "allOf": [ + { + "$ref": "#/definitions/FamilyInvitationStatus" + } + ] + } + }, + "additionalProperties": false + } + } +} diff --git a/contracts/node-families/schema/raw/response_to_get_past_invitations_for_node_paged.json b/contracts/node-families/schema/raw/response_to_get_past_invitations_for_node_paged.json new file mode 100644 index 0000000000..b438371287 --- /dev/null +++ b/contracts/node-families/schema/raw/response_to_get_past_invitations_for_node_paged.json @@ -0,0 +1,207 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PastFamilyInvitationsForNodePagedResponse", + "description": "Response to [`QueryMsg::GetPastInvitationsForNodePaged`](crate::QueryMsg::GetPastInvitationsForNodePaged).", + "type": "object", + "required": [ + "invitations", + "node_id" + ], + "properties": { + "invitations": { + "description": "The archived invitations addressed to this node on this page, in ascending `(family_id, counter)` order across all terminal statuses.", + "type": "array", + "items": { + "$ref": "#/definitions/PastFamilyInvitation" + } + }, + "node_id": { + "description": "The node whose past invitations were queried, echoed back so paginated callers can correlate.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "start_next_after": { + "description": "Cursor to pass as `start_after` on the next call, or `None` if this page is empty (treat as end-of-list).", + "type": [ + "array", + "null" + ], + "items": [ + { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + ], + "maxItems": 2, + "minItems": 2 + } + }, + "additionalProperties": false, + "definitions": { + "FamilyInvitation": { + "description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, revoked, or until the chain advances past `expires_at` (in which case they remain in storage but are treated as inert — there is no background process clearing expired invitations).", + "type": "object", + "required": [ + "expires_at", + "family_id", + "node_id" + ], + "properties": { + "expires_at": { + "description": "Block timestamp (unix seconds) after which the invitation is no longer valid.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "family_id": { + "description": "The family that issued the invitation.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "node_id": { + "description": "The node being invited.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + "FamilyInvitationStatus": { + "description": "Terminal status for an invitation that has been moved out of the pending set.\n\nNote: timed-out invitations are not represented here — they are simply left in the pending set (see `FamilyInvitation::expires_at`).", + "oneOf": [ + { + "description": "Still awaiting a response. Recorded with a timestamp for completeness even though pending invitations live in a separate map.", + "type": "object", + "required": [ + "pending" + ], + "properties": { + "pending": { + "type": "object", + "required": [ + "at" + ], + "properties": { + "at": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "The invitee accepted and joined the family at the given timestamp.", + "type": "object", + "required": [ + "accepted" + ], + "properties": { + "accepted": { + "type": "object", + "required": [ + "at" + ], + "properties": { + "at": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "The invitee explicitly rejected the invitation at the given timestamp.", + "type": "object", + "required": [ + "rejected" + ], + "properties": { + "rejected": { + "type": "object", + "required": [ + "at" + ], + "properties": { + "at": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "The family revoked the invitation at the given timestamp before it could be accepted or rejected.", + "type": "object", + "required": [ + "revoked" + ], + "properties": { + "revoked": { + "type": "object", + "required": [ + "at" + ], + "properties": { + "at": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "PastFamilyInvitation": { + "description": "Historical record of an invitation that has reached a terminal state (`Accepted`, `Rejected`, or `Revoked`). Timed-out invitations are **not** archived here — they remain in the pending map until explicitly cleared.", + "type": "object", + "required": [ + "invitation", + "status" + ], + "properties": { + "invitation": { + "description": "The original invitation as it was issued.", + "allOf": [ + { + "$ref": "#/definitions/FamilyInvitation" + } + ] + }, + "status": { + "description": "What ultimately happened to it.", + "allOf": [ + { + "$ref": "#/definitions/FamilyInvitationStatus" + } + ] + } + }, + "additionalProperties": false + } + } +} diff --git a/contracts/node-families/schema/raw/response_to_get_past_members_for_family_paged.json b/contracts/node-families/schema/raw/response_to_get_past_members_for_family_paged.json new file mode 100644 index 0000000000..2ad75d8dda --- /dev/null +++ b/contracts/node-families/schema/raw/response_to_get_past_members_for_family_paged.json @@ -0,0 +1,79 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PastFamilyMembersPagedResponse", + "description": "Response to [`QueryMsg::GetPastMembersForFamilyPaged`](crate::QueryMsg::GetPastMembersForFamilyPaged).", + "type": "object", + "required": [ + "family_id", + "members" + ], + "properties": { + "family_id": { + "description": "The family whose archived memberships were queried, echoed back so paginated callers can correlate.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "members": { + "description": "The archived membership records on this page, in ascending `(node_id, counter)` order.", + "type": "array", + "items": { + "$ref": "#/definitions/PastFamilyMember" + } + }, + "start_next_after": { + "description": "Cursor to pass as `start_after` on the next call, or `None` if this page is empty (treat as end-of-list).", + "type": [ + "array", + "null" + ], + "items": [ + { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + ], + "maxItems": 2, + "minItems": 2 + } + }, + "additionalProperties": false, + "definitions": { + "PastFamilyMember": { + "description": "Historical record of a node that used to be part of a family but has since been removed (kicked, left voluntarily, or because the family was disbanded).", + "type": "object", + "required": [ + "family_id", + "node_id", + "removed_at" + ], + "properties": { + "family_id": { + "description": "The family the node used to belong to.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "node_id": { + "description": "The node that was removed.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "removed_at": { + "description": "Block timestamp (unix seconds) at which the membership was terminated.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + } +} diff --git a/contracts/node-families/schema/raw/response_to_get_past_members_for_node_paged.json b/contracts/node-families/schema/raw/response_to_get_past_members_for_node_paged.json new file mode 100644 index 0000000000..379b9df988 --- /dev/null +++ b/contracts/node-families/schema/raw/response_to_get_past_members_for_node_paged.json @@ -0,0 +1,79 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PastFamilyMembersForNodePagedResponse", + "description": "Response to [`QueryMsg::GetPastMembersForNodePaged`](crate::QueryMsg::GetPastMembersForNodePaged).", + "type": "object", + "required": [ + "members", + "node_id" + ], + "properties": { + "members": { + "description": "The archived membership records for this node on this page, in ascending `(family_id, counter)` order.", + "type": "array", + "items": { + "$ref": "#/definitions/PastFamilyMember" + } + }, + "node_id": { + "description": "The node whose archived memberships were queried, echoed back so paginated callers can correlate.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "start_next_after": { + "description": "Cursor to pass as `start_after` on the next call, or `None` if this page is empty (treat as end-of-list).", + "type": [ + "array", + "null" + ], + "items": [ + { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + ], + "maxItems": 2, + "minItems": 2 + } + }, + "additionalProperties": false, + "definitions": { + "PastFamilyMember": { + "description": "Historical record of a node that used to be part of a family but has since been removed (kicked, left voluntarily, or because the family was disbanded).", + "type": "object", + "required": [ + "family_id", + "node_id", + "removed_at" + ], + "properties": { + "family_id": { + "description": "The family the node used to belong to.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "node_id": { + "description": "The node that was removed.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "removed_at": { + "description": "Block timestamp (unix seconds) at which the membership was terminated.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + } +} diff --git a/contracts/node-families/schema/raw/response_to_get_pending_invitation.json b/contracts/node-families/schema/raw/response_to_get_pending_invitation.json new file mode 100644 index 0000000000..c9ab63d67a --- /dev/null +++ b/contracts/node-families/schema/raw/response_to_get_pending_invitation.json @@ -0,0 +1,91 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PendingFamilyInvitationResponse", + "description": "Response to [`QueryMsg::GetPendingInvitation`](crate::QueryMsg::GetPendingInvitation).", + "type": "object", + "required": [ + "family_id", + "node_id" + ], + "properties": { + "family_id": { + "description": "The family component of the queried `(family_id, node_id)` key.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "invitation": { + "description": "The matching pending invitation along with an explicit expiry flag, or `None` if no such invitation exists.", + "anyOf": [ + { + "$ref": "#/definitions/PendingFamilyInvitationDetails" + }, + { + "type": "null" + } + ] + }, + "node_id": { + "description": "The node component of the queried `(family_id, node_id)` key.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false, + "definitions": { + "FamilyInvitation": { + "description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, revoked, or until the chain advances past `expires_at` (in which case they remain in storage but are treated as inert — there is no background process clearing expired invitations).", + "type": "object", + "required": [ + "expires_at", + "family_id", + "node_id" + ], + "properties": { + "expires_at": { + "description": "Block timestamp (unix seconds) after which the invitation is no longer valid.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "family_id": { + "description": "The family that issued the invitation.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "node_id": { + "description": "The node being invited.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + "PendingFamilyInvitationDetails": { + "description": "A pending [`FamilyInvitation`] paired with whether it has already timed out at the time the query was served.", + "type": "object", + "required": [ + "expired", + "invitation" + ], + "properties": { + "expired": { + "description": "`true` iff `now >= invitation.expires_at` at query time, i.e. the invitation is still in the pending map but can no longer be acted on.", + "type": "boolean" + }, + "invitation": { + "description": "The stored invitation as it was issued.", + "allOf": [ + { + "$ref": "#/definitions/FamilyInvitation" + } + ] + } + }, + "additionalProperties": false + } + } +} diff --git a/contracts/node-families/schema/raw/response_to_get_pending_invitations_for_family_paged.json b/contracts/node-families/schema/raw/response_to_get_pending_invitations_for_family_paged.json new file mode 100644 index 0000000000..9dc1b084d0 --- /dev/null +++ b/contracts/node-families/schema/raw/response_to_get_pending_invitations_for_family_paged.json @@ -0,0 +1,90 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PendingFamilyInvitationsPagedResponse", + "description": "Response to [`QueryMsg::GetPendingInvitationsForFamilyPaged`](crate::QueryMsg::GetPendingInvitationsForFamilyPaged).", + "type": "object", + "required": [ + "family_id", + "invitations" + ], + "properties": { + "family_id": { + "description": "The family whose pending invitations were queried, echoed back so paginated callers can correlate.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "invitations": { + "description": "The pending invitations on this page, in ascending invitee [`NodeId`] order, each stamped with whether it had already timed out at the time the query was served.", + "type": "array", + "items": { + "$ref": "#/definitions/PendingFamilyInvitationDetails" + } + }, + "start_next_after": { + "description": "Cursor (last invitee node id) to pass as `start_after` on the next call, or `None` if this page is empty (treat as end-of-list).", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false, + "definitions": { + "FamilyInvitation": { + "description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, revoked, or until the chain advances past `expires_at` (in which case they remain in storage but are treated as inert — there is no background process clearing expired invitations).", + "type": "object", + "required": [ + "expires_at", + "family_id", + "node_id" + ], + "properties": { + "expires_at": { + "description": "Block timestamp (unix seconds) after which the invitation is no longer valid.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "family_id": { + "description": "The family that issued the invitation.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "node_id": { + "description": "The node being invited.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + "PendingFamilyInvitationDetails": { + "description": "A pending [`FamilyInvitation`] paired with whether it has already timed out at the time the query was served.", + "type": "object", + "required": [ + "expired", + "invitation" + ], + "properties": { + "expired": { + "description": "`true` iff `now >= invitation.expires_at` at query time, i.e. the invitation is still in the pending map but can no longer be acted on.", + "type": "boolean" + }, + "invitation": { + "description": "The stored invitation as it was issued.", + "allOf": [ + { + "$ref": "#/definitions/FamilyInvitation" + } + ] + } + }, + "additionalProperties": false + } + } +} diff --git a/contracts/node-families/schema/raw/response_to_get_pending_invitations_for_node_paged.json b/contracts/node-families/schema/raw/response_to_get_pending_invitations_for_node_paged.json new file mode 100644 index 0000000000..6d59be893b --- /dev/null +++ b/contracts/node-families/schema/raw/response_to_get_pending_invitations_for_node_paged.json @@ -0,0 +1,90 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PendingInvitationsForNodePagedResponse", + "description": "Response to [`QueryMsg::GetPendingInvitationsForNodePaged`](crate::QueryMsg::GetPendingInvitationsForNodePaged).", + "type": "object", + "required": [ + "invitations", + "node_id" + ], + "properties": { + "invitations": { + "description": "The pending invitations addressed to this node on this page, in ascending [`NodeFamilyId`] order, each stamped with whether it had already timed out at the time the query was served.", + "type": "array", + "items": { + "$ref": "#/definitions/PendingFamilyInvitationDetails" + } + }, + "node_id": { + "description": "The node whose pending invitations were queried, echoed back so paginated callers can correlate.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "start_next_after": { + "description": "Cursor (last issuing family id) to pass as `start_after` on the next call, or `None` if this page is empty (treat as end-of-list).", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false, + "definitions": { + "FamilyInvitation": { + "description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, revoked, or until the chain advances past `expires_at` (in which case they remain in storage but are treated as inert — there is no background process clearing expired invitations).", + "type": "object", + "required": [ + "expires_at", + "family_id", + "node_id" + ], + "properties": { + "expires_at": { + "description": "Block timestamp (unix seconds) after which the invitation is no longer valid.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "family_id": { + "description": "The family that issued the invitation.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "node_id": { + "description": "The node being invited.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + "PendingFamilyInvitationDetails": { + "description": "A pending [`FamilyInvitation`] paired with whether it has already timed out at the time the query was served.", + "type": "object", + "required": [ + "expired", + "invitation" + ], + "properties": { + "expired": { + "description": "`true` iff `now >= invitation.expires_at` at query time, i.e. the invitation is still in the pending map but can no longer be acted on.", + "type": "boolean" + }, + "invitation": { + "description": "The stored invitation as it was issued.", + "allOf": [ + { + "$ref": "#/definitions/FamilyInvitation" + } + ] + } + }, + "additionalProperties": false + } + } +} diff --git a/contracts/node-families/src/bin/schema.rs b/contracts/node-families/src/bin/schema.rs new file mode 100644 index 0000000000..9be22a0984 --- /dev/null +++ b/contracts/node-families/src/bin/schema.rs @@ -0,0 +1,14 @@ +// Copyright 2024 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use cosmwasm_schema::write_api; +use nym_node_families_contract_common::{ExecuteMsg, InstantiateMsg, MigrateMsg, QueryMsg}; + +fn main() { + write_api! { + instantiate: InstantiateMsg, + query: QueryMsg, + execute: ExecuteMsg, + migrate: MigrateMsg, + } +} diff --git a/contracts/node-families/src/contract.rs b/contracts/node-families/src/contract.rs new file mode 100644 index 0000000000..64d37f6df8 --- /dev/null +++ b/contracts/node-families/src/contract.rs @@ -0,0 +1,355 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +//! CosmWasm entry points for the node families contract. + +use crate::queries::{ + query_all_family_members_paged, query_all_past_invitations_paged, + query_all_pending_invitations_paged, query_families_paged, query_family_by_id, + query_family_by_name, query_family_by_owner, query_family_members_paged, + query_family_membership, query_past_invitations_for_family_paged, + query_past_invitations_for_node_paged, query_past_members_for_family_paged, + query_past_members_for_node_paged, query_pending_invitation, + query_pending_invitations_for_family_paged, query_pending_invitations_for_node_paged, +}; +use crate::storage::NodeFamiliesStorage; +use crate::transactions::{ + try_accept_family_invitation, try_create_family, try_disband_family, try_handle_node_unbonding, + try_invite_to_family, try_kick_from_family, try_leave_family, try_reject_family_invitation, + try_revoke_family_invitation, try_update_config, +}; +use cosmwasm_std::{ + entry_point, to_json_binary, Binary, Deps, DepsMut, Env, MessageInfo, Response, +}; +use nym_contracts_common::set_build_information; +use nym_node_families_contract_common::{ + ExecuteMsg, InstantiateMsg, MigrateMsg, NodeFamiliesContractError, QueryMsg, +}; + +const CONTRACT_NAME: &str = "crate:nym-node-families-contract"; + +/// Contract semver, taken from `Cargo.toml` at build time. Bumped on every +/// release; recorded in cw2 storage so migrations can detect the source version. +const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// One-time initialisation of contract storage on code instantiation. +#[entry_point] +pub fn instantiate( + deps: DepsMut, + _env: Env, + info: MessageInfo, + msg: InstantiateMsg, +) -> Result { + cw2::set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + set_build_information!(deps.storage)?; + + let mixnet_contract_address = deps.api.addr_validate(&msg.mixnet_contract_address)?; + + NodeFamiliesStorage::new().initialise( + deps, + info.sender, + mixnet_contract_address, + msg.config, + )?; + + Ok(Response::default()) +} + +/// State-mutating dispatcher. Concrete handlers live in [`crate::transactions`] +/// and are wired up here as variants are added to [`ExecuteMsg`]. +#[entry_point] +pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + match msg { + ExecuteMsg::UpdateConfig { config } => try_update_config(deps, env, info, config), + ExecuteMsg::CreateFamily { name, description } => { + try_create_family(deps, env, info, name, description) + } + ExecuteMsg::DisbandFamily {} => try_disband_family(deps, env, info), + ExecuteMsg::InviteToFamily { + node_id, + validity_secs, + } => try_invite_to_family(deps, env, info, node_id, validity_secs), + ExecuteMsg::RevokeFamilyInvitation { node_id } => { + try_revoke_family_invitation(deps, env, info, node_id) + } + ExecuteMsg::AcceptFamilyInvitation { family_id, node_id } => { + try_accept_family_invitation(deps, env, info, family_id, node_id) + } + ExecuteMsg::RejectFamilyInvitation { family_id, node_id } => { + try_reject_family_invitation(deps, env, info, family_id, node_id) + } + ExecuteMsg::LeaveFamily { node_id } => try_leave_family(deps, env, info, node_id), + ExecuteMsg::KickFromFamily { node_id } => try_kick_from_family(deps, env, info, node_id), + ExecuteMsg::OnNymNodeUnbond { node_id } => { + try_handle_node_unbonding(deps, env, info, node_id) + } + } +} + +/// Read-only dispatcher. Concrete handlers live in [`crate::queries`] and are +/// wired up here as variants are added to [`QueryMsg`]. +#[entry_point] +pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> Result { + match msg { + QueryMsg::GetFamilyById { family_id } => { + Ok(to_json_binary(&query_family_by_id(deps, family_id)?)?) + } + QueryMsg::GetFamilyByOwner { owner } => { + Ok(to_json_binary(&query_family_by_owner(deps, owner)?)?) + } + QueryMsg::GetFamilyByName { name } => { + Ok(to_json_binary(&query_family_by_name(deps, name)?)?) + } + QueryMsg::GetFamilyMembership { node_id } => { + Ok(to_json_binary(&query_family_membership(deps, node_id)?)?) + } + QueryMsg::GetFamilyMembersPaged { + family_id, + start_after, + limit, + } => Ok(to_json_binary(&query_family_members_paged( + deps, + family_id, + start_after, + limit, + )?)?), + QueryMsg::GetAllFamilyMembersPaged { start_after, limit } => Ok(to_json_binary( + &query_all_family_members_paged(deps, start_after, limit)?, + )?), + QueryMsg::GetPendingInvitation { family_id, node_id } => Ok(to_json_binary( + &query_pending_invitation(deps, env, family_id, node_id)?, + )?), + QueryMsg::GetPendingInvitationsForFamilyPaged { + family_id, + start_after, + limit, + } => Ok(to_json_binary( + &query_pending_invitations_for_family_paged(deps, env, family_id, start_after, limit)?, + )?), + QueryMsg::GetPendingInvitationsForNodePaged { + node_id, + start_after, + limit, + } => Ok(to_json_binary(&query_pending_invitations_for_node_paged( + deps, + env, + node_id, + start_after, + limit, + )?)?), + QueryMsg::GetAllPendingInvitationsPaged { start_after, limit } => Ok(to_json_binary( + &query_all_pending_invitations_paged(deps, env, start_after, limit)?, + )?), + QueryMsg::GetPastInvitationsForFamilyPaged { + family_id, + start_after, + limit, + } => Ok(to_json_binary(&query_past_invitations_for_family_paged( + deps, + family_id, + start_after, + limit, + )?)?), + QueryMsg::GetPastInvitationsForNodePaged { + node_id, + start_after, + limit, + } => Ok(to_json_binary(&query_past_invitations_for_node_paged( + deps, + node_id, + start_after, + limit, + )?)?), + QueryMsg::GetAllPastInvitationsPaged { start_after, limit } => Ok(to_json_binary( + &query_all_past_invitations_paged(deps, start_after, limit)?, + )?), + QueryMsg::GetPastMembersForFamilyPaged { + family_id, + start_after, + limit, + } => Ok(to_json_binary(&query_past_members_for_family_paged( + deps, + family_id, + start_after, + limit, + )?)?), + QueryMsg::GetPastMembersForNodePaged { + node_id, + start_after, + limit, + } => Ok(to_json_binary(&query_past_members_for_node_paged( + deps, + node_id, + start_after, + limit, + )?)?), + QueryMsg::GetFamiliesPaged { start_after, limit } => Ok(to_json_binary( + &query_families_paged(deps, start_after, limit)?, + )?), + } +} + +/// Migration entry point. +/// +/// Refreshes recorded build information and ensures the existing on-chain +/// contract version is at most the current `CONTRACT_VERSION` (i.e. forbids +/// downgrades). Any data migrations are dispatched via +/// [`crate::queued_migrations`]. +#[entry_point] +pub fn migrate( + deps: DepsMut, + _env: Env, + _msg: MigrateMsg, +) -> Result { + set_build_information!(deps.storage)?; + cw2::ensure_from_older_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + + Ok(Default::default()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(test)] + mod contract_instantiation { + use super::*; + use cosmwasm_std::coin; + use cosmwasm_std::testing::{message_info, mock_dependencies, mock_env}; + use nym_node_families_contract_common::Config; + + fn mock_config() -> Config { + Config { + create_family_fee: coin(123, "unym"), + family_name_length_limit: 20, + family_description_length_limit: 100, + default_invitation_validity_secs: 24 * 60 * 60, + } + } + + #[test] + fn sets_contract_admin_to_the_message_sender() -> anyhow::Result<()> { + let mut deps = mock_dependencies(); + let env = mock_env(); + let mixnet_contract_address = deps.api.addr_make("mixnet-contract"); + let some_sender = deps.api.addr_make("some_sender"); + + instantiate( + deps.as_mut(), + env, + message_info(&some_sender, &[]), + InstantiateMsg { + config: mock_config(), + mixnet_contract_address: mixnet_contract_address.to_string(), + }, + )?; + + let deps = deps.as_ref(); + + NodeFamiliesStorage::new() + .contract_admin + .assert_admin(deps, &some_sender)?; + + Ok(()) + } + + #[test] + fn persists_the_provided_config() -> anyhow::Result<()> { + let mut deps = mock_dependencies(); + let env = mock_env(); + let mixnet_contract_address = deps.api.addr_make("mixnet-contract"); + let sender = deps.api.addr_make("some_sender"); + let config = mock_config(); + + instantiate( + deps.as_mut(), + env, + message_info(&sender, &[]), + InstantiateMsg { + config: config.clone(), + mixnet_contract_address: mixnet_contract_address.to_string(), + }, + )?; + + let stored = NodeFamiliesStorage::new() + .config + .load(deps.as_ref().storage)?; + assert_eq!(stored, config); + + Ok(()) + } + + #[test] + fn persists_the_validated_mixnet_contract_address() -> anyhow::Result<()> { + let mut deps = mock_dependencies(); + let env = mock_env(); + let mixnet_contract_address = deps.api.addr_make("mixnet-contract"); + let sender = deps.api.addr_make("some_sender"); + + instantiate( + deps.as_mut(), + env, + message_info(&sender, &[]), + InstantiateMsg { + config: mock_config(), + mixnet_contract_address: mixnet_contract_address.to_string(), + }, + )?; + + let stored = NodeFamiliesStorage::new() + .mixnet_contract_address + .load(deps.as_ref().storage)?; + assert_eq!(stored, mixnet_contract_address); + + Ok(()) + } + + #[test] + fn errors_on_invalid_mixnet_contract_address() { + let mut deps = mock_dependencies(); + let env = mock_env(); + let sender = deps.api.addr_make("some_sender"); + + let res = instantiate( + deps.as_mut(), + env, + message_info(&sender, &[]), + InstantiateMsg { + config: mock_config(), + mixnet_contract_address: "not-a-valid-bech32-address".to_string(), + }, + ); + + assert!(res.is_err()); + } + + #[test] + fn records_the_cw2_contract_version() -> anyhow::Result<()> { + let mut deps = mock_dependencies(); + let env = mock_env(); + let mixnet_contract_address = deps.api.addr_make("mixnet-contract"); + let sender = deps.api.addr_make("some_sender"); + + instantiate( + deps.as_mut(), + env, + message_info(&sender, &[]), + InstantiateMsg { + config: mock_config(), + mixnet_contract_address: mixnet_contract_address.to_string(), + }, + )?; + + let version = cw2::get_contract_version(deps.as_ref().storage)?; + assert_eq!(version.contract, CONTRACT_NAME); + assert_eq!(version.version, CONTRACT_VERSION); + + Ok(()) + } + } +} diff --git a/contracts/node-families/src/helpers.rs b/contracts/node-families/src/helpers.rs new file mode 100644 index 0000000000..03ba312779 --- /dev/null +++ b/contracts/node-families/src/helpers.rs @@ -0,0 +1,178 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use crate::storage::NodeFamiliesStorage; +use cosmwasm_std::{Addr, Deps}; +use nym_mixnet_contract_common::{MixnetContractQuerier, NodeId}; +use nym_node_families_contract_common::NodeFamiliesContractError; + +/// Normalise a family name into the canonical form used as the unique-index key. +/// +/// Drops every character that isn't an ASCII letter or digit and lowercases +/// the rest, so `" Foo-Bar! "`, `"foobar"` and `"FOO BAR"` all collide on +/// the storage layer's unique-name index. +pub fn normalise_family_name(name: &str) -> String { + name.chars() + .filter(|c| c.is_ascii_alphanumeric()) + .map(|c| c.to_ascii_lowercase()) + .collect() +} + +/// Ensure no node controlled by `address` is currently a member of any family. +pub(crate) fn ensure_address_holds_no_family_membership( + storage: &NodeFamiliesStorage, + deps: Deps, + address: &Addr, +) -> Result<(), NodeFamiliesContractError> { + let mixnet_contract = storage.mixnet_contract_address.load(deps.storage)?; + let Some(nym_node) = deps + .querier + .query_nymnode_ownership(&mixnet_contract, address)? + else { + // if the owner has no nym-node, it can't possibly be in a family + return Ok(()); + }; + + // check if that node is in a family + if let Some(family) = storage + .family_members + .may_load(deps.storage, nym_node.node_id)? + { + return Err(NodeFamiliesContractError::AlreadyInFamily { + address: address.clone(), + node_id: nym_node.node_id, + family_id: family.family_id, + }); + } + + Ok(()) +} + +/// Cross-contract query: ensure `node_id` is a currently-bonded node in the +/// mixnet contract. Returns [`NodeDoesntExist`] otherwise. +/// +/// [`NodeDoesntExist`]: NodeFamiliesContractError::NodeDoesntExist +pub(crate) fn ensure_node_is_bonded( + storage: &NodeFamiliesStorage, + deps: Deps, + node_id: NodeId, +) -> Result<(), NodeFamiliesContractError> { + let mixnet_contract = storage.mixnet_contract_address.load(deps.storage)?; + if !deps + .querier + .check_node_existence(&mixnet_contract, node_id)? + { + return Err(NodeFamiliesContractError::NodeDoesntExist { node_id }); + } + Ok(()) +} + +/// Ensure `address` is the controller of the bonded node `node_id` per the +/// mixnet contract. Errors with [`SenderDoesntControlNode`] when `address` +/// owns no bonded node, owns a node with a different id, or owns it but it +/// has entered the unbonding state. +/// +/// [`SenderDoesntControlNode`]: NodeFamiliesContractError::SenderDoesntControlNode +pub(crate) fn ensure_has_bonded_node( + storage: &NodeFamiliesStorage, + deps: Deps, + address: &Addr, + node_id: NodeId, +) -> Result<(), NodeFamiliesContractError> { + let mixnet_contract = storage.mixnet_contract_address.load(deps.storage)?; + match deps + .querier + .query_nymnode_ownership(&mixnet_contract, address)? + { + Some(bond) if bond.node_id == node_id && !bond.is_unbonding => Ok(()), + _ => Err(NodeFamiliesContractError::SenderDoesntControlNode { + address: address.clone(), + node_id, + }), + } +} + +/// Ensure `node_id` is not currently a member of any family. Returns +/// [`NodeAlreadyInFamily`] if it is. +/// +/// [`NodeAlreadyInFamily`]: NodeFamiliesContractError::NodeAlreadyInFamily +pub(crate) fn ensure_node_not_in_family( + storage: &NodeFamiliesStorage, + deps: Deps, + node_id: NodeId, +) -> Result<(), NodeFamiliesContractError> { + if let Some(membership) = storage.family_members.may_load(deps.storage, node_id)? { + return Err(NodeFamiliesContractError::NodeAlreadyInFamily { + node_id, + family_id: membership.family_id, + }); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + mod normalise_family_name { + use super::*; + + #[test] + fn empty_input_yields_empty() { + assert_eq!(normalise_family_name(""), ""); + } + + #[test] + fn already_canonical_is_unchanged() { + assert_eq!(normalise_family_name("foobar42"), "foobar42"); + } + + #[test] + fn lowercases_uppercase_letters() { + assert_eq!(normalise_family_name("FOOBAR"), "foobar"); + assert_eq!(normalise_family_name("FooBar"), "foobar"); + } + + #[test] + fn strips_whitespace() { + assert_eq!(normalise_family_name(" foo bar "), "foobar"); + assert_eq!(normalise_family_name("foo\tbar\nbaz"), "foobarbaz"); + } + + #[test] + fn strips_punctuation_and_symbols() { + assert_eq!(normalise_family_name("foo-bar!"), "foobar"); + assert_eq!(normalise_family_name("a.b_c@d"), "abcd"); + } + + #[test] + fn preserves_digits() { + assert_eq!(normalise_family_name("squad-2026"), "squad2026"); + assert_eq!(normalise_family_name("0123456789"), "0123456789"); + } + + #[test] + fn drops_non_ascii_letters() { + // is_ascii_alphanumeric is strict — accented and non-Latin chars are dropped. + assert_eq!(normalise_family_name("café"), "caf"); + assert_eq!(normalise_family_name("Ω-team"), "team"); + assert_eq!(normalise_family_name("名前"), ""); + } + + #[test] + fn all_symbols_input_normalises_to_empty() { + // try_create_family relies on this to surface EmptyFamilyName. + assert_eq!(normalise_family_name(" "), ""); + assert_eq!(normalise_family_name("!!!---"), ""); + } + + #[test] + fn distinct_inputs_collide_under_normalisation() { + // The collision behaviour the unique-name index depends on. + let canonical = normalise_family_name("Foo Bar"); + assert_eq!(canonical, normalise_family_name("foobar")); + assert_eq!(canonical, normalise_family_name("FOO-BAR")); + assert_eq!(canonical, normalise_family_name(" f.o.o.b.a.r ")); + } + } +} diff --git a/contracts/node-families/src/lib.rs b/contracts/node-families/src/lib.rs new file mode 100644 index 0000000000..3325c5b687 --- /dev/null +++ b/contracts/node-families/src/lib.rs @@ -0,0 +1,28 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +//! CosmWasm contract that manages "node families" — owner-led groupings of +//! Nym nodes — including their members, pending invitations, and historical +//! records of past members and rejected/revoked invitations. +//! +//! The shared message and type surface lives in +//! [`node_families_contract_common`]; this crate contains only the on-chain logic +//! and storage layout. + +/// CosmWasm entry points (`instantiate`, `execute`, `query`, `migrate`). +pub mod contract; +/// One-shot data migrations executed by the `migrate` entry point. +pub mod queued_migrations; +/// `cw-storage-plus` definitions: typed maps, items and secondary indexes. +pub mod storage; + +mod helpers; +/// Read-only query handlers backing [`contract::query`]. +mod queries; +/// Test-only helpers — always compiled for this crate's own unit tests via +/// `cfg(test)`; downstream crates can pull them in for their own test +/// harnesses by enabling the `testable-node-families-contract` feature. +#[cfg(any(test, feature = "testable-node-families-contract"))] +pub mod testing; +/// State-mutating execute handlers backing [`contract::execute`]. +mod transactions; diff --git a/contracts/node-families/src/queries.rs b/contracts/node-families/src/queries.rs new file mode 100644 index 0000000000..9b75201fb5 --- /dev/null +++ b/contracts/node-families/src/queries.rs @@ -0,0 +1,2501 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use crate::helpers::normalise_family_name; +use crate::storage::{retrieval_limits, NodeFamiliesStorage}; +use cosmwasm_std::{Deps, Env, Order, StdResult}; +use cw_storage_plus::Bound; +use nym_mixnet_contract_common::NodeId; +use nym_node_families_contract_common::{ + AllFamilyMembersPagedResponse, AllPastFamilyInvitationsPagedResponse, FamiliesPagedResponse, + FamilyMemberRecord, FamilyMembersPagedResponse, GlobalPastFamilyInvitationCursor, + NodeFamiliesContractError, NodeFamilyByNameResponse, NodeFamilyByOwnerResponse, NodeFamilyId, + NodeFamilyMembershipResponse, NodeFamilyResponse, PastFamilyInvitationCursor, + PastFamilyInvitationForNodeCursor, PastFamilyInvitationsForNodePagedResponse, + PastFamilyInvitationsPagedResponse, PastFamilyMemberCursor, PastFamilyMemberForNodeCursor, + PastFamilyMembersForNodePagedResponse, PastFamilyMembersPagedResponse, + PendingFamilyInvitationDetails, PendingFamilyInvitationResponse, + PendingFamilyInvitationsPagedResponse, PendingInvitationsForNodePagedResponse, + PendingInvitationsPagedResponse, +}; + +/// Resolve a single family by its id. Returns `family: None` if no family +/// with that id exists. +pub fn query_family_by_id( + deps: Deps, + family_id: NodeFamilyId, +) -> Result { + let family = NodeFamiliesStorage::new() + .families + .may_load(deps.storage, family_id)?; + Ok(NodeFamilyResponse { family_id, family }) +} + +/// Resolve the (at most one) family owned by `owner`. Returns `family: None` +/// if `owner` does not currently own a family. Backed by the `owner` +/// `UniqueIndex`, so cost is O(1). +pub fn query_family_by_owner( + deps: Deps, + owner: String, +) -> Result { + let owner = deps.api.addr_validate(&owner)?; + let family = NodeFamiliesStorage::new() + .families + .idx + .owner + .item(deps.storage, owner.clone())? + .map(|(_, family)| family); + Ok(NodeFamilyByOwnerResponse { owner, family }) +} + +/// Resolve a single family by its name. The lookup runs the input through +/// [`normalise_family_name`] before hitting the `name` `UniqueIndex`, so +/// e.g. `"foo"`, `"FoO"` and `" foo! "` all resolve to the same family. +/// Returns `family: None` if no family with that (normalised) name exists. +/// Backed by the `name` `UniqueIndex`, so cost is O(1). +pub fn query_family_by_name( + deps: Deps, + name: String, +) -> Result { + let normalised_name = normalise_family_name(&name); + let family = NodeFamiliesStorage::new() + .families + .idx + .normalised_name + .item(deps.storage, normalised_name)? + .map(|(_, family)| family); + Ok(NodeFamilyByNameResponse { name, family }) +} + +/// Report which family — if any — a node currently belongs to. +pub fn query_family_membership( + deps: Deps, + node_id: NodeId, +) -> Result { + let family_id = NodeFamiliesStorage::new() + .family_members + .may_load(deps.storage, node_id)? + .map(|m| m.family_id); + Ok(NodeFamilyMembershipResponse { node_id, family_id }) +} + +/// Resolve a pending invitation by its composite `(family_id, node_id)` key, +/// stamping it with whether it has already timed out at the current block +/// time so the caller doesn't have to do the comparison itself. +pub fn query_pending_invitation( + deps: Deps, + env: Env, + family_id: NodeFamilyId, + node_id: NodeId, +) -> Result { + let now = env.block.time.seconds(); + let invitation = NodeFamiliesStorage::new() + .pending_family_invitations + .may_load(deps.storage, (family_id, node_id))? + .map(|invitation| PendingFamilyInvitationDetails { + expired: now >= invitation.expires_at, + invitation, + }); + Ok(PendingFamilyInvitationResponse { + family_id, + node_id, + invitation, + }) +} + +/// Page through every node currently in `family_id`, in ascending +/// [`NodeId`] order. +/// +/// Backed by the `family` multi-index over [`crate::storage::NodeFamiliesStorage::family_members`], +/// so the cost is O(page size) regardless of how many other families exist. +/// Does not verify that `family_id` refers to an existing family — an +/// unknown id simply yields an empty page. +/// +/// `start_after` is exclusive — pass the previous page's `start_next_after` +/// to fetch the next page; pass `None` to start from the lowest-id member. +/// `limit` defaults to [`retrieval_limits::FAMILY_MEMBERS_DEFAULT_LIMIT`] +/// and is clamped to [`retrieval_limits::FAMILY_MEMBERS_MAX_LIMIT`]. +pub fn query_family_members_paged( + deps: Deps, + family_id: NodeFamilyId, + start_after: Option, + limit: Option, +) -> Result { + let limit = limit + .unwrap_or(retrieval_limits::FAMILY_MEMBERS_DEFAULT_LIMIT) + .min(retrieval_limits::FAMILY_MEMBERS_MAX_LIMIT) as usize; + + let start = start_after.map(Bound::exclusive); + + let storage = NodeFamiliesStorage::new(); + let members = storage + .family_members + .idx + .family + .prefix(family_id) + .range(deps.storage, start, None, Order::Ascending) + .take(limit) + .map(|res| { + res.map(|(node_id, membership)| FamilyMemberRecord { + node_id, + membership, + }) + }) + .collect::>>()?; + + let start_next_after = members.last().map(|record| record.node_id); + + Ok(FamilyMembersPagedResponse { + family_id, + members, + start_next_after, + }) +} + +/// Page through every current family member across all families, in ascending +/// [`NodeId`] order. Each entry carries the [`FamilyMembership`](node_families_contract_common::FamilyMembership) +/// record, which names the family the node belongs to. +/// +/// Cost is O(page size) — full range scan over the primary `family_members` +/// map without any prefix filter. Since each node belongs to at most one +/// family, [`NodeId`] alone is sufficient as a pagination cursor. +/// +/// `start_after` is exclusive — pass the previous page's `start_next_after` +/// to fetch the next page; pass `None` to start from the lowest-id member. +/// `limit` defaults to [`retrieval_limits::FAMILY_MEMBERS_DEFAULT_LIMIT`] +/// and is clamped to [`retrieval_limits::FAMILY_MEMBERS_MAX_LIMIT`]. +pub fn query_all_family_members_paged( + deps: Deps, + start_after: Option, + limit: Option, +) -> Result { + let limit = limit + .unwrap_or(retrieval_limits::FAMILY_MEMBERS_DEFAULT_LIMIT) + .min(retrieval_limits::FAMILY_MEMBERS_MAX_LIMIT) as usize; + + let start = start_after.map(Bound::exclusive); + + let storage = NodeFamiliesStorage::new(); + let members = storage + .family_members + .range(deps.storage, start, None, Order::Ascending) + .take(limit) + .map(|res| { + res.map(|(node_id, membership)| FamilyMemberRecord { + node_id, + membership, + }) + }) + .collect::>>()?; + + let start_next_after = members.last().map(|record| record.node_id); + + Ok(AllFamilyMembersPagedResponse { + members, + start_next_after, + }) +} + +/// Page through every pending invitation issued by `family_id`, in ascending +/// invitee [`NodeId`] order. Each entry is stamped with `expired` based on +/// the current block time, so callers don't have to compare it themselves. +/// +/// Backed by a prefix scan on the composite primary key +/// `(family_id, node_id)` of `pending_family_invitations`, so cost is +/// O(page size). Does not verify that `family_id` refers to an existing +/// family — an unknown id simply yields an empty page. +/// +/// `start_after` is exclusive — pass the previous page's `start_next_after` +/// to fetch the next page; pass `None` to start from the lowest-id invitee. +/// `limit` defaults to [`retrieval_limits::PENDING_INVITATIONS_DEFAULT_LIMIT`] +/// and is clamped to [`retrieval_limits::PENDING_INVITATIONS_MAX_LIMIT`]. +pub fn query_pending_invitations_for_family_paged( + deps: Deps, + env: Env, + family_id: NodeFamilyId, + start_after: Option, + limit: Option, +) -> Result { + let limit = limit + .unwrap_or(retrieval_limits::PENDING_INVITATIONS_DEFAULT_LIMIT) + .min(retrieval_limits::PENDING_INVITATIONS_MAX_LIMIT) as usize; + + let now = env.block.time.seconds(); + let start = start_after.map(Bound::exclusive); + + let storage = NodeFamiliesStorage::new(); + let invitations = storage + .pending_family_invitations + .prefix(family_id) + .range(deps.storage, start, None, Order::Ascending) + .take(limit) + .map(|res| { + res.map(|(_node_id, invitation)| PendingFamilyInvitationDetails { + expired: now >= invitation.expires_at, + invitation, + }) + }) + .collect::>>()?; + + let start_next_after = invitations.last().map(|d| d.invitation.node_id); + + Ok(PendingFamilyInvitationsPagedResponse { + family_id, + invitations, + start_next_after, + }) +} + +/// Page through every pending invitation addressed to `node_id`, in ascending +/// issuing [`NodeFamilyId`] order. Each entry is stamped with `expired` based +/// on the current block time. +/// +/// Backed by the `node` multi-index over `pending_family_invitations`, so +/// cost is O(page size). `start_after` is exclusive — pass the previous +/// page's `start_next_after` to fetch the next page. `limit` defaults to +/// [`retrieval_limits::PENDING_INVITATIONS_DEFAULT_LIMIT`] and is clamped to +/// [`retrieval_limits::PENDING_INVITATIONS_MAX_LIMIT`]. +pub fn query_pending_invitations_for_node_paged( + deps: Deps, + env: Env, + node_id: NodeId, + start_after: Option, + limit: Option, +) -> Result { + let limit = limit + .unwrap_or(retrieval_limits::PENDING_INVITATIONS_DEFAULT_LIMIT) + .min(retrieval_limits::PENDING_INVITATIONS_MAX_LIMIT) as usize; + + let now = env.block.time.seconds(); + let start = start_after.map(|family_id| Bound::exclusive((family_id, node_id))); + + let storage = NodeFamiliesStorage::new(); + let invitations = storage + .pending_family_invitations + .idx + .node + .prefix(node_id) + .range(deps.storage, start, None, Order::Ascending) + .take(limit) + .map(|res| { + res.map(|(_pk, invitation)| PendingFamilyInvitationDetails { + expired: now >= invitation.expires_at, + invitation, + }) + }) + .collect::>>()?; + + let start_next_after = invitations.last().map(|d| d.invitation.family_id); + + Ok(PendingInvitationsForNodePagedResponse { + node_id, + invitations, + start_next_after, + }) +} + +/// Page through every pending invitation across all families, in ascending +/// `(family_id, node_id)` order. Each entry is stamped with `expired` based +/// on the current block time. +/// +/// Cost is O(page size) — full range scan over the +/// `pending_family_invitations` map without any prefix filter. +/// +/// `start_after` is exclusive — pass the previous page's `start_next_after` +/// to fetch the next page; pass `None` to start from the first entry. +/// `limit` defaults to [`retrieval_limits::PENDING_INVITATIONS_DEFAULT_LIMIT`] +/// and is clamped to [`retrieval_limits::PENDING_INVITATIONS_MAX_LIMIT`]. +pub fn query_all_pending_invitations_paged( + deps: Deps, + env: Env, + start_after: Option<(NodeFamilyId, NodeId)>, + limit: Option, +) -> Result { + let limit = limit + .unwrap_or(retrieval_limits::PENDING_INVITATIONS_DEFAULT_LIMIT) + .min(retrieval_limits::PENDING_INVITATIONS_MAX_LIMIT) as usize; + + let now = env.block.time.seconds(); + let start = start_after.map(Bound::exclusive); + + let storage = NodeFamiliesStorage::new(); + let invitations = storage + .pending_family_invitations + .range(deps.storage, start, None, Order::Ascending) + .take(limit) + .map(|res| { + res.map(|(_key, invitation)| PendingFamilyInvitationDetails { + expired: now >= invitation.expires_at, + invitation, + }) + }) + .collect::>>()?; + + let start_next_after = invitations + .last() + .map(|d| (d.invitation.family_id, d.invitation.node_id)); + + Ok(PendingInvitationsPagedResponse { + invitations, + start_next_after, + }) +} + +/// Page through every archived (terminal-state) invitation issued by +/// `family_id`, in ascending `(node_id, counter)` order across all +/// `Accepted` / `Rejected` / `Revoked` statuses. +/// +/// Uses a direct bounds-based range scan on the primary map keyed by +/// `((family_id, node_id), counter)` — `family_id` is already the leftmost +/// key component, so this avoids the extra storage read per entry that +/// going through the `family` multi-index would incur. Cost is O(page +/// size). Does not verify that `family_id` refers to an existing +/// family — an unknown id simply yields an empty page. +/// +/// `start_after` is exclusive — pass the previous page's `start_next_after` +/// to fetch the next page; pass `None` to start from the first archived +/// entry. `limit` defaults to [`retrieval_limits::PAST_INVITATIONS_DEFAULT_LIMIT`] +/// and is clamped to [`retrieval_limits::PAST_INVITATIONS_MAX_LIMIT`]. +pub fn query_past_invitations_for_family_paged( + deps: Deps, + family_id: NodeFamilyId, + start_after: Option, + limit: Option, +) -> Result { + let limit = limit + .unwrap_or(retrieval_limits::PAST_INVITATIONS_DEFAULT_LIMIT) + .min(retrieval_limits::PAST_INVITATIONS_MAX_LIMIT) as usize; + + let lower = Some(match start_after { + Some((node_id, counter)) => Bound::exclusive(((family_id, node_id), counter)), + None => Bound::inclusive(((family_id, 0), 0)), + }); + + // upper bound = first key of next family; + let upper = Some(Bound::exclusive(((family_id + 1, 0), 0))); + + let storage = NodeFamiliesStorage::new(); + let entries = storage + .past_family_invitations + .range(deps.storage, lower, upper, Order::Ascending) + .take(limit) + .collect::>>()?; + + let start_next_after = entries + .last() + .map(|(((_, node_id), counter), _)| (*node_id, *counter)); + + let invitations = entries.into_iter().map(|(_, v)| v).collect(); + + Ok(PastFamilyInvitationsPagedResponse { + family_id, + invitations, + start_next_after, + }) +} + +/// Page through every archived (terminal-state) invitation addressed to +/// `node_id`, in ascending `(family_id, counter)` order across all +/// `Accepted` / `Rejected` / `Revoked` statuses. +/// +/// Backed by the `node` multi-index over `past_family_invitations`, so +/// cost is O(page size). `start_after` is exclusive — pass the previous +/// page's `start_next_after` to fetch the next page. `limit` defaults to +/// [`retrieval_limits::PAST_INVITATIONS_DEFAULT_LIMIT`] and is clamped to +/// [`retrieval_limits::PAST_INVITATIONS_MAX_LIMIT`]. +pub fn query_past_invitations_for_node_paged( + deps: Deps, + node_id: NodeId, + start_after: Option, + limit: Option, +) -> Result { + let limit = limit + .unwrap_or(retrieval_limits::PAST_INVITATIONS_DEFAULT_LIMIT) + .min(retrieval_limits::PAST_INVITATIONS_MAX_LIMIT) as usize; + + let start = + start_after.map(|(family_id, counter)| Bound::exclusive(((family_id, node_id), counter))); + + let storage = NodeFamiliesStorage::new(); + let entries = storage + .past_family_invitations + .idx + .node + .prefix(node_id) + .range(deps.storage, start, None, Order::Ascending) + .take(limit) + .collect::>>()?; + + let start_next_after = entries + .last() + .map(|(((family_id, _), counter), _)| (*family_id, *counter)); + + let invitations = entries.into_iter().map(|(_, v)| v).collect(); + + Ok(PastFamilyInvitationsForNodePagedResponse { + node_id, + invitations, + start_next_after, + }) +} + +/// Page through every archived (terminal-state) invitation across all +/// families, in ascending `((family_id, node_id), counter)` order. +/// +/// Cost is O(page size) — full range scan over the +/// `past_family_invitations` map without any prefix filter. +/// +/// `start_after` is exclusive — pass the previous page's `start_next_after` +/// to fetch the next page; pass `None` to start from the first entry. +/// `limit` defaults to [`retrieval_limits::PAST_INVITATIONS_DEFAULT_LIMIT`] +/// and is clamped to [`retrieval_limits::PAST_INVITATIONS_MAX_LIMIT`]. +pub fn query_all_past_invitations_paged( + deps: Deps, + start_after: Option, + limit: Option, +) -> Result { + let limit = limit + .unwrap_or(retrieval_limits::PAST_INVITATIONS_DEFAULT_LIMIT) + .min(retrieval_limits::PAST_INVITATIONS_MAX_LIMIT) as usize; + + let start = start_after.map(Bound::exclusive); + + let storage = NodeFamiliesStorage::new(); + let entries = storage + .past_family_invitations + .range(deps.storage, start, None, Order::Ascending) + .take(limit) + .collect::>>()?; + + let start_next_after = entries.last().map(|(key, _)| *key); + + let invitations = entries.into_iter().map(|(_, v)| v).collect(); + + Ok(AllPastFamilyInvitationsPagedResponse { + invitations, + start_next_after, + }) +} + +/// Page through every archived membership record for `family_id` (nodes that +/// used to belong to it but have since been removed), in ascending +/// `(node_id, counter)` order. +/// +/// Uses a direct bounds-based range scan on the primary map keyed by +/// `((family_id, node_id), counter)` — `family_id` is already the leftmost +/// key component, so this avoids the extra storage read per entry that +/// going through the `family` multi-index would incur. Cost is O(page +/// size). Does not verify that `family_id` refers to an existing +/// family — an unknown id simply yields an empty page. +/// +/// `start_after` is exclusive — pass the previous page's `start_next_after` +/// to fetch the next page; pass `None` to start from the first archived +/// entry. `limit` defaults to [`retrieval_limits::PAST_MEMBERS_DEFAULT_LIMIT`] +/// and is clamped to [`retrieval_limits::PAST_MEMBERS_MAX_LIMIT`]. +pub fn query_past_members_for_family_paged( + deps: Deps, + family_id: NodeFamilyId, + start_after: Option, + limit: Option, +) -> Result { + let limit = limit + .unwrap_or(retrieval_limits::PAST_MEMBERS_DEFAULT_LIMIT) + .min(retrieval_limits::PAST_MEMBERS_MAX_LIMIT) as usize; + + let lower = Some(match start_after { + Some((node_id, counter)) => Bound::exclusive(((family_id, node_id), counter)), + None => Bound::inclusive(((family_id, 0), 0)), + }); + + // upper bound = first key of next family; + let upper = Some(Bound::exclusive(((family_id + 1, 0), 0))); + + let storage = NodeFamiliesStorage::new(); + let entries = storage + .past_family_members + .range(deps.storage, lower, upper, Order::Ascending) + .take(limit) + .collect::>>()?; + + let start_next_after = entries + .last() + .map(|(((_, node_id), counter), _)| (*node_id, *counter)); + + let members = entries.into_iter().map(|(_, v)| v).collect(); + + Ok(PastFamilyMembersPagedResponse { + family_id, + members, + start_next_after, + }) +} + +/// Page through every archived membership record for `node_id` (every family +/// the node used to belong to but has since been removed from), in ascending +/// `(family_id, counter)` order. +/// +/// Backed by the `node` multi-index over `past_family_members`, so cost is +/// O(page size). `start_after` is exclusive — pass the previous page's +/// `start_next_after` to fetch the next page. `limit` defaults to +/// [`retrieval_limits::PAST_MEMBERS_DEFAULT_LIMIT`] and is clamped to +/// [`retrieval_limits::PAST_MEMBERS_MAX_LIMIT`]. +pub fn query_past_members_for_node_paged( + deps: Deps, + node_id: NodeId, + start_after: Option, + limit: Option, +) -> Result { + let limit = limit + .unwrap_or(retrieval_limits::PAST_MEMBERS_DEFAULT_LIMIT) + .min(retrieval_limits::PAST_MEMBERS_MAX_LIMIT) as usize; + + let start = + start_after.map(|(family_id, counter)| Bound::exclusive(((family_id, node_id), counter))); + + let storage = NodeFamiliesStorage::new(); + let entries = storage + .past_family_members + .idx + .node + .prefix(node_id) + .range(deps.storage, start, None, Order::Ascending) + .take(limit) + .collect::>>()?; + + let start_next_after = entries + .last() + .map(|(((family_id, _), counter), _)| (*family_id, *counter)); + + let members = entries.into_iter().map(|(_, v)| v).collect(); + + Ok(PastFamilyMembersForNodePagedResponse { + node_id, + members, + start_next_after, + }) +} + +/// Page through every existing family in ascending [`NodeFamilyId`] order. +/// +/// `start_after` is exclusive — pass the previous page's `start_next_after` +/// to fetch the next page; pass `None` to start from the first family. +/// `limit` defaults to [`retrieval_limits::FAMILIES_DEFAULT_LIMIT`] and is +/// clamped to [`retrieval_limits::FAMILIES_MAX_LIMIT`]. +pub fn query_families_paged( + deps: Deps, + start_after: Option, + limit: Option, +) -> Result { + let limit = limit + .unwrap_or(retrieval_limits::FAMILIES_DEFAULT_LIMIT) + .min(retrieval_limits::FAMILIES_MAX_LIMIT) as usize; + + let start = start_after.map(Bound::exclusive); + + let storage = NodeFamiliesStorage::new(); + let families = storage + .families + .range(deps.storage, start, None, Order::Ascending) + .take(limit) + .map(|res| res.map(|item| item.1)) + .collect::>>()?; + + let start_next_after = families.last().map(|family| family.id); + + Ok(FamiliesPagedResponse { + families, + start_next_after, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::{init_contract_tester, NodeFamiliesContractTesterExt}; + use nym_contracts_common_testing::{ChainOpts, ContractOpts}; + + #[cfg(test)] + mod family_by_id { + use super::*; + + #[test] + fn family_by_id_returns_none_when_missing() { + let tester = init_contract_tester(); + + let res = query_family_by_id(tester.deps(), 99).unwrap(); + assert_eq!(res.family_id, 99); + assert!(res.family.is_none()); + } + + #[test] + fn family_by_id_returns_persisted_family() { + let mut tester = init_contract_tester(); + let f = tester.add_dummy_family(); + + let res = query_family_by_id(tester.deps(), f.id).unwrap(); + assert_eq!(res.family_id, f.id); + assert_eq!(res.family, Some(f)); + } + } + + #[cfg(test)] + mod family_by_owner { + use super::*; + + #[test] + fn returns_none_when_owner_owns_no_family() { + let tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + + let res = query_family_by_owner(tester.deps(), alice.to_string()).unwrap(); + assert_eq!(res.owner, alice); + assert!(res.family.is_none()); + } + + #[test] + fn returns_persisted_family_for_owner() { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let f = tester.make_family(&alice); + + let res = query_family_by_owner(tester.deps(), alice.to_string()).unwrap(); + assert_eq!(res.owner, alice); + assert_eq!(res.family, Some(f)); + } + + #[test] + fn distinguishes_owners() { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let bob = tester.addr_make("bob"); + let f_alice = tester.make_family(&alice); + let f_bob = tester.make_family(&bob); + + let res = query_family_by_owner(tester.deps(), alice.to_string()).unwrap(); + assert_eq!(res.family, Some(f_alice)); + let res = query_family_by_owner(tester.deps(), bob.to_string()).unwrap(); + assert_eq!(res.family, Some(f_bob)); + } + + #[test] + fn errors_on_invalid_address() { + let tester = init_contract_tester(); + + let res = query_family_by_owner(tester.deps(), "not a valid addr".to_string()); + assert!(res.is_err()); + } + } + + #[cfg(test)] + mod family_by_name { + use super::*; + use nym_contracts_common_testing::RandExt; + + #[test] + fn returns_none_when_name_does_not_exist() { + let tester = init_contract_tester(); + + let res = query_family_by_name(tester.deps(), "missing".to_string()).unwrap(); + assert_eq!(res.name, "missing"); + assert!(res.family.is_none()); + } + + #[test] + fn returns_persisted_family_by_exact_name() { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let f = tester.make_named_family(&alice, "foo"); + + let res = query_family_by_name(tester.deps(), "foo".to_string()).unwrap(); + assert_eq!(res.name, "foo"); + assert_eq!(res.family, Some(f)); + } + + #[test] + fn lookup_normalises_name() { + let variants = ["foo", "FOO", "FoO", "fOo", "foo🚀", "🚀Foo", " foo-!"]; + + for family_name in variants { + let mut tester = init_contract_tester(); + let owner = tester.generate_account(); + let f = tester.make_named_family(&owner, family_name); + + for query_name in variants { + let res = query_family_by_name(tester.deps(), query_name.to_string()).unwrap(); + assert_eq!(res.name, query_name); + assert_eq!(res.family, Some(f.clone())); + let stored = res.family.unwrap(); + // user-submitted formatting is preserved on the record; + // the normalised form is what enforces uniqueness. + assert_eq!(stored.name, family_name); + assert_eq!(stored.normalised_name, normalise_family_name(family_name)); + } + } + } + } + + #[cfg(test)] + mod family_membership { + use super::*; + + #[test] + fn family_membership_returns_none_for_unaffiliated_node() { + let tester = init_contract_tester(); + + let res = query_family_membership(tester.deps(), 999).unwrap(); + assert_eq!(res.node_id, 999); + assert!(res.family_id.is_none()); + } + + #[test] + fn family_membership_returns_family_id_for_member() { + let mut tester = init_contract_tester(); + let f = tester.add_dummy_family(); + + tester.add_to_family(f.id, 42); + + let res = query_family_membership(tester.deps(), 42).unwrap(); + assert_eq!(res.node_id, 42); + assert_eq!(res.family_id, Some(f.id)); + } + + #[test] + fn family_membership_returns_none_after_node_is_removed() { + let mut tester = init_contract_tester(); + let f = tester.add_dummy_family(); + + tester.add_to_family(f.id, 42); + tester.remove_from_family(42); + + let res = query_family_membership(tester.deps(), 42).unwrap(); + assert!(res.family_id.is_none()); + } + } + + #[cfg(test)] + mod pending_invitation { + use super::*; + + #[test] + fn pending_invitation_returns_none_when_missing() { + let tester = init_contract_tester(); + let env = tester.env(); + + let res = query_pending_invitation(tester.deps(), env, 1, 42).unwrap(); + assert_eq!(res.family_id, 1); + assert_eq!(res.node_id, 42); + assert!(res.invitation.is_none()); + } + + #[test] + fn pending_invitation_returns_unexpired_when_in_future() { + let mut tester = init_contract_tester(); + let f = tester.add_dummy_family(); + + let inv = tester.invite_to_family(f.id, 42); + let env = tester.env(); + + let res = query_pending_invitation(tester.deps(), env, f.id, 42).unwrap(); + let details = res.invitation.unwrap(); + assert_eq!(details.invitation, inv); + assert!(!details.expired); + } + + #[test] + fn pending_invitation_flagged_as_expired_after_block_time() { + let mut tester = init_contract_tester(); + let f = tester.add_dummy_family(); + + let expires_at = tester.env().block.time.seconds() + 5; + tester.invite_to_family_with_expiration(f.id, 42, expires_at); + + // advance block time well past the expiry + tester.advance_time_by(60); + + let env = tester.env(); + assert!(env.block.time.seconds() >= expires_at); + + let res = query_pending_invitation(tester.deps(), env, f.id, 42).unwrap(); + let details = res.invitation.unwrap(); + assert_eq!(details.invitation.expires_at, expires_at); + assert!(details.expired); + } + } + + #[cfg(test)] + mod families_paged { + use super::*; + + #[test] + fn empty_when_no_families_exist() { + let tester = init_contract_tester(); + + let res = query_families_paged(tester.deps(), None, None).unwrap(); + assert!(res.families.is_empty()); + assert!(res.start_next_after.is_none()); + } + + #[test] + fn returns_all_families_within_default_limit() { + let mut tester = init_contract_tester(); + let f1 = tester.add_dummy_family(); + let f2 = tester.add_dummy_family(); + let f3 = tester.add_dummy_family(); + + let res = query_families_paged(tester.deps(), None, None).unwrap(); + assert_eq!(res.families, vec![f1, f2, f3.clone()]); + assert_eq!(res.start_next_after, Some(f3.id)); + } + + #[test] + fn returns_families_in_ascending_id_order() { + let mut tester = init_contract_tester(); + let f1 = tester.add_dummy_family(); + let f2 = tester.add_dummy_family(); + let f3 = tester.add_dummy_family(); + + let res = query_families_paged(tester.deps(), None, None).unwrap(); + let ids: Vec<_> = res.families.iter().map(|f| f.id).collect(); + assert_eq!(ids, vec![f1.id, f2.id, f3.id]); + } + + #[test] + fn limit_caps_page_size() { + let mut tester = init_contract_tester(); + let f1 = tester.add_dummy_family(); + let f2 = tester.add_dummy_family(); + let _f3 = tester.add_dummy_family(); + + let res = query_families_paged(tester.deps(), None, Some(2)).unwrap(); + assert_eq!(res.families, vec![f1, f2.clone()]); + assert_eq!(res.start_next_after, Some(f2.id)); + } + + #[test] + fn start_after_is_exclusive() { + let mut tester = init_contract_tester(); + let f1 = tester.add_dummy_family(); + let f2 = tester.add_dummy_family(); + let f3 = tester.add_dummy_family(); + + let res = query_families_paged(tester.deps(), Some(f1.id), None).unwrap(); + assert_eq!(res.families, vec![f2, f3.clone()]); + assert_eq!(res.start_next_after, Some(f3.id)); + } + + #[test] + fn paginates_through_all_families() { + let mut tester = init_contract_tester(); + let f1 = tester.add_dummy_family(); + let f2 = tester.add_dummy_family(); + let f3 = tester.add_dummy_family(); + let f4 = tester.add_dummy_family(); + let f5 = tester.add_dummy_family(); + + let page1 = query_families_paged(tester.deps(), None, Some(2)).unwrap(); + assert_eq!(page1.families, vec![f1, f2.clone()]); + assert_eq!(page1.start_next_after, Some(f2.id)); + + let page2 = + query_families_paged(tester.deps(), page1.start_next_after, Some(2)).unwrap(); + assert_eq!(page2.families, vec![f3, f4.clone()]); + assert_eq!(page2.start_next_after, Some(f4.id)); + + let page3 = + query_families_paged(tester.deps(), page2.start_next_after, Some(2)).unwrap(); + assert_eq!(page3.families, vec![f5.clone()]); + assert_eq!(page3.start_next_after, Some(f5.id)); + + let page4 = + query_families_paged(tester.deps(), page3.start_next_after, Some(2)).unwrap(); + assert!(page4.families.is_empty()); + assert!(page4.start_next_after.is_none()); + } + + #[test] + fn limit_is_clamped_to_max() { + let mut tester = init_contract_tester(); + let total = retrieval_limits::FAMILIES_MAX_LIMIT as usize + 5; + for _ in 0..total { + tester.add_dummy_family(); + } + + let res = query_families_paged(tester.deps(), None, Some(u32::MAX)).unwrap(); + assert_eq!( + res.families.len(), + retrieval_limits::FAMILIES_MAX_LIMIT as usize + ); + } + + #[test] + fn default_limit_applied_when_unspecified() { + let mut tester = init_contract_tester(); + let total = retrieval_limits::FAMILIES_DEFAULT_LIMIT as usize + 5; + for _ in 0..total { + tester.add_dummy_family(); + } + + let res = query_families_paged(tester.deps(), None, None).unwrap(); + assert_eq!( + res.families.len(), + retrieval_limits::FAMILIES_DEFAULT_LIMIT as usize + ); + } + + #[test] + fn start_after_past_end_returns_empty() { + let mut tester = init_contract_tester(); + let f = tester.add_dummy_family(); + + let res = query_families_paged(tester.deps(), Some(f.id), None).unwrap(); + assert!(res.families.is_empty()); + assert!(res.start_next_after.is_none()); + } + } + + #[cfg(test)] + mod family_members_paged { + use super::*; + + #[test] + fn empty_when_family_has_no_members() { + let mut tester = init_contract_tester(); + let f = tester.add_dummy_family(); + + let res = query_family_members_paged(tester.deps(), f.id, None, None).unwrap(); + assert_eq!(res.family_id, f.id); + assert!(res.members.is_empty()); + assert!(res.start_next_after.is_none()); + } + + #[test] + fn empty_for_unknown_family_id() { + let tester = init_contract_tester(); + + let res = query_family_members_paged(tester.deps(), 99, None, None).unwrap(); + assert_eq!(res.family_id, 99); + assert!(res.members.is_empty()); + assert!(res.start_next_after.is_none()); + } + + #[test] + fn returns_only_members_of_requested_family() { + let mut tester = init_contract_tester(); + let f1 = tester.add_dummy_family(); + let f2 = tester.add_dummy_family(); + + tester.add_to_family(f1.id, 10); + tester.add_to_family(f1.id, 11); + tester.add_to_family(f2.id, 20); + + let res = query_family_members_paged(tester.deps(), f1.id, None, None).unwrap(); + let ids: Vec<_> = res.members.iter().map(|m| m.node_id).collect(); + assert_eq!(ids, vec![10, 11]); + for record in &res.members { + assert_eq!(record.membership.family_id, f1.id); + } + } + + #[test] + fn member_record_carries_joined_at_timestamp() { + let mut tester = init_contract_tester(); + let f = tester.add_dummy_family(); + tester.add_to_family(f.id, 42); + + let expected = tester.env().block.time.seconds(); + let res = query_family_members_paged(tester.deps(), f.id, None, None).unwrap(); + let record = res.members.into_iter().next().unwrap(); + assert_eq!(record.node_id, 42); + assert_eq!(record.membership.family_id, f.id); + assert_eq!(record.membership.joined_at, expected); + } + + #[test] + fn members_returned_in_ascending_node_id_order() { + let mut tester = init_contract_tester(); + let f = tester.add_dummy_family(); + // insert out of order to confirm ordering isn't insertion order + tester.add_to_family(f.id, 30); + tester.add_to_family(f.id, 10); + tester.add_to_family(f.id, 20); + + let res = query_family_members_paged(tester.deps(), f.id, None, None).unwrap(); + let ids: Vec<_> = res.members.iter().map(|m| m.node_id).collect(); + assert_eq!(ids, vec![10, 20, 30]); + } + + #[test] + fn limit_caps_page_size() { + let mut tester = init_contract_tester(); + let f = tester.add_dummy_family(); + for n in [10, 11, 12] { + tester.add_to_family(f.id, n); + } + + let res = query_family_members_paged(tester.deps(), f.id, None, Some(2)).unwrap(); + let ids: Vec<_> = res.members.iter().map(|m| m.node_id).collect(); + assert_eq!(ids, vec![10, 11]); + assert_eq!(res.start_next_after, Some(11)); + } + + #[test] + fn start_after_is_exclusive() { + let mut tester = init_contract_tester(); + let f = tester.add_dummy_family(); + for n in [10, 11, 12] { + tester.add_to_family(f.id, n); + } + + let res = query_family_members_paged(tester.deps(), f.id, Some(10), None).unwrap(); + let ids: Vec<_> = res.members.iter().map(|m| m.node_id).collect(); + assert_eq!(ids, vec![11, 12]); + assert_eq!(res.start_next_after, Some(12)); + } + + #[test] + fn paginates_through_all_members() { + let mut tester = init_contract_tester(); + let f = tester.add_dummy_family(); + for n in [10, 11, 12, 13, 14] { + tester.add_to_family(f.id, n); + } + + let p1 = query_family_members_paged(tester.deps(), f.id, None, Some(2)).unwrap(); + assert_eq!( + p1.members.iter().map(|m| m.node_id).collect::>(), + vec![10, 11] + ); + assert_eq!(p1.start_next_after, Some(11)); + + let p2 = query_family_members_paged(tester.deps(), f.id, p1.start_next_after, Some(2)) + .unwrap(); + assert_eq!( + p2.members.iter().map(|m| m.node_id).collect::>(), + vec![12, 13] + ); + assert_eq!(p2.start_next_after, Some(13)); + + let p3 = query_family_members_paged(tester.deps(), f.id, p2.start_next_after, Some(2)) + .unwrap(); + assert_eq!( + p3.members.iter().map(|m| m.node_id).collect::>(), + vec![14] + ); + assert_eq!(p3.start_next_after, Some(14)); + + let p4 = query_family_members_paged(tester.deps(), f.id, p3.start_next_after, Some(2)) + .unwrap(); + assert!(p4.members.is_empty()); + assert!(p4.start_next_after.is_none()); + } + + #[test] + fn limit_is_clamped_to_max() { + let mut tester = init_contract_tester(); + let f = tester.add_dummy_family(); + let total = retrieval_limits::FAMILY_MEMBERS_MAX_LIMIT + 5; + tester.add_n_family_members(f.id, total); + + let res = + query_family_members_paged(tester.deps(), f.id, None, Some(u32::MAX)).unwrap(); + assert_eq!( + res.members.len(), + retrieval_limits::FAMILY_MEMBERS_MAX_LIMIT as usize + ); + } + + #[test] + fn excludes_node_after_it_leaves_family() { + let mut tester = init_contract_tester(); + let f = tester.add_dummy_family(); + tester.add_to_family(f.id, 10); + tester.add_to_family(f.id, 11); + + tester.remove_from_family(10); + + let res = query_family_members_paged(tester.deps(), f.id, None, None).unwrap(); + let ids: Vec<_> = res.members.iter().map(|m| m.node_id).collect(); + assert_eq!(ids, vec![11]); + } + } + + #[cfg(test)] + mod all_family_members_paged { + use super::*; + + #[test] + fn empty_when_no_families_exist() { + let tester = init_contract_tester(); + + let res = query_all_family_members_paged(tester.deps(), None, None).unwrap(); + assert!(res.members.is_empty()); + assert!(res.start_next_after.is_none()); + } + + #[test] + fn empty_when_families_have_no_members() { + let mut tester = init_contract_tester(); + tester.add_dummy_family(); + tester.add_dummy_family(); + + let res = query_all_family_members_paged(tester.deps(), None, None).unwrap(); + assert!(res.members.is_empty()); + assert!(res.start_next_after.is_none()); + } + + #[test] + fn returns_members_from_every_family() { + let mut tester = init_contract_tester(); + let f1 = tester.add_dummy_family(); + let f2 = tester.add_dummy_family(); + let f3 = tester.add_dummy_family(); + + tester.add_to_family(f1.id, 10); + tester.add_to_family(f2.id, 20); + tester.add_to_family(f3.id, 30); + + let res = query_all_family_members_paged(tester.deps(), None, None).unwrap(); + let pairs: Vec<_> = res + .members + .iter() + .map(|m| (m.node_id, m.membership.family_id)) + .collect(); + assert_eq!(pairs, vec![(10, f1.id), (20, f2.id), (30, f3.id)]); + } + + #[test] + fn members_returned_in_ascending_node_id_order_across_families() { + let mut tester = init_contract_tester(); + let f1 = tester.add_dummy_family(); + let f2 = tester.add_dummy_family(); + + // interleaved so insertion order does not match the requested ordering + tester.add_to_family(f2.id, 30); + tester.add_to_family(f1.id, 10); + tester.add_to_family(f2.id, 20); + tester.add_to_family(f1.id, 40); + + let res = query_all_family_members_paged(tester.deps(), None, None).unwrap(); + let ids: Vec<_> = res.members.iter().map(|m| m.node_id).collect(); + assert_eq!(ids, vec![10, 20, 30, 40]); + } + + #[test] + fn member_record_carries_correct_family_and_joined_at() { + let mut tester = init_contract_tester(); + let f1 = tester.add_dummy_family(); + let f2 = tester.add_dummy_family(); + + tester.add_to_family(f1.id, 7); + tester.add_to_family(f2.id, 99); + let expected_joined_at = tester.env().block.time.seconds(); + + let res = query_all_family_members_paged(tester.deps(), None, None).unwrap(); + let by_node: std::collections::HashMap<_, _> = res + .members + .into_iter() + .map(|m| (m.node_id, m.membership)) + .collect(); + + let m7 = &by_node[&7]; + assert_eq!(m7.family_id, f1.id); + assert_eq!(m7.joined_at, expected_joined_at); + + let m99 = &by_node[&99]; + assert_eq!(m99.family_id, f2.id); + assert_eq!(m99.joined_at, expected_joined_at); + } + + #[test] + fn limit_caps_page_size() { + let mut tester = init_contract_tester(); + let f = tester.add_dummy_family(); + for n in [10, 11, 12] { + tester.add_to_family(f.id, n); + } + + let res = query_all_family_members_paged(tester.deps(), None, Some(2)).unwrap(); + let ids: Vec<_> = res.members.iter().map(|m| m.node_id).collect(); + assert_eq!(ids, vec![10, 11]); + assert_eq!(res.start_next_after, Some(11)); + } + + #[test] + fn start_after_is_exclusive() { + let mut tester = init_contract_tester(); + let f = tester.add_dummy_family(); + for n in [10, 11, 12] { + tester.add_to_family(f.id, n); + } + + let res = query_all_family_members_paged(tester.deps(), Some(10), None).unwrap(); + let ids: Vec<_> = res.members.iter().map(|m| m.node_id).collect(); + assert_eq!(ids, vec![11, 12]); + assert_eq!(res.start_next_after, Some(12)); + } + + #[test] + fn paginates_through_all_members_across_families() { + let mut tester = init_contract_tester(); + let f1 = tester.add_dummy_family(); + let f2 = tester.add_dummy_family(); + + tester.add_to_family(f1.id, 10); + tester.add_to_family(f2.id, 11); + tester.add_to_family(f1.id, 12); + tester.add_to_family(f2.id, 13); + tester.add_to_family(f1.id, 14); + + let p1 = query_all_family_members_paged(tester.deps(), None, Some(2)).unwrap(); + assert_eq!( + p1.members.iter().map(|m| m.node_id).collect::>(), + vec![10, 11] + ); + assert_eq!(p1.start_next_after, Some(11)); + + let p2 = query_all_family_members_paged(tester.deps(), p1.start_next_after, Some(2)) + .unwrap(); + assert_eq!( + p2.members.iter().map(|m| m.node_id).collect::>(), + vec![12, 13] + ); + assert_eq!(p2.start_next_after, Some(13)); + + let p3 = query_all_family_members_paged(tester.deps(), p2.start_next_after, Some(2)) + .unwrap(); + assert_eq!( + p3.members.iter().map(|m| m.node_id).collect::>(), + vec![14] + ); + assert_eq!(p3.start_next_after, Some(14)); + + let p4 = query_all_family_members_paged(tester.deps(), p3.start_next_after, Some(2)) + .unwrap(); + assert!(p4.members.is_empty()); + assert!(p4.start_next_after.is_none()); + } + + #[test] + fn limit_is_clamped_to_max() { + let mut tester = init_contract_tester(); + let f = tester.add_dummy_family(); + let total = retrieval_limits::FAMILY_MEMBERS_MAX_LIMIT + 5; + tester.add_n_family_members(f.id, total); + + let res = query_all_family_members_paged(tester.deps(), None, Some(u32::MAX)).unwrap(); + assert_eq!( + res.members.len(), + retrieval_limits::FAMILY_MEMBERS_MAX_LIMIT as usize + ); + } + + #[test] + fn default_limit_applied_when_unspecified() { + let mut tester = init_contract_tester(); + let f = tester.add_dummy_family(); + let total = retrieval_limits::FAMILY_MEMBERS_DEFAULT_LIMIT + 5; + tester.add_n_family_members(f.id, total); + + let res = query_all_family_members_paged(tester.deps(), None, None).unwrap(); + assert_eq!( + res.members.len(), + retrieval_limits::FAMILY_MEMBERS_DEFAULT_LIMIT as usize + ); + } + + #[test] + fn excludes_node_after_it_leaves_family() { + let mut tester = init_contract_tester(); + let f1 = tester.add_dummy_family(); + let f2 = tester.add_dummy_family(); + tester.add_to_family(f1.id, 10); + tester.add_to_family(f2.id, 11); + + tester.remove_from_family(10); + + let res = query_all_family_members_paged(tester.deps(), None, None).unwrap(); + let ids: Vec<_> = res.members.iter().map(|m| m.node_id).collect(); + assert_eq!(ids, vec![11]); + } + } + + #[cfg(test)] + mod pending_invitations_for_family_paged { + use super::*; + + #[test] + fn empty_when_family_has_no_pending_invitations() { + let mut tester = init_contract_tester(); + let f = tester.add_dummy_family(); + let env = tester.env(); + + let res = + query_pending_invitations_for_family_paged(tester.deps(), env, f.id, None, None) + .unwrap(); + assert_eq!(res.family_id, f.id); + assert!(res.invitations.is_empty()); + assert!(res.start_next_after.is_none()); + } + + #[test] + fn empty_for_unknown_family_id() { + let tester = init_contract_tester(); + let env = tester.env(); + + let res = + query_pending_invitations_for_family_paged(tester.deps(), env, 99, None, None) + .unwrap(); + assert_eq!(res.family_id, 99); + assert!(res.invitations.is_empty()); + } + + #[test] + fn returns_only_invitations_from_requested_family() { + let mut tester = init_contract_tester(); + let f1 = tester.add_dummy_family(); + let f2 = tester.add_dummy_family(); + tester.invite_to_family(f1.id, 10); + tester.invite_to_family(f1.id, 11); + tester.invite_to_family(f2.id, 20); + + let env = tester.env(); + let res = + query_pending_invitations_for_family_paged(tester.deps(), env, f1.id, None, None) + .unwrap(); + let ids: Vec<_> = res + .invitations + .iter() + .map(|d| d.invitation.node_id) + .collect(); + assert_eq!(ids, vec![10, 11]); + for d in &res.invitations { + assert_eq!(d.invitation.family_id, f1.id); + } + } + + #[test] + fn returned_in_ascending_node_id_order() { + let mut tester = init_contract_tester(); + let f = tester.add_dummy_family(); + // out-of-order inserts + tester.invite_to_family(f.id, 30); + tester.invite_to_family(f.id, 10); + tester.invite_to_family(f.id, 20); + + let env = tester.env(); + let res = + query_pending_invitations_for_family_paged(tester.deps(), env, f.id, None, None) + .unwrap(); + let ids: Vec<_> = res + .invitations + .iter() + .map(|d| d.invitation.node_id) + .collect(); + assert_eq!(ids, vec![10, 20, 30]); + } + + #[test] + fn flags_expired_against_current_block_time() { + let mut tester = init_contract_tester(); + let f = tester.add_dummy_family(); + let now = tester.env().block.time.seconds(); + + tester.invite_to_family_with_expiration(f.id, 10, now + 5); + tester.invite_to_family_with_expiration(f.id, 11, now + 1000); + + tester.advance_time_by(60); + let env = tester.env(); + let res = + query_pending_invitations_for_family_paged(tester.deps(), env, f.id, None, None) + .unwrap(); + + assert_eq!(res.invitations[0].invitation.node_id, 10); + assert!(res.invitations[0].expired); + assert_eq!(res.invitations[1].invitation.node_id, 11); + assert!(!res.invitations[1].expired); + } + + #[test] + fn limit_caps_page_size_and_start_after_is_exclusive() { + let mut tester = init_contract_tester(); + let f = tester.add_dummy_family(); + for n in [10, 11, 12] { + tester.invite_to_family(f.id, n); + } + + let env = tester.env(); + let p1 = query_pending_invitations_for_family_paged( + tester.deps(), + env.clone(), + f.id, + None, + Some(2), + ) + .unwrap(); + let ids: Vec<_> = p1 + .invitations + .iter() + .map(|d| d.invitation.node_id) + .collect(); + assert_eq!(ids, vec![10, 11]); + assert_eq!(p1.start_next_after, Some(11)); + + let p2 = query_pending_invitations_for_family_paged( + tester.deps(), + env, + f.id, + p1.start_next_after, + Some(2), + ) + .unwrap(); + let ids: Vec<_> = p2 + .invitations + .iter() + .map(|d| d.invitation.node_id) + .collect(); + assert_eq!(ids, vec![12]); + } + + #[test] + fn limit_is_clamped_to_max() { + let mut tester = init_contract_tester(); + let f = tester.add_dummy_family(); + let total = retrieval_limits::PENDING_INVITATIONS_MAX_LIMIT + 5; + for n in 1..=total { + tester.invite_to_family(f.id, n); + } + + let env = tester.env(); + let res = query_pending_invitations_for_family_paged( + tester.deps(), + env, + f.id, + None, + Some(u32::MAX), + ) + .unwrap(); + assert_eq!( + res.invitations.len(), + retrieval_limits::PENDING_INVITATIONS_MAX_LIMIT as usize + ); + } + + #[test] + fn excludes_invitation_after_it_is_revoked() { + let mut tester = init_contract_tester(); + let f = tester.add_dummy_family(); + tester.invite_to_family(f.id, 10); + tester.invite_to_family(f.id, 11); + + // accepting moves the invitation out of the pending map + tester.accept_invitation(f.id, 10); + + let env = tester.env(); + let res = + query_pending_invitations_for_family_paged(tester.deps(), env, f.id, None, None) + .unwrap(); + let ids: Vec<_> = res + .invitations + .iter() + .map(|d| d.invitation.node_id) + .collect(); + assert_eq!(ids, vec![11]); + } + } + + #[cfg(test)] + mod all_pending_invitations_paged { + use super::*; + + #[test] + fn empty_when_no_pending_invitations() { + let tester = init_contract_tester(); + let env = tester.env(); + + let res = query_all_pending_invitations_paged(tester.deps(), env, None, None).unwrap(); + assert!(res.invitations.is_empty()); + assert!(res.start_next_after.is_none()); + } + + #[test] + fn returns_invitations_across_all_families() { + let mut tester = init_contract_tester(); + let f1 = tester.add_dummy_family(); + let f2 = tester.add_dummy_family(); + tester.invite_to_family(f1.id, 10); + tester.invite_to_family(f1.id, 20); + tester.invite_to_family(f2.id, 5); + + let env = tester.env(); + let res = query_all_pending_invitations_paged(tester.deps(), env, None, None).unwrap(); + + let pairs: Vec<_> = res + .invitations + .iter() + .map(|d| (d.invitation.family_id, d.invitation.node_id)) + .collect(); + // ordered by (family_id asc, node_id asc) + assert_eq!(pairs, vec![(f1.id, 10), (f1.id, 20), (f2.id, 5)]); + assert_eq!(res.start_next_after, Some((f2.id, 5))); + } + + #[test] + fn flags_expired_against_current_block_time() { + let mut tester = init_contract_tester(); + let f = tester.add_dummy_family(); + let now = tester.env().block.time.seconds(); + tester.invite_to_family_with_expiration(f.id, 10, now + 5); + tester.invite_to_family_with_expiration(f.id, 11, now + 1000); + + tester.advance_time_by(60); + let env = tester.env(); + let res = query_all_pending_invitations_paged(tester.deps(), env, None, None).unwrap(); + + let by_node: std::collections::HashMap<_, _> = res + .invitations + .iter() + .map(|d| (d.invitation.node_id, d.expired)) + .collect(); + assert!(by_node[&10]); + assert!(!by_node[&11]); + } + + #[test] + fn paginates_with_composite_cursor() { + let mut tester = init_contract_tester(); + let f1 = tester.add_dummy_family(); + let f2 = tester.add_dummy_family(); + tester.invite_to_family(f1.id, 10); + tester.invite_to_family(f1.id, 20); + tester.invite_to_family(f2.id, 5); + tester.invite_to_family(f2.id, 15); + + let env = tester.env(); + let p1 = query_all_pending_invitations_paged(tester.deps(), env.clone(), None, Some(2)) + .unwrap(); + let pairs: Vec<_> = p1 + .invitations + .iter() + .map(|d| (d.invitation.family_id, d.invitation.node_id)) + .collect(); + assert_eq!(pairs, vec![(f1.id, 10), (f1.id, 20)]); + assert_eq!(p1.start_next_after, Some((f1.id, 20))); + + let p2 = query_all_pending_invitations_paged( + tester.deps(), + env.clone(), + p1.start_next_after, + Some(2), + ) + .unwrap(); + let pairs: Vec<_> = p2 + .invitations + .iter() + .map(|d| (d.invitation.family_id, d.invitation.node_id)) + .collect(); + assert_eq!(pairs, vec![(f2.id, 5), (f2.id, 15)]); + + let p3 = query_all_pending_invitations_paged( + tester.deps(), + env, + p2.start_next_after, + Some(2), + ) + .unwrap(); + assert!(p3.invitations.is_empty()); + assert!(p3.start_next_after.is_none()); + } + + #[test] + fn limit_is_clamped_to_max() { + let mut tester = init_contract_tester(); + let f = tester.add_dummy_family(); + let total = retrieval_limits::PENDING_INVITATIONS_MAX_LIMIT + 5; + for n in 1..=total { + tester.invite_to_family(f.id, n); + } + + let env = tester.env(); + let res = query_all_pending_invitations_paged(tester.deps(), env, None, Some(u32::MAX)) + .unwrap(); + assert_eq!( + res.invitations.len(), + retrieval_limits::PENDING_INVITATIONS_MAX_LIMIT as usize + ); + } + } + + #[cfg(test)] + mod past_invitations_for_family_paged { + use super::*; + use nym_node_families_contract_common::FamilyInvitationStatus; + + #[test] + fn empty_when_family_has_no_archive_entries() { + let mut tester = init_contract_tester(); + let f = tester.add_dummy_family(); + + let res = + query_past_invitations_for_family_paged(tester.deps(), f.id, None, None).unwrap(); + assert_eq!(res.family_id, f.id); + assert!(res.invitations.is_empty()); + assert!(res.start_next_after.is_none()); + } + + #[test] + fn empty_for_unknown_family_id() { + let tester = init_contract_tester(); + + let res = + query_past_invitations_for_family_paged(tester.deps(), 99, None, None).unwrap(); + assert_eq!(res.family_id, 99); + assert!(res.invitations.is_empty()); + } + + #[test] + fn returns_only_archived_invitations_from_requested_family() { + let mut tester = init_contract_tester(); + let f1 = tester.add_dummy_family(); + let f2 = tester.add_dummy_family(); + // produce one Accepted in each family, plus one Rejected in f1 + tester.add_to_family(f1.id, 10); + tester.invite_to_family(f1.id, 11); + tester.reject_invitation(f1.id, 11); + tester.add_to_family(f2.id, 20); + + let res = + query_past_invitations_for_family_paged(tester.deps(), f1.id, None, None).unwrap(); + assert_eq!(res.invitations.len(), 2); + for entry in &res.invitations { + assert_eq!(entry.invitation.family_id, f1.id); + } + } + + #[test] + fn covers_all_terminal_statuses() { + let mut tester = init_contract_tester(); + let f = tester.add_dummy_family(); + + // Accepted + tester.add_to_family(f.id, 10); + // Rejected + tester.invite_to_family(f.id, 11); + tester.reject_invitation(f.id, 11); + // Revoked + tester.invite_to_family(f.id, 12); + tester.revoke_invitation(f.id, 12); + + let res = + query_past_invitations_for_family_paged(tester.deps(), f.id, None, None).unwrap(); + let by_node: std::collections::HashMap<_, _> = res + .invitations + .iter() + .map(|p| (p.invitation.node_id, p.status.clone())) + .collect(); + assert!(matches!( + by_node[&10], + FamilyInvitationStatus::Accepted { .. } + )); + assert!(matches!( + by_node[&11], + FamilyInvitationStatus::Rejected { .. } + )); + assert!(matches!( + by_node[&12], + FamilyInvitationStatus::Revoked { .. } + )); + } + + #[test] + fn ordered_by_node_id_then_counter() { + let mut tester = init_contract_tester(); + let f = tester.add_dummy_family(); + + // node 42 joins and leaves twice — produces two Accepted entries with counters 0 and 1 + for _ in 0..2 { + tester.add_to_family(f.id, 42); + tester.remove_from_family(42); + } + // node 7 has one Accepted entry + tester.add_to_family(f.id, 7); + + let res = + query_past_invitations_for_family_paged(tester.deps(), f.id, None, None).unwrap(); + let pairs: Vec<_> = res + .invitations + .iter() + .map(|p| p.invitation.node_id) + .collect(); + // 7 comes before 42; 42's two entries come together (both with counter 0 and 1) + assert_eq!(pairs, vec![7, 42, 42]); + } + + #[test] + fn paginates_with_node_counter_cursor() { + let mut tester = init_contract_tester(); + let f = tester.add_dummy_family(); + for n in [10, 11, 12] { + tester.add_to_family(f.id, n); + } + + let p1 = query_past_invitations_for_family_paged(tester.deps(), f.id, None, Some(2)) + .unwrap(); + let ids: Vec<_> = p1 + .invitations + .iter() + .map(|p| p.invitation.node_id) + .collect(); + assert_eq!(ids, vec![10, 11]); + assert_eq!(p1.start_next_after, Some((11, 0))); + + let p2 = query_past_invitations_for_family_paged( + tester.deps(), + f.id, + p1.start_next_after, + Some(2), + ) + .unwrap(); + let ids: Vec<_> = p2 + .invitations + .iter() + .map(|p| p.invitation.node_id) + .collect(); + assert_eq!(ids, vec![12]); + assert_eq!(p2.start_next_after, Some((12, 0))); + + let p3 = query_past_invitations_for_family_paged( + tester.deps(), + f.id, + p2.start_next_after, + Some(2), + ) + .unwrap(); + assert!(p3.invitations.is_empty()); + assert!(p3.start_next_after.is_none()); + } + + #[test] + fn limit_is_clamped_to_max() { + let mut tester = init_contract_tester(); + let f = tester.add_dummy_family(); + let total = retrieval_limits::PAST_INVITATIONS_MAX_LIMIT + 5; + tester.add_n_family_members(f.id, total); + + let res = + query_past_invitations_for_family_paged(tester.deps(), f.id, None, Some(u32::MAX)) + .unwrap(); + assert_eq!( + res.invitations.len(), + retrieval_limits::PAST_INVITATIONS_MAX_LIMIT as usize + ); + } + + #[test] + fn start_after_none_does_not_leak_earlier_families() { + let mut tester = init_contract_tester(); + let f1 = tester.add_dummy_family(); + let f2 = tester.add_dummy_family(); + tester.add_to_family(f1.id, 10); + tester.add_to_family(f1.id, 11); + + let res = + query_past_invitations_for_family_paged(tester.deps(), f2.id, None, None).unwrap(); + + assert_eq!(res.family_id, f2.id); + assert!( + res.invitations.is_empty(), + "expected no entries for f2 but got: {:?}", + res.invitations + .iter() + .map(|e| (e.invitation.family_id, e.invitation.node_id)) + .collect::>() + ); + assert!(res.start_next_after.is_none()); + } + } + + #[cfg(test)] + mod all_past_invitations_paged { + use super::*; + + #[test] + fn empty_when_no_archive_entries() { + let tester = init_contract_tester(); + + let res = query_all_past_invitations_paged(tester.deps(), None, None).unwrap(); + assert!(res.invitations.is_empty()); + assert!(res.start_next_after.is_none()); + } + + #[test] + fn returns_archives_across_all_families() { + let mut tester = init_contract_tester(); + let f1 = tester.add_dummy_family(); + let f2 = tester.add_dummy_family(); + tester.add_to_family(f1.id, 10); + tester.add_to_family(f1.id, 20); + tester.add_to_family(f2.id, 5); + + let res = query_all_past_invitations_paged(tester.deps(), None, None).unwrap(); + let pairs: Vec<_> = res + .invitations + .iter() + .map(|p| (p.invitation.family_id, p.invitation.node_id)) + .collect(); + assert_eq!(pairs, vec![(f1.id, 10), (f1.id, 20), (f2.id, 5)]); + assert_eq!(res.start_next_after, Some(((f2.id, 5), 0))); + } + + #[test] + fn paginates_with_composite_cursor() { + let mut tester = init_contract_tester(); + let f1 = tester.add_dummy_family(); + let f2 = tester.add_dummy_family(); + tester.add_to_family(f1.id, 10); + tester.add_to_family(f1.id, 20); + tester.add_to_family(f2.id, 5); + tester.add_to_family(f2.id, 15); + + let p1 = query_all_past_invitations_paged(tester.deps(), None, Some(2)).unwrap(); + let pairs: Vec<_> = p1 + .invitations + .iter() + .map(|p| (p.invitation.family_id, p.invitation.node_id)) + .collect(); + assert_eq!(pairs, vec![(f1.id, 10), (f1.id, 20)]); + assert_eq!(p1.start_next_after, Some(((f1.id, 20), 0))); + + let p2 = query_all_past_invitations_paged(tester.deps(), p1.start_next_after, Some(2)) + .unwrap(); + let pairs: Vec<_> = p2 + .invitations + .iter() + .map(|p| (p.invitation.family_id, p.invitation.node_id)) + .collect(); + assert_eq!(pairs, vec![(f2.id, 5), (f2.id, 15)]); + + let p3 = query_all_past_invitations_paged(tester.deps(), p2.start_next_after, Some(2)) + .unwrap(); + assert!(p3.invitations.is_empty()); + assert!(p3.start_next_after.is_none()); + } + + #[test] + fn per_pair_counter_disambiguates_repeat_archive_entries() { + let mut tester = init_contract_tester(); + let f = tester.add_dummy_family(); + // node 42 joins and leaves twice — two Accepted entries for (f, 42) with counters 0 and 1 + for _ in 0..2 { + tester.add_to_family(f.id, 42); + tester.remove_from_family(42); + } + + let res = query_all_past_invitations_paged(tester.deps(), None, None).unwrap(); + assert_eq!(res.invitations.len(), 2); + assert_eq!(res.start_next_after, Some(((f.id, 42), 1))); + } + + #[test] + fn limit_is_clamped_to_max() { + let mut tester = init_contract_tester(); + let f = tester.add_dummy_family(); + let total = retrieval_limits::PAST_INVITATIONS_MAX_LIMIT + 5; + tester.add_n_family_members(f.id, total); + + let res = + query_all_past_invitations_paged(tester.deps(), None, Some(u32::MAX)).unwrap(); + assert_eq!( + res.invitations.len(), + retrieval_limits::PAST_INVITATIONS_MAX_LIMIT as usize + ); + } + } + + #[cfg(test)] + mod pending_invitations_for_node_paged { + use super::*; + + #[test] + fn empty_when_node_has_no_pending_invitations() { + let tester = init_contract_tester(); + let env = tester.env(); + + let res = query_pending_invitations_for_node_paged(tester.deps(), env, 42, None, None) + .unwrap(); + assert_eq!(res.node_id, 42); + assert!(res.invitations.is_empty()); + assert!(res.start_next_after.is_none()); + } + + #[test] + fn returns_only_invitations_for_requested_node() { + let mut tester = init_contract_tester(); + let f1 = tester.add_dummy_family(); + let f2 = tester.add_dummy_family(); + tester.invite_to_family(f1.id, 10); + tester.invite_to_family(f1.id, 11); + tester.invite_to_family(f2.id, 10); + + let env = tester.env(); + let res = query_pending_invitations_for_node_paged(tester.deps(), env, 10, None, None) + .unwrap(); + let pairs: Vec<_> = res + .invitations + .iter() + .map(|d| (d.invitation.family_id, d.invitation.node_id)) + .collect(); + assert_eq!(pairs, vec![(f1.id, 10), (f2.id, 10)]); + } + + #[test] + fn ordered_by_ascending_family_id() { + let mut tester = init_contract_tester(); + let f1 = tester.add_dummy_family(); + let f2 = tester.add_dummy_family(); + let f3 = tester.add_dummy_family(); + // out-of-order inserts + tester.invite_to_family(f3.id, 7); + tester.invite_to_family(f1.id, 7); + tester.invite_to_family(f2.id, 7); + + let env = tester.env(); + let res = query_pending_invitations_for_node_paged(tester.deps(), env, 7, None, None) + .unwrap(); + let ids: Vec<_> = res + .invitations + .iter() + .map(|d| d.invitation.family_id) + .collect(); + assert_eq!(ids, vec![f1.id, f2.id, f3.id]); + } + + #[test] + fn paginates_with_family_id_cursor() { + let mut tester = init_contract_tester(); + let f1 = tester.add_dummy_family(); + let f2 = tester.add_dummy_family(); + let f3 = tester.add_dummy_family(); + tester.invite_to_family(f1.id, 7); + tester.invite_to_family(f2.id, 7); + tester.invite_to_family(f3.id, 7); + + let env = tester.env(); + let p1 = query_pending_invitations_for_node_paged( + tester.deps(), + env.clone(), + 7, + None, + Some(2), + ) + .unwrap(); + let ids: Vec<_> = p1 + .invitations + .iter() + .map(|d| d.invitation.family_id) + .collect(); + assert_eq!(ids, vec![f1.id, f2.id]); + assert_eq!(p1.start_next_after, Some(f2.id)); + + let p2 = query_pending_invitations_for_node_paged( + tester.deps(), + env.clone(), + 7, + p1.start_next_after, + Some(2), + ) + .unwrap(); + let ids: Vec<_> = p2 + .invitations + .iter() + .map(|d| d.invitation.family_id) + .collect(); + assert_eq!(ids, vec![f3.id]); + assert_eq!(p2.start_next_after, Some(f3.id)); + + let p3 = query_pending_invitations_for_node_paged( + tester.deps(), + env, + 7, + p2.start_next_after, + Some(2), + ) + .unwrap(); + assert!(p3.invitations.is_empty()); + assert!(p3.start_next_after.is_none()); + } + + #[test] + fn flags_expired_against_current_block_time() { + let mut tester = init_contract_tester(); + let f1 = tester.add_dummy_family(); + let f2 = tester.add_dummy_family(); + let now = tester.env().block.time.seconds(); + tester.invite_to_family_with_expiration(f1.id, 7, now + 5); + tester.invite_to_family_with_expiration(f2.id, 7, now + 1000); + + tester.advance_time_by(60); + let env = tester.env(); + let res = query_pending_invitations_for_node_paged(tester.deps(), env, 7, None, None) + .unwrap(); + let by_family: std::collections::HashMap<_, _> = res + .invitations + .iter() + .map(|d| (d.invitation.family_id, d.expired)) + .collect(); + assert!(by_family[&f1.id]); + assert!(!by_family[&f2.id]); + } + + #[test] + fn limit_is_clamped_to_max() { + let mut tester = init_contract_tester(); + let total = retrieval_limits::PENDING_INVITATIONS_MAX_LIMIT + 5; + for _ in 0..total { + let f = tester.add_dummy_family(); + tester.invite_to_family(f.id, 7); + } + + let env = tester.env(); + let res = query_pending_invitations_for_node_paged( + tester.deps(), + env, + 7, + None, + Some(u32::MAX), + ) + .unwrap(); + assert_eq!( + res.invitations.len(), + retrieval_limits::PENDING_INVITATIONS_MAX_LIMIT as usize + ); + } + } + + #[cfg(test)] + mod past_invitations_for_node_paged { + use super::*; + use nym_node_families_contract_common::FamilyInvitationStatus; + + #[test] + fn empty_when_node_has_no_archive_entries() { + let tester = init_contract_tester(); + + let res = query_past_invitations_for_node_paged(tester.deps(), 42, None, None).unwrap(); + assert_eq!(res.node_id, 42); + assert!(res.invitations.is_empty()); + assert!(res.start_next_after.is_none()); + } + + #[test] + fn returns_only_archives_for_requested_node() { + let mut tester = init_contract_tester(); + let f1 = tester.add_dummy_family(); + let f2 = tester.add_dummy_family(); + tester.add_to_family(f1.id, 7); + tester.add_to_family(f1.id, 8); + tester.add_to_family(f2.id, 7); + + let res = query_past_invitations_for_node_paged(tester.deps(), 7, None, None).unwrap(); + assert_eq!(res.invitations.len(), 2); + for entry in &res.invitations { + assert_eq!(entry.invitation.node_id, 7); + } + } + + #[test] + fn covers_all_terminal_statuses() { + let mut tester = init_contract_tester(); + let f1 = tester.add_dummy_family(); + let f2 = tester.add_dummy_family(); + let f3 = tester.add_dummy_family(); + // Accepted in f1 + tester.add_to_family(f1.id, 7); + // Rejected in f2 + tester.invite_to_family(f2.id, 7); + tester.reject_invitation(f2.id, 7); + // Revoked in f3 + tester.invite_to_family(f3.id, 7); + tester.revoke_invitation(f3.id, 7); + + let res = query_past_invitations_for_node_paged(tester.deps(), 7, None, None).unwrap(); + let by_family: std::collections::HashMap<_, _> = res + .invitations + .iter() + .map(|p| (p.invitation.family_id, p.status.clone())) + .collect(); + assert!(matches!( + by_family[&f1.id], + FamilyInvitationStatus::Accepted { .. } + )); + assert!(matches!( + by_family[&f2.id], + FamilyInvitationStatus::Rejected { .. } + )); + assert!(matches!( + by_family[&f3.id], + FamilyInvitationStatus::Revoked { .. } + )); + } + + #[test] + fn ordered_by_family_id_then_counter() { + let mut tester = init_contract_tester(); + let f1 = tester.add_dummy_family(); + let f2 = tester.add_dummy_family(); + // node 7 joins and leaves f1 twice — counters 0 and 1 in f1 + for _ in 0..2 { + tester.add_to_family(f1.id, 7); + tester.remove_from_family(7); + } + // one Accepted in f2 + tester.add_to_family(f2.id, 7); + + let res = query_past_invitations_for_node_paged(tester.deps(), 7, None, None).unwrap(); + let pairs: Vec<_> = res + .invitations + .iter() + .map(|p| p.invitation.family_id) + .collect(); + assert_eq!(pairs, vec![f1.id, f1.id, f2.id]); + } + + #[test] + fn paginates_with_family_counter_cursor() { + let mut tester = init_contract_tester(); + let f1 = tester.add_dummy_family(); + let f2 = tester.add_dummy_family(); + let f3 = tester.add_dummy_family(); + tester.add_to_family(f1.id, 7); + tester.add_to_family(f2.id, 7); + tester.add_to_family(f3.id, 7); + + let p1 = + query_past_invitations_for_node_paged(tester.deps(), 7, None, Some(2)).unwrap(); + let ids: Vec<_> = p1 + .invitations + .iter() + .map(|p| p.invitation.family_id) + .collect(); + assert_eq!(ids, vec![f1.id, f2.id]); + assert_eq!(p1.start_next_after, Some((f2.id, 0))); + + let p2 = query_past_invitations_for_node_paged( + tester.deps(), + 7, + p1.start_next_after, + Some(2), + ) + .unwrap(); + let ids: Vec<_> = p2 + .invitations + .iter() + .map(|p| p.invitation.family_id) + .collect(); + assert_eq!(ids, vec![f3.id]); + assert_eq!(p2.start_next_after, Some((f3.id, 0))); + + let p3 = query_past_invitations_for_node_paged( + tester.deps(), + 7, + p2.start_next_after, + Some(2), + ) + .unwrap(); + assert!(p3.invitations.is_empty()); + assert!(p3.start_next_after.is_none()); + } + + #[test] + fn limit_is_clamped_to_max() { + let mut tester = init_contract_tester(); + let total = retrieval_limits::PAST_INVITATIONS_MAX_LIMIT + 5; + for _ in 0..total { + let f = tester.add_dummy_family(); + tester.add_to_family(f.id, 7); + tester.remove_from_family(7); + } + + let res = query_past_invitations_for_node_paged(tester.deps(), 7, None, Some(u32::MAX)) + .unwrap(); + assert_eq!( + res.invitations.len(), + retrieval_limits::PAST_INVITATIONS_MAX_LIMIT as usize + ); + } + } + + #[cfg(test)] + mod past_members_for_family_paged { + use super::*; + + #[test] + fn empty_when_no_archive_entries() { + let mut tester = init_contract_tester(); + let f = tester.add_dummy_family(); + + let res = query_past_members_for_family_paged(tester.deps(), f.id, None, None).unwrap(); + assert_eq!(res.family_id, f.id); + assert!(res.members.is_empty()); + assert!(res.start_next_after.is_none()); + } + + #[test] + fn empty_for_unknown_family_id() { + let tester = init_contract_tester(); + + let res = query_past_members_for_family_paged(tester.deps(), 99, None, None).unwrap(); + assert_eq!(res.family_id, 99); + assert!(res.members.is_empty()); + assert!(res.start_next_after.is_none()); + } + + #[test] + fn returns_only_archives_from_requested_family() { + let mut tester = init_contract_tester(); + let f1 = tester.add_dummy_family(); + let f2 = tester.add_dummy_family(); + tester.add_to_family(f1.id, 10); + tester.remove_from_family(10); + tester.add_to_family(f2.id, 20); + tester.remove_from_family(20); + + let res = + query_past_members_for_family_paged(tester.deps(), f1.id, None, None).unwrap(); + assert_eq!(res.members.len(), 1); + assert_eq!(res.members[0].family_id, f1.id); + assert_eq!(res.members[0].node_id, 10); + } + + #[test] + fn record_carries_removed_at_timestamp() { + let mut tester = init_contract_tester(); + let f = tester.add_dummy_family(); + tester.add_to_family(f.id, 42); + let removed_at = tester.env().block.time.seconds(); + tester.remove_from_family(42); + + let res = query_past_members_for_family_paged(tester.deps(), f.id, None, None).unwrap(); + let entry = res.members.into_iter().next().unwrap(); + assert_eq!(entry.family_id, f.id); + assert_eq!(entry.node_id, 42); + assert_eq!(entry.removed_at, removed_at); + } + + #[test] + fn ordered_by_node_id_then_counter() { + let mut tester = init_contract_tester(); + let f = tester.add_dummy_family(); + // node 42 joins/leaves twice — counters 0 and 1 + for _ in 0..2 { + tester.add_to_family(f.id, 42); + tester.remove_from_family(42); + } + // node 7 once + tester.add_to_family(f.id, 7); + tester.remove_from_family(7); + + let res = query_past_members_for_family_paged(tester.deps(), f.id, None, None).unwrap(); + let ids: Vec<_> = res.members.iter().map(|m| m.node_id).collect(); + // 7 comes before 42; 42 appears twice + assert_eq!(ids, vec![7, 42, 42]); + } + + #[test] + fn paginates_with_node_counter_cursor() { + let mut tester = init_contract_tester(); + let f = tester.add_dummy_family(); + for n in [10, 11, 12] { + tester.add_to_family(f.id, n); + tester.remove_from_family(n); + } + + let p1 = + query_past_members_for_family_paged(tester.deps(), f.id, None, Some(2)).unwrap(); + let ids: Vec<_> = p1.members.iter().map(|m| m.node_id).collect(); + assert_eq!(ids, vec![10, 11]); + assert_eq!(p1.start_next_after, Some((11, 0))); + + let p2 = query_past_members_for_family_paged( + tester.deps(), + f.id, + p1.start_next_after, + Some(2), + ) + .unwrap(); + let ids: Vec<_> = p2.members.iter().map(|m| m.node_id).collect(); + assert_eq!(ids, vec![12]); + assert_eq!(p2.start_next_after, Some((12, 0))); + + let p3 = query_past_members_for_family_paged( + tester.deps(), + f.id, + p2.start_next_after, + Some(2), + ) + .unwrap(); + assert!(p3.members.is_empty()); + assert!(p3.start_next_after.is_none()); + } + + #[test] + fn limit_is_clamped_to_max() { + let mut tester = init_contract_tester(); + let f = tester.add_dummy_family(); + let total = retrieval_limits::PAST_MEMBERS_MAX_LIMIT + 5; + for n in 1..=total { + tester.add_to_family(f.id, n); + tester.remove_from_family(n); + } + + let res = + query_past_members_for_family_paged(tester.deps(), f.id, None, Some(u32::MAX)) + .unwrap(); + assert_eq!( + res.members.len(), + retrieval_limits::PAST_MEMBERS_MAX_LIMIT as usize + ); + } + + #[test] + fn start_after_none_does_not_leak_earlier_families() { + let mut tester = init_contract_tester(); + let f1 = tester.add_dummy_family(); + let f2 = tester.add_dummy_family(); + tester.add_to_family(f1.id, 10); + tester.add_to_family(f1.id, 11); + tester.remove_from_family(10); + tester.remove_from_family(11); + + let res = + query_past_members_for_family_paged(tester.deps(), f2.id, None, None).unwrap(); + + assert_eq!(res.family_id, f2.id); + assert!( + res.members.is_empty(), + "expected no entries for f2 but got: {:?}", + res.members + .iter() + .map(|m| (m.family_id, m.node_id)) + .collect::>() + ); + assert!(res.start_next_after.is_none()); + } + } + + #[cfg(test)] + mod past_members_for_node_paged { + use super::*; + + #[test] + fn empty_when_node_has_no_archive_entries() { + let tester = init_contract_tester(); + + let res = query_past_members_for_node_paged(tester.deps(), 42, None, None).unwrap(); + assert_eq!(res.node_id, 42); + assert!(res.members.is_empty()); + assert!(res.start_next_after.is_none()); + } + + #[test] + fn returns_only_archives_for_requested_node() { + let mut tester = init_contract_tester(); + let f1 = tester.add_dummy_family(); + let f2 = tester.add_dummy_family(); + tester.add_to_family(f1.id, 7); + tester.add_to_family(f1.id, 8); + tester.add_to_family(f2.id, 7); + tester.remove_from_family(7); + tester.remove_from_family(8); + + let res = query_past_members_for_node_paged(tester.deps(), 7, None, None).unwrap(); + assert_eq!(res.members.len(), 1); + for entry in &res.members { + assert_eq!(entry.node_id, 7); + } + } + + #[test] + fn record_carries_removed_at_timestamp() { + let mut tester = init_contract_tester(); + let f = tester.add_dummy_family(); + tester.add_to_family(f.id, 42); + let removed_at = tester.env().block.time.seconds(); + tester.remove_from_family(42); + + let res = query_past_members_for_node_paged(tester.deps(), 42, None, None).unwrap(); + let entry = res.members.into_iter().next().unwrap(); + assert_eq!(entry.family_id, f.id); + assert_eq!(entry.node_id, 42); + assert_eq!(entry.removed_at, removed_at); + } + + #[test] + fn ordered_by_family_id_then_counter() { + let mut tester = init_contract_tester(); + let f1 = tester.add_dummy_family(); + let f2 = tester.add_dummy_family(); + // node 7 joins/leaves f1 twice — counters 0 and 1 in f1 + for _ in 0..2 { + tester.add_to_family(f1.id, 7); + tester.remove_from_family(7); + } + // one record in f2 + tester.add_to_family(f2.id, 7); + tester.remove_from_family(7); + + let res = query_past_members_for_node_paged(tester.deps(), 7, None, None).unwrap(); + let ids: Vec<_> = res.members.iter().map(|m| m.family_id).collect(); + assert_eq!(ids, vec![f1.id, f1.id, f2.id]); + } + + #[test] + fn paginates_with_family_counter_cursor() { + let mut tester = init_contract_tester(); + let f1 = tester.add_dummy_family(); + let f2 = tester.add_dummy_family(); + let f3 = tester.add_dummy_family(); + tester.add_to_family(f1.id, 7); + tester.remove_from_family(7); + tester.add_to_family(f2.id, 7); + tester.remove_from_family(7); + tester.add_to_family(f3.id, 7); + tester.remove_from_family(7); + + let p1 = query_past_members_for_node_paged(tester.deps(), 7, None, Some(2)).unwrap(); + let ids: Vec<_> = p1.members.iter().map(|m| m.family_id).collect(); + assert_eq!(ids, vec![f1.id, f2.id]); + assert_eq!(p1.start_next_after, Some((f2.id, 0))); + + let p2 = + query_past_members_for_node_paged(tester.deps(), 7, p1.start_next_after, Some(2)) + .unwrap(); + let ids: Vec<_> = p2.members.iter().map(|m| m.family_id).collect(); + assert_eq!(ids, vec![f3.id]); + assert_eq!(p2.start_next_after, Some((f3.id, 0))); + + let p3 = + query_past_members_for_node_paged(tester.deps(), 7, p2.start_next_after, Some(2)) + .unwrap(); + assert!(p3.members.is_empty()); + assert!(p3.start_next_after.is_none()); + } + + #[test] + fn limit_is_clamped_to_max() { + let mut tester = init_contract_tester(); + let total = retrieval_limits::PAST_MEMBERS_MAX_LIMIT + 5; + for _ in 0..total { + let f = tester.add_dummy_family(); + tester.add_to_family(f.id, 7); + tester.remove_from_family(7); + } + + let res = + query_past_members_for_node_paged(tester.deps(), 7, None, Some(u32::MAX)).unwrap(); + assert_eq!( + res.members.len(), + retrieval_limits::PAST_MEMBERS_MAX_LIMIT as usize + ); + } + } +} diff --git a/contracts/node-families/src/queued_migrations.rs b/contracts/node-families/src/queued_migrations.rs new file mode 100644 index 0000000000..bdc05451cb --- /dev/null +++ b/contracts/node-families/src/queued_migrations.rs @@ -0,0 +1,2 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only diff --git a/contracts/node-families/src/storage/mod.rs b/contracts/node-families/src/storage/mod.rs new file mode 100644 index 0000000000..62ae93c82d --- /dev/null +++ b/contracts/node-families/src/storage/mod.rs @@ -0,0 +1,1386 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +// storage will be used in subsequent PRs/tickets +#![allow(dead_code)] + +use crate::storage::storage_indexes::{ + FamilyMembersIndex, NodeFamiliesIndex, NodeFamilyInvitationIndex, PastFamilyInvitationsIndex, + PastFamilyMembersIndex, +}; +use cosmwasm_std::{Addr, Coin, DepsMut, Env, Order, StdResult, Storage}; +use cw_controllers::Admin; +use cw_storage_plus::{IndexedMap, Item, Map}; +use nym_mixnet_contract_common::NodeId; +use nym_node_families_contract_common::constants::storage_keys; +use nym_node_families_contract_common::{ + Config, FamilyInvitation, FamilyInvitationStatus, FamilyMembership, NodeFamiliesContractError, + NodeFamily, NodeFamilyId, PastFamilyInvitation, PastFamilyMember, +}; + +pub(crate) mod retrieval_limits; +mod storage_indexes; + +/// Composite primary key for the invitation / past-member maps: +/// `(family id, node id)`. Only one pending invitation can exist for a given +/// `(family, node)` pair at a time. +pub(crate) type FamilyMember = (NodeFamilyId, NodeId); + +/// Container for every storage handle used by the contract. +/// +/// Constructed once via [`NodeFamiliesStorage::new`] and accessed through a +/// `lazy_static`-style singleton in the entry point modules. +pub struct NodeFamiliesStorage<'a> { + /// Admin of the contract; gates privileged operations. + pub(crate) contract_admin: Admin, + + /// Runtime configuration (fees, length limits) persisted at instantiation + /// and consulted by transaction handlers. + pub(crate) config: Item, + + /// Address of the mixnet contract; used to verify a node id refers to a + /// real, registered node. + pub(crate) mixnet_contract_address: Item, + + /// Monotonically increasing id assigned to every newly created family. + /// Ids start at `1` (see [`NodeFamiliesStorage::next_family_id`]); `0` is + /// reserved as a "no family" sentinel. + pub(crate) node_family_id_counter: Item, + + /// All existing families, keyed by id, with unique secondary indexes on + /// `owner` (one-family-per-owner-address) and on `normalised_name` + /// (family names are globally unique under their normalised form, so + /// `"MyFamily"` and `"myfamily"` collide while the original `name` field + /// preserves the user-submitted formatting). + pub(crate) families: IndexedMap>, + + /// Current family membership records, keyed by [`NodeId`]. A node + /// belongs to at most one family at a time, so the PK is the node id. + /// A `family` multi-index enables paginated listing of all nodes + /// belonging to a given family. + pub(crate) family_members: IndexedMap>, + + /// Currently outstanding family invitations, indexed by both family id + /// and node id (a single node can simultaneously hold invitations from + /// multiple families). + pub(crate) pending_family_invitations: + IndexedMap>, + + // ##### historical data ##### + // + // The two maps below archive terminal events. The trailing `u64` in the + // composite key is a per-`(family, node)` counter — a node can be removed + // from (or rejected by) the same family more than once, and we cannot use + // the block timestamp to disambiguate because multiple txs may share a + // block. + /// Archive of family memberships that have ended (kicked, left, or family + /// disbanded). Key: `((family_id, node_id), counter)`. + pub(crate) past_family_members: + IndexedMap<(FamilyMember, u64), PastFamilyMember, PastFamilyMembersIndex<'a>>, + + /// Per-`(family, node)` counter for the [`Self::past_family_members`] + /// archive — yields the next free `counter` slot when archiving a new + /// past-membership record. Stored explicitly (rather than derived via + /// range scan) to keep archival writes O(1). + pub(crate) past_family_member_counter: Map, + + /// Archive of invitations that reached a terminal `Accepted` / `Rejected` + /// / `Revoked` state. Timed-out invitations are **not** archived here — + /// there is no background process that sweeps expired entries out of + /// [`Self::pending_family_invitations`]. + pub(crate) past_family_invitations: + IndexedMap<(FamilyMember, u64), PastFamilyInvitation, PastFamilyInvitationsIndex<'a>>, + + /// Per-`(family, node)` counter for the [`Self::past_family_invitations`] + /// archive — yields the next free `counter` slot when archiving a + /// terminal invitation event. + pub(crate) past_family_invitation_counter: Map, +} + +impl NodeFamiliesStorage<'_> { + #[allow(clippy::new_without_default)] + pub fn new() -> Self { + NodeFamiliesStorage { + contract_admin: Admin::new(storage_keys::CONTRACT_ADMIN), + config: Item::new(storage_keys::CONFIG), + mixnet_contract_address: Item::new(storage_keys::MIXNET_CONTRACT_ADDRESS), + node_family_id_counter: Item::new(storage_keys::NODE_FAMILY_ID_COUNTER), + families: IndexedMap::new(storage_keys::FAMILIES_NAMESPACE, NodeFamiliesIndex::new()), + family_members: IndexedMap::new( + storage_keys::NODE_FAMILY_MEMBERS, + FamilyMembersIndex::new(), + ), + pending_family_invitations: IndexedMap::new( + storage_keys::INVITATIONS_NAMESPACE, + NodeFamilyInvitationIndex::new(), + ), + past_family_members: IndexedMap::new( + storage_keys::PAST_FAMILY_MEMBER_NAMESPACE, + PastFamilyMembersIndex::new(), + ), + past_family_member_counter: Map::new( + storage_keys::PAST_FAMILY_MEMBER_COUNTER_NAMESPACE, + ), + past_family_invitations: IndexedMap::new( + storage_keys::PAST_INVITATIONS_NAMESPACE, + PastFamilyInvitationsIndex::new(), + ), + past_family_invitation_counter: Map::new( + storage_keys::PAST_INVITATIONS_COUNTER_NAMESPACE, + ), + } + } + + /// One-time storage initialisation called from the contract's `instantiate` + /// entry point. Persists the runtime [`Config`] and mixnet contract + /// address, and sets `sender` as the contract admin. + pub(crate) fn initialise( + &self, + deps: DepsMut, + sender: Addr, + mixnet_contract_address: Addr, + config: Config, + ) -> Result<(), NodeFamiliesContractError> { + self.config.save(deps.storage, &config)?; + self.mixnet_contract_address + .save(deps.storage, &mixnet_contract_address)?; + + self.contract_admin.set(deps, Some(sender))?; + Ok(()) + } + + /// Allocate the next [`NodeFamilyId`] and persist the bumped counter. + /// + /// Ids are issued starting from `1`; `0` is reserved as a "no family" + /// sentinel value and must never be assigned to a real family. + pub(crate) fn next_family_id( + &self, + store: &mut dyn Storage, + ) -> Result { + let next_id = self + .node_family_id_counter + .may_load(store)? + .unwrap_or_default() + + 1; + self.node_family_id_counter.save(store, &next_id)?; + Ok(next_id) + } + + /// Allocate the next free archive slot for the [`Self::past_family_invitations`] + /// map under the given `(family, node)` key, and persist the bumped counter. + /// + /// Slots are issued starting from `0` and increase by 1 on every call. + pub(crate) fn next_past_invitation_counter( + &self, + store: &mut dyn Storage, + key: FamilyMember, + ) -> Result { + let counter = self + .past_family_invitation_counter + .may_load(store, key)? + .unwrap_or_default(); + self.past_family_invitation_counter + .save(store, key, &(counter + 1))?; + Ok(counter) + } + + /// Allocate the next free archive slot for the [`Self::past_family_members`] + /// map under the given `(family, node)` key, and persist the bumped counter. + /// + /// Slots are issued starting from `0` and increase by 1 on every call. + pub(crate) fn next_past_member_counter( + &self, + store: &mut dyn Storage, + key: FamilyMember, + ) -> Result { + let counter = self + .past_family_member_counter + .may_load(store, key)? + .unwrap_or_default(); + self.past_family_member_counter + .save(store, key, &(counter + 1))?; + Ok(counter) + } + + /// Persist a brand-new family in storage. + /// + /// Assigns a fresh [`NodeFamilyId`], stamps `created_at` from `env` + /// (unix seconds) and starts the membership counter at `0` — the owner + /// is **not** counted as a member. + /// + /// The caller (a transaction handler) is responsible for: + /// - validating `name`, `description` and `owner`; + /// - computing `normalised_name` from `name` (e.g. via + /// [`crate::helpers::normalise_family_name`]) — the unique-name index + /// keys on this field, so it is what enforces global uniqueness; + /// - ensuring `owner` does not already own a family **and** is not + /// currently a member of one. + /// + /// Returns the freshly persisted [`NodeFamily`]. The underlying + /// `IndexedMap` enforces the one-family-per-owner and unique-name + /// invariants via unique indexes on `owner` and `normalised_name` as a + /// defence-in-depth check, so this call will fail if either is already + /// taken — but the caller must not rely on it for the membership check. + #[allow(clippy::too_many_arguments)] + pub(crate) fn register_new_family( + &self, + store: &mut dyn Storage, + env: &Env, + fee: Coin, + owner: Addr, + name: String, + normalised_name: String, + description: String, + ) -> Result { + let id = self.next_family_id(store)?; + let family = NodeFamily { + id, + name, + normalised_name, + description, + owner, + paid_fee: fee, + members: 0, + created_at: env.block.time.seconds(), + }; + self.families.save(store, id, &family)?; + Ok(family) + } + + /// Persist a new pending invitation for `node_id` to join `family_id`. + /// + /// `expires_at` is taken as a unix-seconds absolute deadline (the caller + /// is expected to compute it from the current block time plus the + /// configured invitation duration). + /// + /// The caller (a transaction handler) is responsible for: + /// - verifying that `family_id` exists and that the transaction sender + /// is its owner; + /// - verifying that `node_id` refers to a real, registered node; + /// - ensuring `node_id` is not already a member of any family; + /// - ensuring `expires_at` is strictly in the future. + /// + /// As defence-in-depth, this method errors with [`FamilyNotFound`] if + /// `family_id` is unknown and with [`PendingInvitationAlreadyExists`] if + /// a pending invitation for the same `(family, node)` pair is already + /// stored — the underlying `IndexedMap` would otherwise silently + /// overwrite it. + /// + /// Returns the freshly persisted [`FamilyInvitation`]. + /// + /// [`FamilyNotFound`]: NodeFamiliesContractError::FamilyNotFound + /// [`PendingInvitationAlreadyExists`]: NodeFamiliesContractError::PendingInvitationAlreadyExists + pub(crate) fn add_pending_invitation( + &self, + store: &mut dyn Storage, + family_id: NodeFamilyId, + node_id: NodeId, + expires_at: u64, + ) -> Result { + let key: FamilyMember = (family_id, node_id); + + if !self.families.has(store, family_id) { + return Err(NodeFamiliesContractError::FamilyNotFound { family_id }); + } + + if self + .pending_family_invitations + .may_load(store, key)? + .is_some() + { + return Err(NodeFamiliesContractError::PendingInvitationAlreadyExists { + family_id, + node_id, + }); + } + + let invitation = FamilyInvitation { + family_id, + node_id, + expires_at, + }; + self.pending_family_invitations + .save(store, key, &invitation)?; + Ok(invitation) + } + + /// Accept a pending invitation for `node_id` to join `family_id`. + /// + /// Performs the full storage transition atomically: + /// 1. loads the pending invitation (errors with [`InvitationNotFound`] if + /// none exists for the given pair); + /// 2. verifies it has not expired (`now < expires_at`, errors with + /// [`InvitationExpired`] otherwise); + /// 3. removes it from the pending map; + /// 4. records `node_id -> family_id` in [`Self::family_members`]; + /// 5. increments the family's `members` counter (errors with + /// [`FamilyNotFound`] if the family has somehow been removed); + /// 6. archives the invitation in [`Self::past_family_invitations`] with + /// status [`FamilyInvitationStatus::Accepted`], using the next free + /// per-`(family, node)` counter. + /// + /// The caller is responsible for verifying that `node_id` is owned by + /// the transaction sender and is not already a member of any family. + /// + /// Returns the updated [`NodeFamily`] (with the bumped `members` count). + /// + /// [`InvitationNotFound`]: NodeFamiliesContractError::InvitationNotFound + /// [`InvitationExpired`]: NodeFamiliesContractError::InvitationExpired + /// [`FamilyNotFound`]: NodeFamiliesContractError::FamilyNotFound + pub(crate) fn accept_invitation( + &self, + store: &mut dyn Storage, + env: &Env, + family_id: NodeFamilyId, + node_id: NodeId, + ) -> Result { + let now = env.block.time.seconds(); + let key: FamilyMember = (family_id, node_id); + + let invitation = self + .pending_family_invitations + .may_load(store, key)? + .ok_or(NodeFamiliesContractError::InvitationNotFound { family_id, node_id })?; + + if now >= invitation.expires_at { + return Err(NodeFamiliesContractError::InvitationExpired { + family_id, + node_id, + expires_at: invitation.expires_at, + now, + }); + } + + self.pending_family_invitations.remove(store, key)?; + + self.family_members.save( + store, + node_id, + &FamilyMembership { + family_id, + joined_at: now, + }, + )?; + + let mut family = self + .families + .may_load(store, family_id)? + .ok_or(NodeFamiliesContractError::FamilyNotFound { family_id })?; + family.members += 1; + self.families.save(store, family_id, &family)?; + + let counter = self.next_past_invitation_counter(store, key)?; + self.past_family_invitations.save( + store, + (key, counter), + &PastFamilyInvitation { + invitation, + status: FamilyInvitationStatus::Accepted { at: now }, + }, + )?; + + Ok(family) + } + + /// Reject a pending invitation for `node_id` from `family_id`. + /// + /// Invitee-side counterpart to [`Self::revoke_pending_invitation`]: + /// removes the invitation from [`Self::pending_family_invitations`] and + /// archives it in [`Self::past_family_invitations`] with status + /// [`FamilyInvitationStatus::Rejected`], using the next free + /// per-`(family, node)` counter. Errors with [`InvitationNotFound`] if + /// no pending invitation exists for the given pair. + /// + /// Works regardless of whether the invitation has expired. + /// + /// The caller is responsible for verifying that the transaction sender + /// is the controller of `node_id`. + /// + /// Returns the rejected [`FamilyInvitation`]. + /// + /// [`InvitationNotFound`]: NodeFamiliesContractError::InvitationNotFound + pub(crate) fn reject_pending_invitation( + &self, + store: &mut dyn Storage, + env: &Env, + family_id: NodeFamilyId, + node_id: NodeId, + ) -> Result { + let now = env.block.time.seconds(); + let key: FamilyMember = (family_id, node_id); + + let invitation = self + .pending_family_invitations + .may_load(store, key)? + .ok_or(NodeFamiliesContractError::InvitationNotFound { family_id, node_id })?; + + self.pending_family_invitations.remove(store, key)?; + + let counter = self.next_past_invitation_counter(store, key)?; + self.past_family_invitations.save( + store, + (key, counter), + &PastFamilyInvitation { + invitation: invitation.clone(), + status: FamilyInvitationStatus::Rejected { at: now }, + }, + )?; + + Ok(invitation) + } + + /// Revoke a pending invitation for `node_id` from `family_id`. + /// + /// Removes the invitation from [`Self::pending_family_invitations`] and + /// archives it in [`Self::past_family_invitations`] with status + /// [`FamilyInvitationStatus::Revoked`], using the next free + /// per-`(family, node)` counter. Errors with [`InvitationNotFound`] if + /// no pending invitation exists for the given pair. + /// + /// Works regardless of whether the invitation has expired — this is the + /// only path that can clean expired entries out of the pending map, since + /// no background sweeper exists. + /// + /// The caller is responsible for verifying that the transaction sender + /// is the owner of `family_id`. + /// + /// Returns the revoked [`FamilyInvitation`]. + /// + /// [`InvitationNotFound`]: NodeFamiliesContractError::InvitationNotFound + pub(crate) fn revoke_pending_invitation( + &self, + store: &mut dyn Storage, + env: &Env, + family_id: NodeFamilyId, + node_id: NodeId, + ) -> Result { + let now = env.block.time.seconds(); + let key: FamilyMember = (family_id, node_id); + + let invitation = self + .pending_family_invitations + .may_load(store, key)? + .ok_or(NodeFamiliesContractError::InvitationNotFound { family_id, node_id })?; + + self.pending_family_invitations.remove(store, key)?; + + let counter = self.next_past_invitation_counter(store, key)?; + self.past_family_invitations.save( + store, + (key, counter), + &PastFamilyInvitation { + invitation: invitation.clone(), + status: FamilyInvitationStatus::Revoked { at: now }, + }, + )?; + + Ok(invitation) + } + + /// Remove `node_id` from whichever family it currently belongs to. + /// + /// Shared storage path for both routes that drop a member: + /// - **kick** — invoked by the family owner against another node; + /// - **leave** — invoked by the node's own controller. + /// + /// Looks up the node's family via [`Self::family_members`] (errors with + /// [`NodeNotInFamily`] if the node has no membership record), removes + /// the membership entry, decrements the family's `members` counter + /// (saturating at `0` as defence-in-depth — a underflow would indicate + /// an invariant break elsewhere), and archives a [`PastFamilyMember`] + /// record stamped with `removed_at = env.block.time.seconds()` using + /// the next per-`(family, node)` archive slot. + /// + /// The caller is responsible for verifying that the transaction sender + /// is authorised to remove this node — either as the family owner + /// (kick) or as the node's controller (leave). + /// + /// Returns the updated [`NodeFamily`] (with the decremented `members` + /// count). Errors with [`FamilyNotFound`] if the node's family has + /// somehow been removed. + /// + /// [`NodeNotInFamily`]: NodeFamiliesContractError::NodeNotInFamily + /// [`FamilyNotFound`]: NodeFamiliesContractError::FamilyNotFound + pub(crate) fn remove_family_member( + &self, + store: &mut dyn Storage, + env: &Env, + node_id: NodeId, + ) -> Result { + let now = env.block.time.seconds(); + + let family_id = self + .family_members + .may_load(store, node_id)? + .ok_or(NodeFamiliesContractError::NodeNotInFamily { node_id })? + .family_id; + + self.family_members.remove(store, node_id)?; + + let mut family = self + .families + .may_load(store, family_id)? + .ok_or(NodeFamiliesContractError::FamilyNotFound { family_id })?; + family.members = family.members.saturating_sub(1); + self.families.save(store, family_id, &family)?; + + let key: FamilyMember = (family_id, node_id); + let counter = self.next_past_member_counter(store, key)?; + self.past_family_members.save( + store, + (key, counter), + &PastFamilyMember { + family_id, + node_id, + removed_at: now, + }, + )?; + + Ok(family) + } + + /// Apply the family-side cleanup triggered when `node_id` initiates + /// unbonding from the mixnet contract. + /// + /// Idempotent over the membership half: drops the node's [`FamilyMembership`] + /// record (decrementing the family's `members` count and archiving a + /// [`PastFamilyMember`]) iff such a record exists, otherwise leaves the + /// state untouched. Then sweeps every pending invitation addressed to + /// `node_id` (iterating via [`NodeFamilyInvitationIndex::node`]), + /// removing each from the pending map and archiving it as + /// [`FamilyInvitationStatus::Rejected`] at `env.block.time` — the + /// auto-cleared invitations share the `Rejected` terminal state with + /// invitations the node controller would have explicitly declined. + /// + /// The caller is responsible for verifying that the transaction sender + /// is the configured mixnet contract address — there is no node-side + /// authority for this path and storage cannot tell the difference between + /// the legitimate callback and an arbitrary execute call. + /// + /// Returns the number of pending invitations that were swept (useful for + /// event attributes / telemetry); the membership half is observable via + /// the archived `PastFamilyMember`. + pub(crate) fn handle_node_unbonding( + &self, + store: &mut dyn Storage, + env: &Env, + node_id: NodeId, + ) -> Result { + if self.family_members.may_load(store, node_id)?.is_some() { + self.remove_family_member(store, env, node_id)?; + } + + let now = env.block.time.seconds(); + let pending: Vec<(FamilyMember, FamilyInvitation)> = self + .pending_family_invitations + .idx + .node + .prefix(node_id) + .range(store, None, None, Order::Ascending) + .collect::>>()?; + + let swept = pending.len() as u64; + for (key, invitation) in pending { + self.pending_family_invitations.remove(store, key)?; + let counter = self.next_past_invitation_counter(store, key)?; + self.past_family_invitations.save( + store, + (key, counter), + &PastFamilyInvitation { + invitation, + status: FamilyInvitationStatus::Rejected { at: now }, + }, + )?; + } + + Ok(swept) + } + + /// Disband (delete) `family_id`. + /// + /// Sweeps every still-pending invitation issued by the family + /// (iterating via the `family` multi-index over + /// [`Self::pending_family_invitations`]), removing each from the + /// pending map and archiving it as + /// [`FamilyInvitationStatus::Revoked`] at `env.block.time` — disbanding + /// the family is treated as the family withdrawing all of its + /// outstanding invitations. Gas cost therefore scales with the number + /// of leftover invitations; if that becomes a concern, the owner can + /// revoke them manually before disbanding. + /// + /// The caller (a transaction handler) is responsible for: + /// - verifying that the transaction sender is the owner of `family_id`; + /// - verifying that the family has zero current members (errors with + /// [`FamilyNotEmpty`] are raised at the transaction layer, not here) + /// — disbanding a family with members would otherwise leak orphaned + /// `FamilyMembership` records pointing at a removed family. + /// + /// Errors with [`FamilyNotFound`] if `family_id` does not exist. + /// Returns the disbanded [`NodeFamily`] (final snapshot) for use in + /// event attributes. + /// + /// [`FamilyNotEmpty`]: NodeFamiliesContractError::FamilyNotEmpty + /// [`FamilyNotFound`]: NodeFamiliesContractError::FamilyNotFound + pub(crate) fn disband_family( + &self, + store: &mut dyn Storage, + env: &Env, + family_id: NodeFamilyId, + ) -> Result { + let now = env.block.time.seconds(); + + let family = self + .families + .may_load(store, family_id)? + .ok_or(NodeFamiliesContractError::FamilyNotFound { family_id })?; + + // collect first, then mutate — iterating an IndexedMap while modifying it is unsafe + let pending: Vec<(FamilyMember, FamilyInvitation)> = self + .pending_family_invitations + .idx + .family + .prefix(family_id) + .range(store, None, None, Order::Ascending) + .collect::>>()?; + + for (key, invitation) in pending { + self.pending_family_invitations.remove(store, key)?; + let counter = self.next_past_invitation_counter(store, key)?; + self.past_family_invitations.save( + store, + (key, counter), + &PastFamilyInvitation { + invitation, + status: FamilyInvitationStatus::Revoked { at: now }, + }, + )?; + } + + self.families.remove(store, family_id)?; + + Ok(family) + } + + // helpers + + /// Look up the family owned by `owner` via the unique `owner` index. + /// + /// Returns `Ok(None)` if the address owns no family. The unique index + /// guarantees the lookup is `O(log n)` and that at most one family can + /// ever match. + pub(crate) fn may_get_owned_family( + &self, + storage: &dyn Storage, + owner: &Addr, + ) -> Result, NodeFamiliesContractError> { + Ok(self + .families + .idx + .owner + .item(storage, owner.clone())? + .map(|(_, owned)| owned)) + } + + /// Like [`Self::may_get_owned_family`] but errors with + /// [`SenderDoesntOwnAFamily`] when the address doesn't own one — meant + /// for owner-gated execute paths (`disband`, `invite`, …) where the + /// absence of a family is itself an error. + /// + /// [`SenderDoesntOwnAFamily`]: NodeFamiliesContractError::SenderDoesntOwnAFamily + pub(crate) fn must_get_owned_family( + &self, + storage: &dyn Storage, + owner: &Addr, + ) -> Result { + self.may_get_owned_family(storage, owner)?.ok_or_else(|| { + NodeFamiliesContractError::SenderDoesntOwnAFamily { + address: owner.clone(), + } + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::{init_contract_tester, NodeFamiliesContractTesterExt}; + use nym_contracts_common_testing::ContractOpts; + + // ---- counters ---- + + #[test] + fn next_family_id_starts_at_1_and_increments() { + let mut tester = init_contract_tester(); + let s = NodeFamiliesStorage::new(); + + assert_eq!(s.next_family_id(tester.storage_mut()).unwrap(), 1); + assert_eq!(s.next_family_id(tester.storage_mut()).unwrap(), 2); + assert_eq!(s.next_family_id(tester.storage_mut()).unwrap(), 3); + } + + #[test] + fn past_invitation_counter_starts_at_0_per_key() { + let mut tester = init_contract_tester(); + let s = NodeFamiliesStorage::new(); + let k1: FamilyMember = (1, 100); + let k2: FamilyMember = (2, 100); + + assert_eq!( + s.next_past_invitation_counter(tester.storage_mut(), k1) + .unwrap(), + 0 + ); + assert_eq!( + s.next_past_invitation_counter(tester.storage_mut(), k1) + .unwrap(), + 1 + ); + // independent counter for a different key + assert_eq!( + s.next_past_invitation_counter(tester.storage_mut(), k2) + .unwrap(), + 0 + ); + assert_eq!( + s.next_past_invitation_counter(tester.storage_mut(), k1) + .unwrap(), + 2 + ); + } + + #[test] + fn past_member_counter_starts_at_0_per_key() { + let mut tester = init_contract_tester(); + let s = NodeFamiliesStorage::new(); + let k: FamilyMember = (1, 100); + + assert_eq!( + s.next_past_member_counter(tester.storage_mut(), k).unwrap(), + 0 + ); + assert_eq!( + s.next_past_member_counter(tester.storage_mut(), k).unwrap(), + 1 + ); + } + + // ---- register_new_family ---- + + #[test] + fn register_new_family_persists_with_expected_fields() { + let mut tester = init_contract_tester(); + let s = NodeFamiliesStorage::new(); + let fee = tester.family_fee(); + let env = tester.env(); + let owner = tester.addr_make("alice"); + + let family = s + .register_new_family( + tester.storage_mut(), + &env, + fee, + owner.clone(), + "Fam!".into(), + "fam".into(), + "desc".into(), + ) + .unwrap(); + + assert_eq!(family.id, 1); + assert_eq!(family.owner, owner); + assert_eq!(family.name, "Fam!"); + assert_eq!(family.normalised_name, "fam"); + assert_eq!(family.description, "desc"); + assert_eq!(family.members, 0); + assert_eq!(family.created_at, tester.env().block.time.seconds()); + + let stored = s.families.load(tester.storage(), 1).unwrap(); + assert_eq!(stored, family); + } + + #[test] + fn register_new_family_assigns_sequential_ids() { + let mut tester = init_contract_tester(); + + let f1 = tester.add_dummy_family(); + let f2 = tester.add_dummy_family(); + + assert_eq!(f1.id, 1); + assert_eq!(f2.id, 2); + } + + #[test] + fn register_new_family_rejects_duplicate_name() { + let mut tester = init_contract_tester(); + let s = NodeFamiliesStorage::new(); + let env = tester.env(); + let alice = tester.addr_make("alice"); + let bob = tester.addr_make("bob"); + let fee = tester.family_fee(); + + s.register_new_family( + tester.storage_mut(), + &env, + fee.clone(), + alice, + "Shared".into(), + "shared".into(), + "".into(), + ) + .unwrap(); + + // unique-index defence-in-depth check: same normalised_name even though + // the user-submitted `name` differs in casing/punctuation. + let res = s.register_new_family( + tester.storage_mut(), + &env, + fee, + bob, + "$$shared$$".into(), + "shared".into(), + "".into(), + ); + assert!(res.is_err()); + } + + #[test] + fn register_new_family_rejects_duplicate_owner() { + let mut tester = init_contract_tester(); + let s = NodeFamiliesStorage::new(); + let env = tester.env(); + let alice = tester.addr_make("alice"); + let fee = tester.family_fee(); + + tester.make_family(&alice); + + // unique-index defence-in-depth check + let res = s.register_new_family( + tester.storage_mut(), + &env, + fee, + alice, + "second".into(), + "second".into(), + "".into(), + ); + assert!(res.is_err()); + } + + // ---- add_pending_invitation ---- + + #[test] + fn add_pending_invitation_persists() { + let mut tester = init_contract_tester(); + let s = NodeFamiliesStorage::new(); + let alice = tester.addr_make("alice"); + let f = tester.make_family(&alice); + let expires_at = tester.env().block.time.seconds() + 100; + + let inv = s + .add_pending_invitation(tester.storage_mut(), f.id, 42, expires_at) + .unwrap(); + + assert_eq!(inv.family_id, f.id); + assert_eq!(inv.node_id, 42); + assert_eq!(inv.expires_at, expires_at); + let stored = s + .pending_family_invitations + .load(tester.storage(), (f.id, 42)) + .unwrap(); + assert_eq!(stored, inv); + } + + #[test] + fn add_pending_invitation_errors_on_unknown_family() { + let mut tester = init_contract_tester(); + let s = NodeFamiliesStorage::new(); + let env = tester.env(); + let expires_at = env.block.time.seconds() + 100; + + let res = s.add_pending_invitation(tester.storage_mut(), 99, 42, expires_at); + assert_eq!( + res.unwrap_err(), + NodeFamiliesContractError::FamilyNotFound { family_id: 99 } + ); + } + + #[test] + fn add_pending_invitation_errors_on_duplicate() { + let mut tester = init_contract_tester(); + let s = NodeFamiliesStorage::new(); + let env = tester.env(); + let alice = tester.addr_make("alice"); + let f = tester.make_family(&alice); + + tester.invite_to_family(f.id, 42); + + let expires_at = env.block.time.seconds() + 200; + let res = s.add_pending_invitation(tester.storage_mut(), f.id, 42, expires_at); + assert_eq!( + res.unwrap_err(), + NodeFamiliesContractError::PendingInvitationAlreadyExists { + family_id: f.id, + node_id: 42, + } + ); + } + + // ---- accept_invitation ---- + + #[test] + fn accept_invitation_happy_path() { + let mut tester = init_contract_tester(); + let s = NodeFamiliesStorage::new(); + let env = tester.env(); + let alice = tester.addr_make("alice"); + let f = tester.make_family(&alice); + let expires_at = env.block.time.seconds() + 100; + s.add_pending_invitation(tester.storage_mut(), f.id, 42, expires_at) + .unwrap(); + + let updated = s + .accept_invitation(tester.storage_mut(), &env, f.id, 42) + .unwrap(); + + assert_eq!(updated.members, 1); + assert!(s + .pending_family_invitations + .may_load(tester.storage(), (f.id, 42)) + .unwrap() + .is_none()); + let membership = s.family_members.load(tester.storage(), 42).unwrap(); + assert_eq!(membership.family_id, f.id); + assert_eq!(membership.joined_at, tester.env().block.time.seconds()); + assert_eq!(s.families.load(tester.storage(), f.id).unwrap().members, 1); + + let past = s + .past_family_invitations + .load(tester.storage(), ((f.id, 42), 0)) + .unwrap(); + assert_eq!( + past.status, + FamilyInvitationStatus::Accepted { + at: tester.env().block.time.seconds() + } + ); + assert_eq!(past.invitation.family_id, f.id); + assert_eq!(past.invitation.node_id, 42); + } + + #[test] + fn accept_invitation_errors_when_no_pending() { + let mut tester = init_contract_tester(); + let s = NodeFamiliesStorage::new(); + let env = tester.env(); + + let res = s.accept_invitation(tester.storage_mut(), &env, 1, 42); + assert_eq!( + res.unwrap_err(), + NodeFamiliesContractError::InvitationNotFound { + family_id: 1, + node_id: 42, + } + ); + } + + #[test] + fn accept_invitation_errors_when_expired() { + let mut tester = init_contract_tester(); + let s = NodeFamiliesStorage::new(); + let env = tester.env(); + let alice = tester.addr_make("alice"); + let f = tester.make_family(&alice); + // expires at exactly `now` — `now >= expires_at` triggers + let expires_at = tester.env().block.time.seconds(); + s.add_pending_invitation(tester.storage_mut(), f.id, 42, expires_at) + .unwrap(); + + let res = s.accept_invitation(tester.storage_mut(), &env, f.id, 42); + assert_eq!( + res.unwrap_err(), + NodeFamiliesContractError::InvitationExpired { + family_id: f.id, + node_id: 42, + expires_at, + now: tester.env().block.time.seconds(), + } + ); + } + + // ---- reject_pending_invitation ---- + + #[test] + fn reject_invitation_happy_path() { + let mut tester = init_contract_tester(); + let s = NodeFamiliesStorage::new(); + let env = tester.env(); + let alice = tester.addr_make("alice"); + let f = tester.make_family(&alice); + tester.invite_to_family(f.id, 42); + + let inv = s + .reject_pending_invitation(tester.storage_mut(), &env, f.id, 42) + .unwrap(); + assert_eq!(inv.node_id, 42); + assert!(s + .pending_family_invitations + .may_load(tester.storage(), (f.id, 42)) + .unwrap() + .is_none()); + let past = s + .past_family_invitations + .load(tester.storage(), ((f.id, 42), 0)) + .unwrap(); + assert_eq!( + past.status, + FamilyInvitationStatus::Rejected { + at: tester.env().block.time.seconds() + } + ); + } + + #[test] + fn reject_invitation_works_on_expired() { + let mut tester = init_contract_tester(); + let s = NodeFamiliesStorage::new(); + let env = tester.env(); + let alice = tester.addr_make("alice"); + let f = tester.make_family(&alice); + let expires_at = env.block.time.seconds(); + s.add_pending_invitation(tester.storage_mut(), f.id, 42, expires_at) + .unwrap(); + + s.reject_pending_invitation(tester.storage_mut(), &env, f.id, 42) + .unwrap(); + } + + #[test] + fn reject_invitation_errors_when_no_pending() { + let mut tester = init_contract_tester(); + let s = NodeFamiliesStorage::new(); + let env = tester.env(); + + let res = s.reject_pending_invitation(tester.storage_mut(), &env, 1, 42); + assert_eq!( + res.unwrap_err(), + NodeFamiliesContractError::InvitationNotFound { + family_id: 1, + node_id: 42, + } + ); + } + + // ---- revoke_pending_invitation ---- + + #[test] + fn revoke_invitation_happy_path() { + let mut tester = init_contract_tester(); + let s = NodeFamiliesStorage::new(); + let env = tester.env(); + let alice = tester.addr_make("alice"); + let f = tester.make_family(&alice); + tester.invite_to_family(f.id, 42); + + s.revoke_pending_invitation(tester.storage_mut(), &env, f.id, 42) + .unwrap(); + let past = s + .past_family_invitations + .load(tester.storage(), ((f.id, 42), 0)) + .unwrap(); + assert_eq!( + past.status, + FamilyInvitationStatus::Revoked { + at: tester.env().block.time.seconds() + } + ); + } + + #[test] + fn revoke_invitation_errors_when_no_pending() { + let mut tester = init_contract_tester(); + let s = NodeFamiliesStorage::new(); + let env = tester.env(); + + let res = s.revoke_pending_invitation(tester.storage_mut(), &env, 1, 42); + assert_eq!( + res.unwrap_err(), + NodeFamiliesContractError::InvitationNotFound { + family_id: 1, + node_id: 42, + } + ); + } + + // ---- remove_family_member ---- + + #[test] + fn remove_family_member_happy_path() { + let mut tester = init_contract_tester(); + let s = NodeFamiliesStorage::new(); + let env = tester.env(); + let alice = tester.addr_make("alice"); + let f = tester.make_family(&alice); + tester.add_to_family(f.id, 42); + + let updated = s + .remove_family_member(tester.storage_mut(), &env, 42) + .unwrap(); + + assert_eq!(updated.members, 0); + assert!(s + .family_members + .may_load(tester.storage(), 42) + .unwrap() + .is_none()); + let past = s + .past_family_members + .load(tester.storage(), ((f.id, 42), 0)) + .unwrap(); + assert_eq!(past.family_id, f.id); + assert_eq!(past.node_id, 42); + assert_eq!(past.removed_at, tester.env().block.time.seconds()); + } + + #[test] + fn remove_family_member_errors_when_node_not_in_any_family() { + let mut tester = init_contract_tester(); + let s = NodeFamiliesStorage::new(); + let env = tester.env(); + + let res = s.remove_family_member(tester.storage_mut(), &env, 999); + assert_eq!( + res.unwrap_err(), + NodeFamiliesContractError::NodeNotInFamily { node_id: 999 } + ); + } + + #[test] + fn remove_family_member_uses_per_pair_archive_counter() { + // joining and leaving the same family twice must not collide on the archive key + let mut tester = init_contract_tester(); + let s = NodeFamiliesStorage::new(); + let env = tester.env(); + let alice = tester.addr_make("alice"); + let f = tester.make_family(&alice); + + let expires_at = env.block.time.seconds() + 100; + for _ in 0..2 { + s.add_pending_invitation(tester.storage_mut(), f.id, 42, expires_at) + .unwrap(); + s.accept_invitation(tester.storage_mut(), &env, f.id, 42) + .unwrap(); + s.remove_family_member(tester.storage_mut(), &env, 42) + .unwrap(); + } + + // both archive slots present + s.past_family_members + .load(tester.storage(), ((f.id, 42), 0)) + .unwrap(); + s.past_family_members + .load(tester.storage(), ((f.id, 42), 1)) + .unwrap(); + } + + // ---- disband_family ---- + + #[test] + fn disband_family_happy_path_no_pending() { + let mut tester = init_contract_tester(); + let s = NodeFamiliesStorage::new(); + let env = tester.env(); + let alice = tester.addr_make("alice"); + let f = tester.make_family(&alice); + + let snap = s.disband_family(tester.storage_mut(), &env, f.id).unwrap(); + assert_eq!(snap.id, f.id); + assert!(s + .families + .may_load(tester.storage(), f.id) + .unwrap() + .is_none()); + } + + #[test] + fn disband_family_sweeps_all_pending_invitations_as_revoked() { + let mut tester = init_contract_tester(); + let s = NodeFamiliesStorage::new(); + let env = tester.env(); + let alice = tester.addr_make("alice"); + let f = tester.make_family(&alice); + tester.invite_to_family(f.id, 42); + tester.invite_to_family(f.id, 43); + + s.disband_family(tester.storage_mut(), &env, f.id).unwrap(); + + assert!(s + .pending_family_invitations + .may_load(tester.storage(), (f.id, 42)) + .unwrap() + .is_none()); + assert!(s + .pending_family_invitations + .may_load(tester.storage(), (f.id, 43)) + .unwrap() + .is_none()); + assert_eq!( + s.past_family_invitations + .load(tester.storage(), ((f.id, 42), 0)) + .unwrap() + .status, + FamilyInvitationStatus::Revoked { + at: tester.env().block.time.seconds() + } + ); + assert_eq!( + s.past_family_invitations + .load(tester.storage(), ((f.id, 43), 0)) + .unwrap() + .status, + FamilyInvitationStatus::Revoked { + at: tester.env().block.time.seconds() + } + ); + } + + #[test] + fn disband_family_errors_on_unknown_family() { + let mut tester = init_contract_tester(); + let s = NodeFamiliesStorage::new(); + let env = tester.env(); + + let res = s.disband_family(tester.storage_mut(), &env, 99); + assert_eq!( + res.unwrap_err(), + NodeFamiliesContractError::FamilyNotFound { family_id: 99 } + ); + } + + #[test] + fn after_disband_owner_can_register_again_with_new_id() { + let mut tester = init_contract_tester(); + let fee = tester.family_fee(); + let s = NodeFamiliesStorage::new(); + let env = tester.env(); + let alice = tester.addr_make("alice"); + + let f1 = tester.make_family(&alice); + s.disband_family(tester.storage_mut(), &env, f1.id).unwrap(); + let f2 = s + .register_new_family( + tester.storage_mut(), + &env, + fee, + alice, + "2".into(), + "2".into(), + "".into(), + ) + .unwrap(); + + // ids monotonically increase, never recycled + assert_eq!(f1.id, 1); + assert_eq!(f2.id, 2); + } + + // ---- may_get_owned_family ---- + + #[test] + fn may_get_owned_family_returns_none_when_address_owns_nothing() { + let tester = init_contract_tester(); + let s = NodeFamiliesStorage::new(); + let alice = tester.addr_make("alice"); + + let res = s.may_get_owned_family(tester.storage(), &alice).unwrap(); + assert!(res.is_none()); + } + + #[test] + fn may_get_owned_family_returns_the_family_for_its_owner() { + let mut tester = init_contract_tester(); + let s = NodeFamiliesStorage::new(); + let alice = tester.addr_make("alice"); + let family = tester.make_family(&alice); + + let res = s + .may_get_owned_family(tester.storage(), &alice) + .unwrap() + .unwrap(); + assert_eq!(res, family); + } + + #[test] + fn may_get_owned_family_does_not_leak_other_owners_family() { + let mut tester = init_contract_tester(); + let s = NodeFamiliesStorage::new(); + let alice = tester.addr_make("alice"); + let bob = tester.addr_make("bob"); + tester.make_family(&alice); + + let res = s.may_get_owned_family(tester.storage(), &bob).unwrap(); + assert!(res.is_none()); + } + + #[test] + fn may_get_owned_family_returns_none_after_disband() { + let mut tester = init_contract_tester(); + let s = NodeFamiliesStorage::new(); + let alice = tester.addr_make("alice"); + let family = tester.make_family(&alice); + + let env = tester.env(); + s.disband_family(tester.storage_mut(), &env, family.id) + .unwrap(); + + let res = s.may_get_owned_family(tester.storage(), &alice).unwrap(); + assert!(res.is_none()); + } + + // ---- must_get_owned_family ---- + + #[test] + fn must_get_owned_family_returns_the_family_for_its_owner() { + let mut tester = init_contract_tester(); + let s = NodeFamiliesStorage::new(); + let alice = tester.addr_make("alice"); + let family = tester.make_family(&alice); + + let res = s.must_get_owned_family(tester.storage(), &alice).unwrap(); + assert_eq!(res, family); + } + + #[test] + fn must_get_owned_family_errors_when_address_owns_nothing() { + let tester = init_contract_tester(); + let s = NodeFamiliesStorage::new(); + let alice = tester.addr_make("alice"); + + let err = s + .must_get_owned_family(tester.storage(), &alice) + .unwrap_err(); + assert_eq!( + err, + NodeFamiliesContractError::SenderDoesntOwnAFamily { address: alice } + ); + } + + #[test] + fn must_get_owned_family_errors_after_disband() { + let mut tester = init_contract_tester(); + let s = NodeFamiliesStorage::new(); + let alice = tester.addr_make("alice"); + let family = tester.make_family(&alice); + + let env = tester.env(); + s.disband_family(tester.storage_mut(), &env, family.id) + .unwrap(); + + let err = s + .must_get_owned_family(tester.storage(), &alice) + .unwrap_err(); + assert_eq!( + err, + NodeFamiliesContractError::SenderDoesntOwnAFamily { address: alice } + ); + } +} diff --git a/contracts/node-families/src/storage/retrieval_limits.rs b/contracts/node-families/src/storage/retrieval_limits.rs new file mode 100644 index 0000000000..bb993fe289 --- /dev/null +++ b/contracts/node-families/src/storage/retrieval_limits.rs @@ -0,0 +1,34 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +/// Default page size for paginated family listings when the caller omits `limit`. +pub const FAMILIES_DEFAULT_LIMIT: u32 = 50; + +/// Hard cap on the page size for paginated family listings; larger values are clamped. +pub const FAMILIES_MAX_LIMIT: u32 = 100; + +/// Default page size for paginated family-member listings when the caller omits `limit`. +pub const FAMILY_MEMBERS_DEFAULT_LIMIT: u32 = 50; + +/// Hard cap on the page size for paginated family-member listings; larger values are clamped. +pub const FAMILY_MEMBERS_MAX_LIMIT: u32 = 100; + +/// Default page size for paginated pending-invitation listings (both per-family +/// and global) when the caller omits `limit`. +pub const PENDING_INVITATIONS_DEFAULT_LIMIT: u32 = 50; + +/// Hard cap on the page size for paginated pending-invitation listings; larger values are clamped. +pub const PENDING_INVITATIONS_MAX_LIMIT: u32 = 100; + +/// Default page size for paginated past-invitation (archive) listings (both +/// per-family and global) when the caller omits `limit`. +pub const PAST_INVITATIONS_DEFAULT_LIMIT: u32 = 50; + +/// Hard cap on the page size for paginated past-invitation listings; larger values are clamped. +pub const PAST_INVITATIONS_MAX_LIMIT: u32 = 100; + +/// Default page size for paginated past-member (archive) listings when the caller omits `limit`. +pub const PAST_MEMBERS_DEFAULT_LIMIT: u32 = 50; + +/// Hard cap on the page size for paginated past-member listings; larger values are clamped. +pub const PAST_MEMBERS_MAX_LIMIT: u32 = 100; diff --git a/contracts/node-families/src/storage/storage_indexes.rs b/contracts/node-families/src/storage/storage_indexes.rs new file mode 100644 index 0000000000..0bae76f32a --- /dev/null +++ b/contracts/node-families/src/storage/storage_indexes.rs @@ -0,0 +1,174 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use crate::storage::FamilyMember; +use cosmwasm_std::Addr; +use cw_storage_plus::{Index, IndexList, MultiIndex, UniqueIndex}; +use nym_mixnet_contract_common::NodeId; +use nym_node_families_contract_common::constants::storage_keys; +use nym_node_families_contract_common::{ + FamilyInvitation, FamilyMembership, NodeFamily, NodeFamilyId, PastFamilyInvitation, + PastFamilyMember, +}; + +/// Secondary indexes over [`NodeFamily`]. Enforces one-family-per-owner and +/// globally-unique family names via `UniqueIndex`es on `owner` and `name`. +pub(crate) struct NodeFamiliesIndex<'a> { + /// Unique index: at most one family per owner [`Addr`]. + pub(crate) owner: UniqueIndex<'a, Addr, NodeFamily, NodeFamilyId>, + /// Unique index keyed on [`NodeFamily::normalised_name`]: enforces global + /// uniqueness on the normalised form, so families with the same + /// canonical name but different user-submitted formatting collide. + pub(crate) normalised_name: UniqueIndex<'a, String, NodeFamily, NodeFamilyId>, +} + +impl NodeFamiliesIndex<'_> { + #[allow(clippy::new_without_default)] + pub(crate) fn new() -> Self { + NodeFamiliesIndex { + owner: UniqueIndex::new( + |family| family.owner.clone(), + storage_keys::FAMILIES_OWNER_IDX_NAMESPACE, + ), + normalised_name: UniqueIndex::new( + |family| family.normalised_name.clone(), + storage_keys::FAMILIES_NAME_IDX_NAMESPACE, + ), + } + } +} + +impl IndexList for NodeFamiliesIndex<'_> { + fn get_indexes(&'_ self) -> Box> + '_> { + let v: Vec<&dyn Index> = vec![&self.owner, &self.normalised_name]; + Box::new(v.into_iter()) + } +} + +/// Secondary indexes over current [`FamilyMembership`] records. The PK is +/// `NodeId` (one family per node), and the family-id multi-index enables +/// paginated listing of all nodes belonging to a given family. +pub(crate) struct FamilyMembersIndex<'a> { + /// Multi-index: every node currently in a given family. + pub(crate) family: MultiIndex<'a, NodeFamilyId, FamilyMembership, NodeId>, +} + +impl FamilyMembersIndex<'_> { + #[allow(clippy::new_without_default)] + pub(crate) fn new() -> Self { + FamilyMembersIndex { + family: MultiIndex::new( + |_pk, m| m.family_id, + storage_keys::NODE_FAMILY_MEMBERS, + storage_keys::NODE_FAMILY_MEMBERS_FAMILY_IDX_NAMESPACE, + ), + } + } +} + +impl IndexList for FamilyMembersIndex<'_> { + fn get_indexes(&'_ self) -> Box> + '_> { + let v: Vec<&dyn Index> = vec![&self.family]; + Box::new(v.into_iter()) + } +} + +/// Secondary indexes over pending [`FamilyInvitation`]s, allowing lookup by +/// either family id or node id. +pub(crate) struct NodeFamilyInvitationIndex<'a> { + /// Multi-index: all pending invitations issued by a given family. + pub(crate) family: MultiIndex<'a, NodeFamilyId, FamilyInvitation, FamilyMember>, + /// Multi-index: all pending invitations addressed to a given node. + pub(crate) node: MultiIndex<'a, NodeId, FamilyInvitation, FamilyMember>, +} + +impl NodeFamilyInvitationIndex<'_> { + pub(crate) fn new() -> Self { + NodeFamilyInvitationIndex { + family: MultiIndex::new( + |_pk, inv| inv.family_id, + storage_keys::INVITATIONS_NAMESPACE, + storage_keys::INVITATIONS_FAMILY_IDX_NAMESPACE, + ), + node: MultiIndex::new( + |_pk, inv| inv.node_id, + storage_keys::INVITATIONS_NAMESPACE, + storage_keys::INVITATIONS_NODE_IDX_NAMESPACE, + ), + } + } +} + +impl IndexList for NodeFamilyInvitationIndex<'_> { + fn get_indexes(&'_ self) -> Box> + '_> { + let v: Vec<&dyn Index> = vec![&self.family, &self.node]; + Box::new(v.into_iter()) + } +} + +/// Secondary indexes over the [`PastFamilyMember`] archive. +pub(crate) struct PastFamilyMembersIndex<'a> { + /// Multi-index: every past membership record for a given family. + pub(crate) family: MultiIndex<'a, NodeFamilyId, PastFamilyMember, (FamilyMember, u64)>, + /// Multi-index: every past membership record for a given node. + pub(crate) node: MultiIndex<'a, NodeId, PastFamilyMember, (FamilyMember, u64)>, +} + +impl PastFamilyMembersIndex<'_> { + #[allow(clippy::new_without_default)] + pub(crate) fn new() -> Self { + PastFamilyMembersIndex { + family: MultiIndex::new( + |_pk, mem| mem.family_id, + storage_keys::PAST_FAMILY_MEMBER_NAMESPACE, + storage_keys::PAST_FAMILY_MEMBER_FAMILY_IDX_NAMESPACE, + ), + node: MultiIndex::new( + |_pk, mem| mem.node_id, + storage_keys::PAST_FAMILY_MEMBER_NAMESPACE, + storage_keys::PAST_FAMILY_MEMBER_NODE_IDX_NAMESPACE, + ), + } + } +} + +impl IndexList for PastFamilyMembersIndex<'_> { + fn get_indexes(&'_ self) -> Box> + '_> { + let v: Vec<&dyn Index> = vec![&self.family, &self.node]; + Box::new(v.into_iter()) + } +} + +/// Secondary indexes over the [`PastFamilyInvitation`] archive +/// (rejected / revoked invitations). +pub(crate) struct PastFamilyInvitationsIndex<'a> { + /// Multi-index: every archived invitation issued by a given family. + pub(crate) family: MultiIndex<'a, NodeFamilyId, PastFamilyInvitation, (FamilyMember, u64)>, + /// Multi-index: every archived invitation addressed to a given node. + pub(crate) node: MultiIndex<'a, NodeId, PastFamilyInvitation, (FamilyMember, u64)>, +} + +impl PastFamilyInvitationsIndex<'_> { + #[allow(clippy::new_without_default)] + pub(crate) fn new() -> Self { + PastFamilyInvitationsIndex { + family: MultiIndex::new( + |_pk, inv| inv.invitation.family_id, + storage_keys::PAST_INVITATIONS_NAMESPACE, + storage_keys::PAST_INVITATIONS_FAMILY_IDX_NAMESPACE, + ), + node: MultiIndex::new( + |_pk, inv| inv.invitation.node_id, + storage_keys::PAST_INVITATIONS_NAMESPACE, + storage_keys::PAST_INVITATIONS_NODE_IDX_NAMESPACE, + ), + } + } +} + +impl IndexList for PastFamilyInvitationsIndex<'_> { + fn get_indexes(&'_ self) -> Box> + '_> { + let v: Vec<&dyn Index> = vec![&self.family, &self.node]; + Box::new(v.into_iter()) + } +} diff --git a/contracts/node-families/src/testing.rs b/contracts/node-families/src/testing.rs new file mode 100644 index 0000000000..f76bbacf26 --- /dev/null +++ b/contracts/node-families/src/testing.rs @@ -0,0 +1,219 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +// fine in test code +#![allow(clippy::unwrap_used)] +#![allow(clippy::expect_used)] + +use crate::contract::{execute, instantiate, migrate, query}; +use crate::helpers::normalise_family_name; +use crate::storage::NodeFamiliesStorage; +use cosmwasm_std::{coin, Addr, Coin, Storage}; +use mixnet_contract::testable_mixnet_contract::{EmbeddedMixnetContractExt, MixnetContract}; +use nym_contracts_common_testing::{ + AdminExt, ArbitraryContractStorageReader, ArbitraryContractStorageWriter, BankExt, ChainOpts, + CommonStorageKeys, ContractFn, ContractOpts, ContractTester, ContractTesterBuilder, DenomExt, + PermissionedFn, QueryFn, RandExt, TestableNymContract, TEST_DENOM, +}; +use nym_mixnet_contract_common::ContractState; +use nym_mixnet_contract_common::NodeId; +use nym_node_families_contract_common::constants::storage_keys; +use nym_node_families_contract_common::{ + Config, ExecuteMsg, FamilyInvitation, InstantiateMsg, MigrateMsg, NodeFamiliesContractError, + NodeFamily, NodeFamilyId, QueryMsg, +}; + +pub struct NodeFamiliesContract; + +impl TestableNymContract for NodeFamiliesContract { + const NAME: &'static str = "node-families-contract"; + type InitMsg = InstantiateMsg; + type ExecuteMsg = ExecuteMsg; + type QueryMsg = QueryMsg; + type MigrateMsg = MigrateMsg; + type ContractError = NodeFamiliesContractError; + + fn instantiate() -> ContractFn { + instantiate + } + + fn execute() -> ContractFn { + execute + } + + fn query() -> QueryFn { + query + } + + fn migrate() -> PermissionedFn { + migrate + } + + fn init() -> ContractTester + where + Self: Sized, + { + let builder = ContractTesterBuilder::new().instantiate::(None); + + // we just instantiated it + let mixnet_address = builder + .well_known_contracts + .get(MixnetContract::NAME) + .unwrap() + .clone(); + + builder + .instantiate::(Some(InstantiateMsg { + config: Config { + create_family_fee: coin(100_000000, TEST_DENOM), + family_name_length_limit: 20, + family_description_length_limit: 200, + default_invitation_validity_secs: 24 * 60 * 60, + }, + mixnet_contract_address: mixnet_address.to_string(), + })) + .build() + } +} + +/// Storage key the mixnet contract uses for its `ContractState` `Item` +/// (mirrors `mixnet/src/constants.rs::CONTRACT_STATE_KEY`). +const MIXNET_CONTRACT_STATE_STORAGE_KEY: &str = "state"; + +pub fn init_contract_tester() -> ContractTester { + let mut tester = NodeFamiliesContract::init() + .with_common_storage_key(CommonStorageKeys::Admin, storage_keys::CONTRACT_ADMIN); + + // Chicken-and-egg: the mixnet contract is instantiated first and is given + // a placeholder `node_families_contract_address` because the families + // contract doesn't exist yet. Once the families contract has been + // instantiated we patch the mixnet's stored `ContractState` so that the + // unbond callback (`OnNymNodeUnbond`) actually dispatches to the right + // contract. In production this fixup happens via a contract migration; + // here we go straight to storage to avoid jumping through cw2 version + // checks that don't apply on a fresh tester. + let families_address = tester.contract_address.clone(); + let mut mixnet_state: ContractState = tester + .read_from_mixnet_contract_storage(MIXNET_CONTRACT_STATE_STORAGE_KEY) + .expect("mixnet contract state should be loadable"); + mixnet_state.node_families_contract_address = families_address; + tester + .write_to_mixnet_contract_storage_value(MIXNET_CONTRACT_STATE_STORAGE_KEY, &mixnet_state) + .expect("should be able to patch mixnet contract state"); + + tester +} + +pub trait NodeFamiliesContractTesterExt: + ContractOpts< + ExecuteMsg = ExecuteMsg, + QueryMsg = QueryMsg, + ContractError = NodeFamiliesContractError, + > + ChainOpts + + AdminExt + + DenomExt + + BankExt + + RandExt + + Storage + + ArbitraryContractStorageReader + + ArbitraryContractStorageWriter + + EmbeddedMixnetContractExt + + Sized +{ + fn family_fee(&self) -> Coin { + let s = NodeFamiliesStorage::new(); + s.config.load(self).unwrap().create_family_fee + } + + fn make_named_family(&mut self, owner: &Addr, name: &str) -> NodeFamily { + let normalised = normalise_family_name(name); + let env = self.env(); + let fee = self.family_fee(); + NodeFamiliesStorage::new() + .register_new_family( + self, + &env, + fee, + owner.clone(), + name.to_string(), + normalised, + "dummy".to_string(), + ) + .unwrap() + } + + fn make_family(&mut self, owner: &Addr) -> NodeFamily { + // names must be globally unique; derive from owner addr (also unique) + let name = format!("family-{owner}"); + self.make_named_family(owner, &name) + } + + fn disband_family(&mut self, family: NodeFamilyId) { + let env = self.env(); + NodeFamiliesStorage::new() + .disband_family(self, &env, family) + .unwrap(); + } + + fn add_dummy_family(&mut self) -> NodeFamily { + let owner = self.generate_account(); + self.make_family(&owner) + } + + fn invite_to_family_with_expiration( + &mut self, + family: NodeFamilyId, + node: NodeId, + expiration: u64, + ) -> FamilyInvitation { + NodeFamiliesStorage::new() + .add_pending_invitation(self, family, node, expiration) + .unwrap() + } + + fn invite_to_family(&mut self, family: NodeFamilyId, node: NodeId) -> FamilyInvitation { + let exp = self.env().block.time.seconds() + 100; + self.invite_to_family_with_expiration(family, node, exp) + } + + fn accept_invitation(&mut self, family: NodeFamilyId, node: NodeId) { + let env = self.env(); + NodeFamiliesStorage::new() + .accept_invitation(self, &env, family, node) + .unwrap(); + } + + fn reject_invitation(&mut self, family: NodeFamilyId, node: NodeId) { + let env = self.env(); + NodeFamiliesStorage::new() + .reject_pending_invitation(self, &env, family, node) + .unwrap(); + } + + fn revoke_invitation(&mut self, family: NodeFamilyId, node: NodeId) { + let env = self.env(); + NodeFamiliesStorage::new() + .revoke_pending_invitation(self, &env, family, node) + .unwrap(); + } + + fn add_to_family(&mut self, family: NodeFamilyId, node: NodeId) { + self.invite_to_family(family, node); + self.accept_invitation(family, node); + } + + fn remove_from_family(&mut self, node: NodeId) { + let env = self.env(); + NodeFamiliesStorage::new() + .remove_family_member(self, &env, node) + .unwrap(); + } + + fn add_n_family_members(&mut self, family: NodeFamilyId, count: u32) { + for n in 1..=count { + self.add_to_family(family, n); + } + } +} + +impl NodeFamiliesContractTesterExt for ContractTester {} diff --git a/contracts/node-families/src/transactions.rs b/contracts/node-families/src/transactions.rs new file mode 100644 index 0000000000..0237dcf3ab --- /dev/null +++ b/contracts/node-families/src/transactions.rs @@ -0,0 +1,2165 @@ +// Copyright 2026 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +//! State-mutating execute handlers + +use crate::helpers::{ + ensure_address_holds_no_family_membership, ensure_has_bonded_node, ensure_node_is_bonded, + ensure_node_not_in_family, normalise_family_name, +}; +use crate::storage::NodeFamiliesStorage; +use cosmwasm_std::{BankMsg, DepsMut, Env, Event, MessageInfo, Response}; +use nym_mixnet_contract_common::NodeId; +use nym_node_families_contract_common::constants::events; +use nym_node_families_contract_common::{Config, NodeFamiliesContractError, NodeFamilyId}; + +/// Replace the contract's runtime [`Config`]. Restricted to the contract admin. +pub(crate) fn try_update_config( + deps: DepsMut, + _env: Env, + info: MessageInfo, + config: Config, +) -> Result { + let storage = NodeFamiliesStorage::new(); + storage + .contract_admin + .assert_admin(deps.as_ref(), &info.sender)?; + storage.config.save(deps.storage, &config)?; + Ok(Response::default()) +} + +/// Create a new family owned by `info.sender`. +/// +/// Performs the caller-side checks specified on +/// [`NodeFamiliesStorage::register_new_family`]: validates the attached fee +/// matches the configured `create_family_fee`, that name and description +/// are within their configured length limits, that the name normalises to a +/// non-empty string, that the sender doesn't already own a family or +/// collide with an existing family's normalised name, and that the sender's +/// bonded node (if any) isn't already in a family. The unique indexes on +/// `owner` and `normalised_name` provide defence-in-depth, but pre-checking +/// yields typed errors with useful context. +pub(crate) fn try_create_family( + deps: DepsMut, + env: Env, + info: MessageInfo, + name: String, + description: String, +) -> Result { + let storage = NodeFamiliesStorage::new(); + let config = storage.config.load(deps.storage)?; + + // check for the correct number of coins and denom + let submitted = cw_utils::must_pay(&info, &config.create_family_fee.denom)?; + + // verify the amount + if submitted != config.create_family_fee.amount { + return Err(NodeFamiliesContractError::InvalidFamilyCreationFee { + expected: config.create_family_fee, + received: info.funds, + }); + } + + // validate family name + if name.len() > config.family_name_length_limit { + return Err(NodeFamiliesContractError::FamilyNameTooLong { + length: name.len(), + limit: config.family_name_length_limit, + }); + } + let normalised = normalise_family_name(&name); + if normalised.is_empty() { + return Err(NodeFamiliesContractError::EmptyFamilyName); + } + + // validate family description + if description.len() > config.family_description_length_limit { + return Err(NodeFamiliesContractError::FamilyDescriptionTooLong { + length: description.len(), + limit: config.family_description_length_limit, + }); + } + + // check if the sender already owns a family + if let Some(existing) = storage.may_get_owned_family(deps.storage, &info.sender)? { + return Err(NodeFamiliesContractError::SenderAlreadyOwnsAFamily { + address: info.sender, + family_id: existing.id, + }); + } + + // explicitly verify duplicate family name for a better error message + if let Some((_, existing)) = storage + .families + .idx + .normalised_name + .item(deps.storage, normalised.clone())? + { + return Err(NodeFamiliesContractError::FamilyNameAlreadyTaken { + name: normalised, + family_id: existing.id, + }); + } + + // check whether this owner has a bonded node which belongs to a family + ensure_address_holds_no_family_membership(&storage, deps.as_ref(), &info.sender)?; + + let family = storage.register_new_family( + deps.storage, + &env, + config.create_family_fee, + info.sender, + name, + normalised, + description, + )?; + + Ok(Response::new().add_event( + Event::new(events::FAMILY_CREATION_EVENT_NAME) + .add_attribute(events::FAMILY_CREATION_EVENT_FAMILY_NAME, family.name) + .add_attribute(events::FAMILY_CREATION_EVENT_OWNER_ADDRESS, family.owner) + .add_attribute( + events::FAMILY_CREATION_EVENT_FAMILY_ID, + family.id.to_string(), + ) + .add_attribute( + events::FAMILY_CREATION_EVENT_PAID_FEE, + family.paid_fee.to_string(), + ), + )) +} + +/// Disband the family owned by `info.sender` and refund the original +/// creation fee. +/// +/// Looks up the sender's family via the `owner` unique index (errors with +/// [`SenderDoesntOwnAFamily`] if none). The storage layer enforces the +/// "family must have zero current members" precondition and sweeps any +/// still-pending invitations as `Revoked`. The originally paid creation fee +/// is returned to the sender via a [`BankMsg::Send`] attached to the +/// response. +/// +/// [`SenderDoesntOwnAFamily`]: NodeFamiliesContractError::SenderDoesntOwnAFamily +pub(crate) fn try_disband_family( + deps: DepsMut, + env: Env, + info: MessageInfo, +) -> Result { + let storage = NodeFamiliesStorage::new(); + + let owned = storage.must_get_owned_family(deps.storage, &info.sender)?; + + if owned.members != 0 { + return Err(NodeFamiliesContractError::FamilyNotEmpty { + family_id: owned.id, + members: owned.members, + }); + } + + let family = storage.disband_family(deps.storage, &env, owned.id)?; + + let refund = BankMsg::Send { + to_address: family.owner.to_string(), + amount: vec![family.paid_fee.clone()], + }; + + Ok(Response::new().add_message(refund).add_event( + Event::new(events::FAMILY_DISBAND_EVENT_NAME) + .add_attribute( + events::FAMILY_DISBAND_EVENT_FAMILY_ID, + family.id.to_string(), + ) + .add_attribute(events::FAMILY_DISBAND_EVENT_OWNER_ADDRESS, &family.owner) + .add_attribute( + events::FAMILY_DISBAND_EVENT_REFUNDED_FEE, + family.paid_fee.to_string(), + ), + )) +} + +/// Issue a pending invitation for `node_id` to join the family owned by +/// `info.sender`. +/// +/// `validity_secs` overrides the configured `default_invitation_validity_secs` +/// when supplied; a value of `Some(0)` is rejected with +/// [`ZeroInvitationValidity`] since the invitation would already be expired +/// the moment it landed in storage. +/// +/// [`ZeroInvitationValidity`]: NodeFamiliesContractError::ZeroInvitationValidity +pub(crate) fn try_invite_to_family( + deps: DepsMut, + env: Env, + info: MessageInfo, + node_id: NodeId, + validity_secs: Option, +) -> Result { + let storage = NodeFamiliesStorage::new(); + let config = storage.config.load(deps.storage)?; + + let validity = validity_secs.unwrap_or(config.default_invitation_validity_secs); + if validity == 0 { + return Err(NodeFamiliesContractError::ZeroInvitationValidity); + } + + let owned = storage.must_get_owned_family(deps.storage, &info.sender)?; + ensure_node_is_bonded(&storage, deps.as_ref(), node_id)?; + ensure_node_not_in_family(&storage, deps.as_ref(), node_id)?; + + let expires_at = env.block.time.seconds() + validity; + let invitation = storage.add_pending_invitation(deps.storage, owned.id, node_id, expires_at)?; + + Ok(Response::new().add_event( + Event::new(events::FAMILY_INVITATION_EVENT_NAME) + .add_attribute( + events::FAMILY_INVITATION_EVENT_FAMILY_ID, + owned.id.to_string(), + ) + .add_attribute(events::FAMILY_INVITATION_EVENT_NODE_ID, node_id.to_string()) + .add_attribute( + events::FAMILY_INVITATION_EVENT_EXPIRES_AT, + invitation.expires_at.to_string(), + ), + )) +} + +/// Revoke a still-pending invitation previously issued by the sender's +/// family. +/// +/// The sender must currently own a family — the `(family, node)` pair +/// targeted for revocation is derived from that ownership rather than passed +/// explicitly, so a sender cannot revoke another family's invitations. +/// Errors with [`SenderDoesntOwnAFamily`] if the sender owns no family, or +/// [`InvitationNotFound`] if no pending invitation for `node_id` exists in +/// the sender's family. Expired invitations *can* be revoked — this is the +/// only path that cleans them out of the pending map. +/// +/// [`SenderDoesntOwnAFamily`]: NodeFamiliesContractError::SenderDoesntOwnAFamily +/// [`InvitationNotFound`]: NodeFamiliesContractError::InvitationNotFound +pub(crate) fn try_revoke_family_invitation( + deps: DepsMut, + env: Env, + info: MessageInfo, + node_id: NodeId, +) -> Result { + let storage = NodeFamiliesStorage::new(); + let owned = storage.must_get_owned_family(deps.storage, &info.sender)?; + + storage.revoke_pending_invitation(deps.storage, &env, owned.id, node_id)?; + + Ok(Response::new().add_event( + Event::new(events::FAMILY_INVITATION_REVOKED_EVENT_NAME) + .add_attribute( + events::FAMILY_INVITATION_REVOKED_EVENT_FAMILY_ID, + owned.id.to_string(), + ) + .add_attribute( + events::FAMILY_INVITATION_REVOKED_EVENT_NODE_ID, + node_id.to_string(), + ), + )) +} + +/// Accept the pending invitation for `node_id` to join `family_id`. +/// +/// `info.sender` must be the bond controller of `node_id` per the mixnet +/// contract; the storage layer's `family_members` write would otherwise +/// silently overwrite the membership of an unrelated node, so the controller +/// check (and the defence-in-depth `ensure_node_not_in_family`) live here +/// rather than down in storage. Errors with [`SenderDoesntControlNode`] if +/// the sender's bonded node id doesn't match (or the node is unbonding), +/// [`NodeAlreadyInFamily`] if the node has somehow joined another family +/// since the invitation was issued, [`InvitationNotFound`] if no pending +/// invitation exists for the pair, and [`InvitationExpired`] if it has. +/// +/// [`SenderDoesntControlNode`]: NodeFamiliesContractError::SenderDoesntControlNode +/// [`NodeAlreadyInFamily`]: NodeFamiliesContractError::NodeAlreadyInFamily +/// [`InvitationNotFound`]: NodeFamiliesContractError::InvitationNotFound +/// [`InvitationExpired`]: NodeFamiliesContractError::InvitationExpired +pub(crate) fn try_accept_family_invitation( + deps: DepsMut, + env: Env, + info: MessageInfo, + family_id: NodeFamilyId, + node_id: NodeId, +) -> Result { + let storage = NodeFamiliesStorage::new(); + + ensure_has_bonded_node(&storage, deps.as_ref(), &info.sender, node_id)?; + ensure_node_not_in_family(&storage, deps.as_ref(), node_id)?; + + storage.accept_invitation(deps.storage, &env, family_id, node_id)?; + + Ok(Response::new().add_event( + Event::new(events::FAMILY_INVITATION_ACCEPTED_EVENT_NAME) + .add_attribute( + events::FAMILY_INVITATION_ACCEPTED_EVENT_FAMILY_ID, + family_id.to_string(), + ) + .add_attribute( + events::FAMILY_INVITATION_ACCEPTED_EVENT_NODE_ID, + node_id.to_string(), + ), + )) +} + +/// Reject the pending invitation for `node_id` to join `family_id`. +/// +/// `info.sender` must be the bond controller of `node_id` per the mixnet +/// contract — rejection is the invitee's choice. Errors with +/// [`SenderDoesntControlNode`] if the sender doesn't control `node_id` (or +/// the node is unbonding) and [`InvitationNotFound`] if no pending invitation +/// exists for the pair. Expired invitations *can* be rejected — symmetric +/// with revocation, this is also a path that cleans them out of the pending +/// map. +/// +/// [`SenderDoesntControlNode`]: NodeFamiliesContractError::SenderDoesntControlNode +/// [`InvitationNotFound`]: NodeFamiliesContractError::InvitationNotFound +pub(crate) fn try_reject_family_invitation( + deps: DepsMut, + env: Env, + info: MessageInfo, + family_id: NodeFamilyId, + node_id: NodeId, +) -> Result { + let storage = NodeFamiliesStorage::new(); + + ensure_has_bonded_node(&storage, deps.as_ref(), &info.sender, node_id)?; + + storage.reject_pending_invitation(deps.storage, &env, family_id, node_id)?; + + Ok(Response::new().add_event( + Event::new(events::FAMILY_INVITATION_REJECTED_EVENT_NAME) + .add_attribute( + events::FAMILY_INVITATION_REJECTED_EVENT_FAMILY_ID, + family_id.to_string(), + ) + .add_attribute( + events::FAMILY_INVITATION_REJECTED_EVENT_NODE_ID, + node_id.to_string(), + ), + )) +} + +/// Remove `node_id` from whichever family it currently belongs to, at the +/// request of the node's controller. +/// +/// `info.sender` must be the bond controller of `node_id` per the mixnet +/// contract — a node only leaves of its own accord. Errors with +/// [`SenderDoesntControlNode`] if the sender doesn't control `node_id` (or +/// the node is unbonding) and [`NodeNotInFamily`] if the node isn't currently +/// a member of any family. +/// +/// The mixnet-contract unbonding callback drops a node from its family +/// independently (see [`try_handle_node_unbonding`]); this handler is the +/// voluntary-leave path and is not the one fired on unbond. +/// +/// [`SenderDoesntControlNode`]: NodeFamiliesContractError::SenderDoesntControlNode +/// [`NodeNotInFamily`]: NodeFamiliesContractError::NodeNotInFamily +pub(crate) fn try_leave_family( + deps: DepsMut, + env: Env, + info: MessageInfo, + node_id: NodeId, +) -> Result { + let storage = NodeFamiliesStorage::new(); + + ensure_has_bonded_node(&storage, deps.as_ref(), &info.sender, node_id)?; + + let family = storage.remove_family_member(deps.storage, &env, node_id)?; + + Ok(Response::new().add_event( + Event::new(events::FAMILY_MEMBER_LEFT_EVENT_NAME) + .add_attribute( + events::FAMILY_MEMBER_LEFT_EVENT_FAMILY_ID, + family.id.to_string(), + ) + .add_attribute( + events::FAMILY_MEMBER_LEFT_EVENT_NODE_ID, + node_id.to_string(), + ), + )) +} + +/// Kick `node_id` out of the family owned by `info.sender`. +/// +/// Owner-gated: the family acted on is derived from `info.sender`'s ownership +/// rather than passed as an argument, so a sender cannot kick from another +/// family. Errors with [`SenderDoesntOwnAFamily`] if the sender owns no +/// family, [`NodeNotInFamily`] if the node has no membership at all, and +/// [`NodeNotMemberOfFamily`] if the node is in a different family — the +/// scope check happens at the tx layer because `family_members` is keyed by +/// node only and `remove_family_member` would otherwise silently strip a +/// node from someone else's family. +/// +/// [`SenderDoesntOwnAFamily`]: NodeFamiliesContractError::SenderDoesntOwnAFamily +/// [`NodeNotInFamily`]: NodeFamiliesContractError::NodeNotInFamily +/// [`NodeNotMemberOfFamily`]: NodeFamiliesContractError::NodeNotMemberOfFamily +pub(crate) fn try_kick_from_family( + deps: DepsMut, + env: Env, + info: MessageInfo, + node_id: NodeId, +) -> Result { + let storage = NodeFamiliesStorage::new(); + + let owned = storage.must_get_owned_family(deps.storage, &info.sender)?; + + let membership = storage + .family_members + .may_load(deps.storage, node_id)? + .ok_or(NodeFamiliesContractError::NodeNotInFamily { node_id })?; + if membership.family_id != owned.id { + return Err(NodeFamiliesContractError::NodeNotMemberOfFamily { + node_id, + family_id: owned.id, + }); + } + + storage.remove_family_member(deps.storage, &env, node_id)?; + + Ok(Response::new().add_event( + Event::new(events::FAMILY_MEMBER_KICKED_EVENT_NAME) + .add_attribute( + events::FAMILY_MEMBER_KICKED_EVENT_FAMILY_ID, + owned.id.to_string(), + ) + .add_attribute( + events::FAMILY_MEMBER_KICKED_EVENT_NODE_ID, + node_id.to_string(), + ), + )) +} + +/// Cross-contract callback fired by the mixnet contract the moment `node_id` +/// initiates unbonding. Unbonding is irreversible, so from the families +/// contract's perspective the node is already effectively gone — drop it +/// from any family it currently belongs to and clear every pending +/// invitation issued to it. +/// +/// Auth: `info.sender` must equal the configured `mixnet_contract_address`, +/// since the mixnet contract is the only authority that can attest a node +/// has unbonded. Errors with [`UnauthorisedMixnetCallback`] otherwise. +/// +/// The membership half is idempotent — a node that initiates unbonding +/// without ever joining a family is the common case and is not an error. +/// Swept invitations are archived as +/// [`FamilyInvitationStatus::Rejected`]: the auto-cleanup shares the +/// `Rejected` terminal state with invitations that would have been +/// explicitly declined by the node controller. +/// +/// [`UnauthorisedMixnetCallback`]: NodeFamiliesContractError::UnauthorisedMixnetCallback +pub(crate) fn try_handle_node_unbonding( + deps: DepsMut, + env: Env, + info: MessageInfo, + node_id: NodeId, +) -> Result { + let storage = NodeFamiliesStorage::new(); + + let mixnet_contract = storage.mixnet_contract_address.load(deps.storage)?; + if info.sender != mixnet_contract { + return Err(NodeFamiliesContractError::UnauthorisedMixnetCallback { + sender: info.sender, + }); + } + + storage.handle_node_unbonding(deps.storage, &env, node_id)?; + + Ok(Response::new().add_event( + Event::new(events::NODE_UNBOND_CLEANUP_EVENT_NAME).add_attribute( + events::NODE_UNBOND_CLEANUP_EVENT_NODE_ID, + node_id.to_string(), + ), + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::init_contract_tester; + use cosmwasm_std::coin; + use cosmwasm_std::testing::message_info; + use cw_controllers::AdminError; + use nym_contracts_common_testing::{AdminExt, ContractOpts, RandExt}; + + fn updated_config() -> Config { + Config { + create_family_fee: coin(999, "unym"), + family_name_length_limit: 1, + family_description_length_limit: 2, + default_invitation_validity_secs: 60, + } + } + + #[test] + fn admin_can_replace_the_config() { + let mut tester = init_contract_tester(); + let admin = tester.admin_msg(); + let new_config = updated_config(); + let env = tester.env(); + let res = try_update_config(tester.deps_mut(), env, admin, new_config.clone()); + assert!(res.is_ok()); + + let stored = NodeFamiliesStorage::new() + .config + .load(tester.deps().storage) + .unwrap(); + assert_eq!(stored, new_config); + } + + #[test] + fn non_admin_cannot_update_the_config() { + let mut tester = init_contract_tester(); + let not_admin = tester.generate_account(); + let not_admin = message_info(¬_admin, &[]); + + let original = NodeFamiliesStorage::new() + .config + .load(tester.deps().storage) + .unwrap(); + + let env = tester.env(); + let err = + try_update_config(tester.deps_mut(), env, not_admin, updated_config()).unwrap_err(); + + assert_eq!( + err, + NodeFamiliesContractError::Admin(AdminError::NotAdmin {}) + ); + + // config left untouched + let stored = NodeFamiliesStorage::new() + .config + .load(tester.deps().storage) + .unwrap(); + assert_eq!(stored, original); + } + + mod create_family { + use super::*; + use crate::testing::NodeFamiliesContractTesterExt; + use cosmwasm_std::coins; + use cw_utils::PaymentError; + use mixnet_contract::testable_mixnet_contract::EmbeddedMixnetContractExt; + use nym_contracts_common_testing::TEST_DENOM; + + #[test] + fn happy_path_persists_family_preserving_submitted_name() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let fee = tester.family_fee(); + let alice = tester.make_sender_with_funds("alice", &[fee]); + let env = tester.env(); + let deps = tester.deps_mut(); + + // user-submitted formatting includes punctuation + casing that + // the normaliser strips; both forms should end up on the stored + // record. + try_create_family( + deps, + env, + alice.clone(), + "My Family!".to_string(), + "description".to_string(), + )?; + + let storage = NodeFamiliesStorage::new(); + let family = storage.families.load(tester.deps().storage, 1)?; + assert_eq!(family.id, 1); + assert_eq!(family.name, "My Family!"); + assert_eq!(family.normalised_name, "myfamily"); + assert_eq!(family.owner, alice.sender); + assert_eq!(family.description, "description"); + assert_eq!(family.paid_fee, alice.funds[0]); + assert_eq!(family.members, 0); + + Ok(()) + } + + #[test] + fn rejects_when_no_funds_attached() { + let mut tester = init_contract_tester(); + let alice = tester.make_sender_with_funds("alice", &[]); + let env = tester.env(); + let deps = tester.deps_mut(); + + let err = try_create_family( + deps, + env, + alice.clone(), + "name".to_string(), + "description".to_string(), + ) + .unwrap_err(); + assert_eq!( + err, + NodeFamiliesContractError::InvalidDeposit(PaymentError::NoFunds {}) + ); + } + + #[test] + fn rejects_when_fee_amount_mismatched() { + let mut tester = init_contract_tester(); + let fee = tester.family_fee(); + let too_little = coins(fee.amount.u128() - 1, fee.denom.clone()); + let alice = tester.make_sender_with_funds("alice", &too_little); + let env = tester.env(); + let deps = tester.deps_mut(); + + let err = try_create_family( + deps, + env, + alice.clone(), + "name".to_string(), + "description".to_string(), + ) + .unwrap_err(); + assert_eq!( + err, + NodeFamiliesContractError::InvalidFamilyCreationFee { + expected: fee, + received: too_little, + } + ); + } + + #[test] + fn rejects_when_fee_denom_mismatched() { + let mut tester = init_contract_tester(); + + let fee = tester.family_fee(); + let wrong_denom = coins(fee.amount.u128(), "uatom"); + let alice = tester.make_sender_with_funds("alice", &wrong_denom); + let env = tester.env(); + let deps = tester.deps_mut(); + + let err = try_create_family( + deps, + env, + alice.clone(), + "name".to_string(), + "description".to_string(), + ) + .unwrap_err(); + assert_eq!( + err, + NodeFamiliesContractError::InvalidDeposit(PaymentError::MissingDenom( + TEST_DENOM.to_string() + )) + ); + } + + #[test] + fn rejects_name_exceeding_length_limit() { + let mut tester = init_contract_tester(); + + let fee = tester.family_fee(); + let alice = tester.make_sender_with_funds("alice", &[fee]); + + let limit = NodeFamiliesStorage::new() + .config + .load(tester.deps().storage) + .unwrap() + .family_name_length_limit; + let too_long: String = "a".repeat(limit + 1); + let env = tester.env(); + let deps = tester.deps_mut(); + + let err = try_create_family( + deps, + env, + alice, + too_long.to_string(), + "description".to_string(), + ) + .unwrap_err(); + assert_eq!( + err, + NodeFamiliesContractError::FamilyNameTooLong { + length: limit + 1, + limit, + } + ); + } + + #[test] + fn rejects_description_exceeding_length_limit() { + let mut tester = init_contract_tester(); + + let fee = tester.family_fee(); + let alice = tester.make_sender_with_funds("alice", &[fee]); + + let limit = NodeFamiliesStorage::new() + .config + .load(tester.deps().storage) + .unwrap() + .family_description_length_limit; + let too_long: String = "a".repeat(limit + 1); + let env = tester.env(); + let deps = tester.deps_mut(); + + let err = try_create_family(deps, env, alice, "name".to_string(), too_long.to_string()) + .unwrap_err(); + + assert_eq!( + err, + NodeFamiliesContractError::FamilyDescriptionTooLong { + length: limit + 1, + limit, + } + ); + } + + #[test] + fn rejects_name_that_normalises_to_empty() { + let mut tester = init_contract_tester(); + let env = tester.env(); + let alice = tester.make_sender_with_funds("alice", &[tester.family_fee()]); + let deps = tester.deps_mut(); + + let err = try_create_family( + deps, + env, + alice.clone(), + "!!! ---".to_string(), + "".to_string(), + ) + .unwrap_err(); + + assert_eq!(err, NodeFamiliesContractError::EmptyFamilyName); + } + + #[test] + fn rejects_when_sender_already_owns_a_family() { + let mut tester = init_contract_tester(); + let env = tester.env(); + let alice = tester.make_sender_with_funds("alice", &[tester.family_fee()]); + + tester.make_family(&alice.sender); + let deps = tester.deps_mut(); + + let err = try_create_family( + deps, + env, + alice.clone(), + "name".to_string(), + "description".to_string(), + ) + .unwrap_err(); + + assert_eq!( + err, + NodeFamiliesContractError::SenderAlreadyOwnsAFamily { + address: alice.sender, + family_id: 1, + } + ); + } + + #[test] + fn rejects_when_normalised_name_is_already_taken() { + let mut tester = init_contract_tester(); + let fee = vec![tester.family_fee()]; + let alice = tester.make_sender_with_funds("alice", &fee); + let bob = tester.make_sender_with_funds("bob", &fee); + let env = tester.env(); + + tester.make_named_family(&alice.sender, "MyFamily"); + let deps = tester.deps_mut(); + + // different casing / punctuation, same normalised form + let err = try_create_family( + deps, + env, + bob.clone(), + "$$myFaMiLy$$".to_string(), + "description".to_string(), + ) + .unwrap_err(); + + assert_eq!( + err, + NodeFamiliesContractError::FamilyNameAlreadyTaken { + name: "myfamily".to_string(), + family_id: 1, + } + ); + } + + #[test] + fn rejects_when_owner_owns_node_in_different_family() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let fee = tester.family_fee(); + let alice = tester.generate_account_with_balance(); + let env = tester.env(); + + let other_family = tester.make_family(&tester.addr_make("bob")); + let node_id = tester.bond_dummy_nymnode_for(&alice)?; + + let alice = message_info(&alice, &[fee]); + + // has node which is not in a family - that's still allowed! + let deps = tester.deps_mut(); + try_create_family( + deps, + env.clone(), + alice.clone(), + "My Family!".to_string(), + "description".to_string(), + )?; + tester.disband_family(2); + + // after joining family we error out + tester.add_to_family(other_family.id, node_id); + + let deps = tester.deps_mut(); + let err = try_create_family( + deps, + env.clone(), + alice.clone(), + "My Family!".to_string(), + "description".to_string(), + ) + .unwrap_err(); + assert_eq!( + err, + NodeFamiliesContractError::AlreadyInFamily { + address: alice.sender.clone(), + node_id, + family_id: other_family.id, + } + ); + + // after unbonding it is fine again + tester.unbond_nymnode(node_id)?; + let deps = tester.deps_mut(); + try_create_family( + deps, + env.clone(), + alice.clone(), + "My Family!".to_string(), + "description".to_string(), + )?; + + Ok(()) + } + } + + mod disband_family { + use super::*; + use crate::testing::NodeFamiliesContractTesterExt; + use cosmwasm_std::{BankMsg, CosmosMsg, SubMsg}; + + #[test] + fn happy_path_removes_family_and_refunds_fee() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let family = tester.make_family(&alice); + let info = message_info(&alice, &[]); + let env = tester.env(); + + let res = try_disband_family(tester.deps_mut(), env, info)?; + + // family is gone from storage + let storage = NodeFamiliesStorage::new(); + assert!(storage + .families + .may_load(tester.deps().storage, family.id)? + .is_none()); + + // single bank refund attached, going to the original owner with the + // exact paid fee + assert_eq!(res.messages.len(), 1); + assert_eq!( + res.messages[0], + SubMsg::new(CosmosMsg::Bank(BankMsg::Send { + to_address: alice.to_string(), + amount: vec![family.paid_fee.clone()], + })) + ); + + Ok(()) + } + + #[test] + fn rejects_when_sender_owns_no_family() { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let info = message_info(&alice, &[]); + let env = tester.env(); + + let err = try_disband_family(tester.deps_mut(), env, info).unwrap_err(); + assert_eq!( + err, + NodeFamiliesContractError::SenderDoesntOwnAFamily { address: alice } + ); + } + + #[test] + fn rejects_when_family_still_has_members() { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let family = tester.make_family(&alice); + tester.add_to_family(family.id, 42); + + let info = message_info(&alice, &[]); + let env = tester.env(); + let err = try_disband_family(tester.deps_mut(), env, info).unwrap_err(); + assert_eq!( + err, + NodeFamiliesContractError::FamilyNotEmpty { + family_id: family.id, + members: 1, + } + ); + } + + #[test] + fn after_disband_owner_can_create_a_new_family() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + tester.make_family(&alice); + + let env = tester.env(); + try_disband_family(tester.deps_mut(), env.clone(), message_info(&alice, &[]))?; + + // owner-index slot freed → a fresh create_family should succeed + let fee = tester.family_fee(); + let alice_with_fee = message_info(&alice, &[fee]); + try_create_family( + tester.deps_mut(), + env, + alice_with_fee, + "second".to_string(), + "".to_string(), + )?; + Ok(()) + } + } + + mod invite_to_family { + use super::*; + use crate::testing::NodeFamiliesContractTesterExt; + use mixnet_contract::testable_mixnet_contract::EmbeddedMixnetContractExt; + + #[test] + fn happy_path_persists_pending_invitation() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let family = tester.make_family(&alice); + let node_id = tester.bond_dummy_nymnode()?; + + let env = tester.env(); + let info = message_info(&alice, &[]); + try_invite_to_family(tester.deps_mut(), env.clone(), info, node_id, None)?; + + let storage = NodeFamiliesStorage::new(); + let invitation = storage + .pending_family_invitations + .load(tester.deps().storage, (family.id, node_id))?; + assert_eq!(invitation.family_id, family.id); + assert_eq!(invitation.node_id, node_id); + + let default_validity = storage + .config + .load(tester.deps().storage)? + .default_invitation_validity_secs; + assert_eq!( + invitation.expires_at, + env.block.time.seconds() + default_validity + ); + Ok(()) + } + + #[test] + fn custom_validity_overrides_default() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let family = tester.make_family(&alice); + let node_id = tester.bond_dummy_nymnode()?; + + let env = tester.env(); + let info = message_info(&alice, &[]); + try_invite_to_family(tester.deps_mut(), env.clone(), info, node_id, Some(5))?; + + let invitation = NodeFamiliesStorage::new() + .pending_family_invitations + .load(tester.deps().storage, (family.id, node_id))?; + assert_eq!(invitation.expires_at, env.block.time.seconds() + 5); + Ok(()) + } + + #[test] + fn rejects_zero_validity() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + tester.make_family(&alice); + let node_id = tester.bond_dummy_nymnode()?; + + let env = tester.env(); + let info = message_info(&alice, &[]); + let err = + try_invite_to_family(tester.deps_mut(), env, info, node_id, Some(0)).unwrap_err(); + assert_eq!(err, NodeFamiliesContractError::ZeroInvitationValidity); + Ok(()) + } + + #[test] + fn rejects_when_sender_owns_no_family() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let node_id = tester.bond_dummy_nymnode()?; + + let env = tester.env(); + let info = message_info(&alice, &[]); + let err = + try_invite_to_family(tester.deps_mut(), env, info, node_id, None).unwrap_err(); + assert_eq!( + err, + NodeFamiliesContractError::SenderDoesntOwnAFamily { address: alice } + ); + Ok(()) + } + + #[test] + fn rejects_when_node_is_not_bonded() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + tester.make_family(&alice); + + let env = tester.env(); + let info = message_info(&alice, &[]); + let err = try_invite_to_family(tester.deps_mut(), env, info, 999, None).unwrap_err(); + assert_eq!( + err, + NodeFamiliesContractError::NodeDoesntExist { node_id: 999 } + ); + Ok(()) + } + + #[test] + fn rejects_when_node_is_already_in_a_family() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let alice_family = tester.make_family(&alice); + let bob = tester.addr_make("bob"); + let bob_family = tester.make_family(&bob); + + let node_id = tester.bond_dummy_nymnode()?; + tester.add_to_family(bob_family.id, node_id); + + let env = tester.env(); + let info = message_info(&alice, &[]); + let err = + try_invite_to_family(tester.deps_mut(), env, info, node_id, None).unwrap_err(); + assert_eq!( + err, + NodeFamiliesContractError::NodeAlreadyInFamily { + node_id, + family_id: bob_family.id, + } + ); + // alice's family is unchanged + assert!(NodeFamiliesStorage::new() + .pending_family_invitations + .may_load(tester.deps().storage, (alice_family.id, node_id))? + .is_none()); + Ok(()) + } + + #[test] + fn rejects_duplicate_pending_invitation() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let family = tester.make_family(&alice); + let node_id = tester.bond_dummy_nymnode()?; + + let env = tester.env(); + try_invite_to_family( + tester.deps_mut(), + env.clone(), + message_info(&alice, &[]), + node_id, + None, + )?; + let err = try_invite_to_family( + tester.deps_mut(), + env, + message_info(&alice, &[]), + node_id, + None, + ) + .unwrap_err(); + assert_eq!( + err, + NodeFamiliesContractError::PendingInvitationAlreadyExists { + family_id: family.id, + node_id, + } + ); + Ok(()) + } + } + + mod revoke_family_invitation { + use super::*; + use crate::testing::NodeFamiliesContractTesterExt; + use nym_node_families_contract_common::FamilyInvitationStatus; + + #[test] + fn happy_path_removes_pending_and_archives_revoked() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let family = tester.make_family(&alice); + let node_id = 7; + tester.invite_to_family(family.id, node_id); + + let env = tester.env(); + try_revoke_family_invitation( + tester.deps_mut(), + env.clone(), + message_info(&alice, &[]), + node_id, + )?; + + let storage = NodeFamiliesStorage::new(); + assert!(storage + .pending_family_invitations + .may_load(tester.deps().storage, (family.id, node_id))? + .is_none()); + + let archived = storage + .past_family_invitations + .load(tester.deps().storage, ((family.id, node_id), 0))?; + assert!(matches!( + archived.status, + FamilyInvitationStatus::Revoked { at } if at == env.block.time.seconds() + )); + Ok(()) + } + + #[test] + fn rejects_when_sender_owns_no_family() { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let env = tester.env(); + + let err = + try_revoke_family_invitation(tester.deps_mut(), env, message_info(&alice, &[]), 42) + .unwrap_err(); + assert_eq!( + err, + NodeFamiliesContractError::SenderDoesntOwnAFamily { address: alice } + ); + } + + #[test] + fn rejects_when_no_pending_invitation_for_node() { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let family = tester.make_family(&alice); + let env = tester.env(); + + let err = + try_revoke_family_invitation(tester.deps_mut(), env, message_info(&alice, &[]), 42) + .unwrap_err(); + assert_eq!( + err, + NodeFamiliesContractError::InvitationNotFound { + family_id: family.id, + node_id: 42, + } + ); + } + + #[test] + fn cannot_revoke_another_familys_invitation() { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let bob = tester.addr_make("bob"); + tester.make_family(&alice); + let bob_family = tester.make_family(&bob); + let node_id = 7; + tester.invite_to_family(bob_family.id, node_id); + + // alice is targeting node 7 — but there is no pending invitation + // in *her* family for it, so the lookup misses against alice's id + let env = tester.env(); + let err = try_revoke_family_invitation( + tester.deps_mut(), + env, + message_info(&alice, &[]), + node_id, + ) + .unwrap_err(); + assert_eq!( + err, + NodeFamiliesContractError::InvitationNotFound { + family_id: 1, + node_id, + } + ); + + // bob's invitation is still pending and untouched + let still_pending = NodeFamiliesStorage::new() + .pending_family_invitations + .may_load(tester.deps().storage, (bob_family.id, node_id)) + .unwrap(); + assert!(still_pending.is_some()); + } + + #[test] + fn revoking_expired_invitation_is_allowed() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let family = tester.make_family(&alice); + let node_id = 7; + // already-expired (expires_at = 1, well before block.time) + tester.invite_to_family_with_expiration(family.id, node_id, 1); + + let env = tester.env(); + try_revoke_family_invitation( + tester.deps_mut(), + env, + message_info(&alice, &[]), + node_id, + )?; + + // pending entry is gone + assert!(NodeFamiliesStorage::new() + .pending_family_invitations + .may_load(tester.deps().storage, (family.id, node_id))? + .is_none()); + Ok(()) + } + } + + mod accept_family_invitation { + use super::*; + use crate::testing::NodeFamiliesContractTesterExt; + use mixnet_contract::testable_mixnet_contract::EmbeddedMixnetContractExt; + use nym_node_families_contract_common::FamilyInvitationStatus; + + #[test] + fn happy_path_records_membership_and_archives_accepted() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let family = tester.make_family(&alice); + + let bob = tester.generate_account_with_balance(); + let node_id = tester.bond_dummy_nymnode_for(&bob)?; + tester.invite_to_family(family.id, node_id); + + let env = tester.env(); + try_accept_family_invitation( + tester.deps_mut(), + env.clone(), + message_info(&bob, &[]), + family.id, + node_id, + )?; + + let storage = NodeFamiliesStorage::new(); + assert!(storage + .pending_family_invitations + .may_load(tester.deps().storage, (family.id, node_id))? + .is_none()); + + let membership = storage + .family_members + .load(tester.deps().storage, node_id)?; + assert_eq!(membership.family_id, family.id); + assert_eq!(membership.joined_at, env.block.time.seconds()); + + let updated = storage.families.load(tester.deps().storage, family.id)?; + assert_eq!(updated.members, 1); + + let archived = storage + .past_family_invitations + .load(tester.deps().storage, ((family.id, node_id), 0))?; + assert!(matches!( + archived.status, + FamilyInvitationStatus::Accepted { at } if at == env.block.time.seconds() + )); + Ok(()) + } + + #[test] + fn rejects_when_sender_controls_no_bonded_node() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let family = tester.make_family(&alice); + let node_id = tester.bond_dummy_nymnode()?; + tester.invite_to_family(family.id, node_id); + + // mallory doesn't control any bonded node + let mallory = tester.addr_make("mallory"); + let env = tester.env(); + let err = try_accept_family_invitation( + tester.deps_mut(), + env, + message_info(&mallory, &[]), + family.id, + node_id, + ) + .unwrap_err(); + assert_eq!( + err, + NodeFamiliesContractError::SenderDoesntControlNode { + address: mallory, + node_id, + } + ); + Ok(()) + } + + #[test] + fn rejects_when_sender_controls_a_different_node() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let family = tester.make_family(&alice); + + let bob = tester.generate_account_with_balance(); + let bob_node = tester.bond_dummy_nymnode_for(&bob)?; + // invitation targets a different (also-bonded) node + let other_node = tester.bond_dummy_nymnode()?; + tester.invite_to_family(family.id, other_node); + + let env = tester.env(); + let err = try_accept_family_invitation( + tester.deps_mut(), + env, + message_info(&bob, &[]), + family.id, + other_node, + ) + .unwrap_err(); + assert_eq!( + err, + NodeFamiliesContractError::SenderDoesntControlNode { + address: bob, + node_id: other_node, + } + ); + // sanity: bob really does control bob_node + assert_ne!(bob_node, other_node); + Ok(()) + } + + #[test] + fn rejects_when_sender_node_is_unbonding() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let family = tester.make_family(&alice); + let bob = tester.generate_account_with_balance(); + let node_id = tester.bond_dummy_nymnode_for(&bob)?; + tester.invite_to_family(family.id, node_id); + + // unbond_nymnode advances the epoch, fully removing the bond from + // the mixnet store — the ownership query then returns None, which + // surfaces as the same SenderDoesntControlNode error as + // "no bonded node at all". + tester.unbond_nymnode(node_id)?; + + let env = tester.env(); + let err = try_accept_family_invitation( + tester.deps_mut(), + env, + message_info(&bob, &[]), + family.id, + node_id, + ) + .unwrap_err(); + assert_eq!( + err, + NodeFamiliesContractError::SenderDoesntControlNode { + address: bob, + node_id, + } + ); + Ok(()) + } + + #[test] + fn rejects_when_no_pending_invitation_exists() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let family = tester.make_family(&alice); + let bob = tester.generate_account_with_balance(); + let node_id = tester.bond_dummy_nymnode_for(&bob)?; + + let env = tester.env(); + let err = try_accept_family_invitation( + tester.deps_mut(), + env, + message_info(&bob, &[]), + family.id, + node_id, + ) + .unwrap_err(); + assert_eq!( + err, + NodeFamiliesContractError::InvitationNotFound { + family_id: family.id, + node_id, + } + ); + Ok(()) + } + + #[test] + fn rejects_when_invitation_is_expired() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let family = tester.make_family(&alice); + let bob = tester.generate_account_with_balance(); + let node_id = tester.bond_dummy_nymnode_for(&bob)?; + + let env = tester.env(); + // expires_at == now triggers the `now >= expires_at` branch + tester.invite_to_family_with_expiration(family.id, node_id, env.block.time.seconds()); + + let err = try_accept_family_invitation( + tester.deps_mut(), + env.clone(), + message_info(&bob, &[]), + family.id, + node_id, + ) + .unwrap_err(); + assert_eq!( + err, + NodeFamiliesContractError::InvitationExpired { + family_id: family.id, + node_id, + expires_at: env.block.time.seconds(), + now: env.block.time.seconds(), + } + ); + Ok(()) + } + + #[test] + fn rejects_when_node_already_in_another_family() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let alice_family = tester.make_family(&alice); + let bob = tester.addr_make("bob"); + let bob_family = tester.make_family(&bob); + + let bob = tester.generate_account_with_balance(); + let node_id = tester.bond_dummy_nymnode_for(&bob)?; + + // bob's node joins bob's family first, then tries to accept + // alice's still-pending invitation + tester.invite_to_family(alice_family.id, node_id); + tester.add_to_family(bob_family.id, node_id); + + let env = tester.env(); + let err = try_accept_family_invitation( + tester.deps_mut(), + env, + message_info(&bob, &[]), + alice_family.id, + node_id, + ) + .unwrap_err(); + assert_eq!( + err, + NodeFamiliesContractError::NodeAlreadyInFamily { + node_id, + family_id: bob_family.id, + } + ); + + // membership is unchanged — still in bob's family + let membership = NodeFamiliesStorage::new() + .family_members + .load(tester.deps().storage, node_id)?; + assert_eq!(membership.family_id, bob_family.id); + Ok(()) + } + } + + mod reject_family_invitation { + use super::*; + use crate::testing::NodeFamiliesContractTesterExt; + use mixnet_contract::testable_mixnet_contract::EmbeddedMixnetContractExt; + use nym_node_families_contract_common::FamilyInvitationStatus; + + #[test] + fn happy_path_removes_pending_and_archives_rejected() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let family = tester.make_family(&alice); + + let bob = tester.generate_account_with_balance(); + let node_id = tester.bond_dummy_nymnode_for(&bob)?; + tester.invite_to_family(family.id, node_id); + + let env = tester.env(); + try_reject_family_invitation( + tester.deps_mut(), + env.clone(), + message_info(&bob, &[]), + family.id, + node_id, + )?; + + let storage = NodeFamiliesStorage::new(); + assert!(storage + .pending_family_invitations + .may_load(tester.deps().storage, (family.id, node_id))? + .is_none()); + + let archived = storage + .past_family_invitations + .load(tester.deps().storage, ((family.id, node_id), 0))?; + assert!(matches!( + archived.status, + FamilyInvitationStatus::Rejected { at } if at == env.block.time.seconds() + )); + + // membership was never recorded + assert!(storage + .family_members + .may_load(tester.deps().storage, node_id)? + .is_none()); + Ok(()) + } + + #[test] + fn rejects_when_sender_controls_no_bonded_node() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let family = tester.make_family(&alice); + let node_id = tester.bond_dummy_nymnode()?; + tester.invite_to_family(family.id, node_id); + + let mallory = tester.addr_make("mallory"); + let env = tester.env(); + let err = try_reject_family_invitation( + tester.deps_mut(), + env, + message_info(&mallory, &[]), + family.id, + node_id, + ) + .unwrap_err(); + assert_eq!( + err, + NodeFamiliesContractError::SenderDoesntControlNode { + address: mallory, + node_id, + } + ); + Ok(()) + } + + #[test] + fn rejects_when_sender_controls_a_different_node() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let family = tester.make_family(&alice); + + let bob = tester.generate_account_with_balance(); + let bob_node = tester.bond_dummy_nymnode_for(&bob)?; + let other_node = tester.bond_dummy_nymnode()?; + tester.invite_to_family(family.id, other_node); + + let env = tester.env(); + let err = try_reject_family_invitation( + tester.deps_mut(), + env, + message_info(&bob, &[]), + family.id, + other_node, + ) + .unwrap_err(); + assert_eq!( + err, + NodeFamiliesContractError::SenderDoesntControlNode { + address: bob, + node_id: other_node, + } + ); + assert_ne!(bob_node, other_node); + Ok(()) + } + + #[test] + fn rejects_when_sender_node_is_unbonding() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let family = tester.make_family(&alice); + let bob = tester.generate_account_with_balance(); + let node_id = tester.bond_dummy_nymnode_for(&bob)?; + tester.invite_to_family(family.id, node_id); + + tester.unbond_nymnode(node_id)?; + + let env = tester.env(); + let err = try_reject_family_invitation( + tester.deps_mut(), + env, + message_info(&bob, &[]), + family.id, + node_id, + ) + .unwrap_err(); + assert_eq!( + err, + NodeFamiliesContractError::SenderDoesntControlNode { + address: bob, + node_id, + } + ); + Ok(()) + } + + #[test] + fn rejects_when_no_pending_invitation_exists() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let family = tester.make_family(&alice); + let bob = tester.generate_account_with_balance(); + let node_id = tester.bond_dummy_nymnode_for(&bob)?; + + let env = tester.env(); + let err = try_reject_family_invitation( + tester.deps_mut(), + env, + message_info(&bob, &[]), + family.id, + node_id, + ) + .unwrap_err(); + assert_eq!( + err, + NodeFamiliesContractError::InvitationNotFound { + family_id: family.id, + node_id, + } + ); + Ok(()) + } + + #[test] + fn rejecting_expired_invitation_is_allowed() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let family = tester.make_family(&alice); + let bob = tester.generate_account_with_balance(); + let node_id = tester.bond_dummy_nymnode_for(&bob)?; + + let env = tester.env(); + // already-expired (expires_at == now) + tester.invite_to_family_with_expiration(family.id, node_id, env.block.time.seconds()); + + try_reject_family_invitation( + tester.deps_mut(), + env.clone(), + message_info(&bob, &[]), + family.id, + node_id, + )?; + + let storage = NodeFamiliesStorage::new(); + assert!(storage + .pending_family_invitations + .may_load(tester.deps().storage, (family.id, node_id))? + .is_none()); + let archived = storage + .past_family_invitations + .load(tester.deps().storage, ((family.id, node_id), 0))?; + assert!(matches!( + archived.status, + FamilyInvitationStatus::Rejected { at } if at == env.block.time.seconds() + )); + Ok(()) + } + } + + mod leave_family { + use super::*; + use crate::testing::NodeFamiliesContractTesterExt; + use mixnet_contract::testable_mixnet_contract::EmbeddedMixnetContractExt; + + #[test] + fn happy_path_drops_membership_and_archives_past_member() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let family = tester.make_family(&alice); + + let bob = tester.generate_account_with_balance(); + let node_id = tester.bond_dummy_nymnode_for(&bob)?; + tester.add_to_family(family.id, node_id); + + // sanity: family has the member, count is 1 + let storage = NodeFamiliesStorage::new(); + assert_eq!( + storage + .families + .load(tester.deps().storage, family.id)? + .members, + 1 + ); + + let env = tester.env(); + try_leave_family( + tester.deps_mut(), + env.clone(), + message_info(&bob, &[]), + node_id, + )?; + + // membership gone + assert!(storage + .family_members + .may_load(tester.deps().storage, node_id)? + .is_none()); + + // family count decremented + let updated = storage.families.load(tester.deps().storage, family.id)?; + assert_eq!(updated.members, 0); + + // archived as past member + let past = storage + .past_family_members + .load(tester.deps().storage, ((family.id, node_id), 0))?; + assert_eq!(past.family_id, family.id); + assert_eq!(past.node_id, node_id); + assert_eq!(past.removed_at, env.block.time.seconds()); + Ok(()) + } + + #[test] + fn rejects_when_sender_controls_no_bonded_node() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let family = tester.make_family(&alice); + let node_id = tester.bond_dummy_nymnode()?; + tester.add_to_family(family.id, node_id); + + let mallory = tester.addr_make("mallory"); + let env = tester.env(); + let err = + try_leave_family(tester.deps_mut(), env, message_info(&mallory, &[]), node_id) + .unwrap_err(); + assert_eq!( + err, + NodeFamiliesContractError::SenderDoesntControlNode { + address: mallory, + node_id, + } + ); + Ok(()) + } + + #[test] + fn rejects_when_sender_controls_a_different_node() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let family = tester.make_family(&alice); + + let bob = tester.generate_account_with_balance(); + let bob_node = tester.bond_dummy_nymnode_for(&bob)?; + let other_node = tester.bond_dummy_nymnode()?; + tester.add_to_family(family.id, other_node); + + let env = tester.env(); + let err = try_leave_family(tester.deps_mut(), env, message_info(&bob, &[]), other_node) + .unwrap_err(); + assert_eq!( + err, + NodeFamiliesContractError::SenderDoesntControlNode { + address: bob, + node_id: other_node, + } + ); + assert_ne!(bob_node, other_node); + Ok(()) + } + + #[test] + fn rejects_when_sender_node_is_unbonding() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let family = tester.make_family(&alice); + let bob = tester.generate_account_with_balance(); + let node_id = tester.bond_dummy_nymnode_for(&bob)?; + tester.add_to_family(family.id, node_id); + + tester.unbond_nymnode(node_id)?; + + let env = tester.env(); + let err = try_leave_family(tester.deps_mut(), env, message_info(&bob, &[]), node_id) + .unwrap_err(); + assert_eq!( + err, + NodeFamiliesContractError::SenderDoesntControlNode { + address: bob, + node_id, + } + ); + Ok(()) + } + + #[test] + fn rejects_when_node_is_not_in_any_family() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let bob = tester.generate_account_with_balance(); + let node_id = tester.bond_dummy_nymnode_for(&bob)?; + + let env = tester.env(); + let err = try_leave_family(tester.deps_mut(), env, message_info(&bob, &[]), node_id) + .unwrap_err(); + assert_eq!(err, NodeFamiliesContractError::NodeNotInFamily { node_id }); + Ok(()) + } + } + + mod kick_from_family { + use super::*; + use crate::testing::NodeFamiliesContractTesterExt; + use mixnet_contract::testable_mixnet_contract::EmbeddedMixnetContractExt; + + #[test] + fn happy_path_drops_membership_and_archives_past_member() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let family = tester.make_family(&alice); + let node_id = tester.bond_dummy_nymnode()?; + tester.add_to_family(family.id, node_id); + + let env = tester.env(); + try_kick_from_family( + tester.deps_mut(), + env.clone(), + message_info(&alice, &[]), + node_id, + )?; + + let storage = NodeFamiliesStorage::new(); + assert!(storage + .family_members + .may_load(tester.deps().storage, node_id)? + .is_none()); + + let updated = storage.families.load(tester.deps().storage, family.id)?; + assert_eq!(updated.members, 0); + + let past = storage + .past_family_members + .load(tester.deps().storage, ((family.id, node_id), 0))?; + assert_eq!(past.family_id, family.id); + assert_eq!(past.node_id, node_id); + assert_eq!(past.removed_at, env.block.time.seconds()); + Ok(()) + } + + #[test] + fn rejects_when_sender_owns_no_family() { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let env = tester.env(); + + let err = try_kick_from_family(tester.deps_mut(), env, message_info(&alice, &[]), 42) + .unwrap_err(); + assert_eq!( + err, + NodeFamiliesContractError::SenderDoesntOwnAFamily { address: alice } + ); + } + + #[test] + fn rejects_when_node_is_not_in_any_family() { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + tester.make_family(&alice); + let env = tester.env(); + + let err = try_kick_from_family(tester.deps_mut(), env, message_info(&alice, &[]), 42) + .unwrap_err(); + assert_eq!( + err, + NodeFamiliesContractError::NodeNotInFamily { node_id: 42 } + ); + } + + #[test] + fn cannot_kick_member_of_another_family() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let bob = tester.addr_make("bob"); + let alice_family = tester.make_family(&alice); + let bob_family = tester.make_family(&bob); + + let node_id = tester.bond_dummy_nymnode()?; + tester.add_to_family(bob_family.id, node_id); + + // alice is targeting a node in bob's family — must error rather + // than silently strip the membership + let env = tester.env(); + let err = + try_kick_from_family(tester.deps_mut(), env, message_info(&alice, &[]), node_id) + .unwrap_err(); + assert_eq!( + err, + NodeFamiliesContractError::NodeNotMemberOfFamily { + node_id, + family_id: alice_family.id, + } + ); + + // bob's membership and family count are untouched + let storage = NodeFamiliesStorage::new(); + let mem = storage + .family_members + .load(tester.deps().storage, node_id)?; + assert_eq!(mem.family_id, bob_family.id); + let bob_fam = storage + .families + .load(tester.deps().storage, bob_family.id)?; + assert_eq!(bob_fam.members, 1); + Ok(()) + } + + #[test] + fn cannot_kick_member_already_cleared_by_unbond_callback() -> anyhow::Result<()> { + // The mixnet contract dispatches `OnNymNodeUnbond` to the families + // contract synchronously when a node initiates unbonding, so by + // the time `unbond_nymnode` returns the membership is already gone. + // A subsequent manual kick from the owner has nothing left to act + // on and surfaces `NodeNotInFamily`. + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let family = tester.make_family(&alice); + + let bob = tester.generate_account_with_balance(); + let node_id = tester.bond_dummy_nymnode_for(&bob)?; + tester.add_to_family(family.id, node_id); + tester.unbond_nymnode(node_id)?; + + // sanity: the unbond callback already cleaned up the membership + let storage = NodeFamiliesStorage::new(); + assert!(storage + .family_members + .may_load(tester.deps().storage, node_id)? + .is_none()); + + let env = tester.env(); + let err = + try_kick_from_family(tester.deps_mut(), env, message_info(&alice, &[]), node_id) + .unwrap_err(); + assert_eq!(err, NodeFamiliesContractError::NodeNotInFamily { node_id }); + Ok(()) + } + } + + mod handle_node_unbonding { + use super::*; + use crate::testing::NodeFamiliesContractTesterExt; + use cosmwasm_std::Addr; + use mixnet_contract::testable_mixnet_contract::EmbeddedMixnetContractExt; + use nym_node_families_contract_common::FamilyInvitationStatus; + + fn mixnet_addr(tester: &impl NodeFamiliesContractTesterExt) -> Addr { + NodeFamiliesStorage::new() + .mixnet_contract_address + .load(tester.deps().storage) + .unwrap() + } + + #[test] + fn rejects_when_sender_is_not_the_mixnet_contract() { + let mut tester = init_contract_tester(); + let mallory = tester.addr_make("mallory"); + let env = tester.env(); + + let err = + try_handle_node_unbonding(tester.deps_mut(), env, message_info(&mallory, &[]), 42) + .unwrap_err(); + assert_eq!( + err, + NodeFamiliesContractError::UnauthorisedMixnetCallback { sender: mallory } + ); + } + + #[test] + fn no_op_when_node_has_no_membership_and_no_invitations() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let env = tester.env(); + let mixnet = mixnet_addr(&tester); + + // node 42 has nothing in the contract — callback succeeds anyway + try_handle_node_unbonding(tester.deps_mut(), env, message_info(&mixnet, &[]), 42)?; + Ok(()) + } + + #[test] + fn drops_membership_when_node_is_a_member() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let family = tester.make_family(&alice); + let node_id = tester.bond_dummy_nymnode()?; + tester.add_to_family(family.id, node_id); + + let env = tester.env(); + let mixnet = mixnet_addr(&tester); + try_handle_node_unbonding( + tester.deps_mut(), + env.clone(), + message_info(&mixnet, &[]), + node_id, + )?; + + let storage = NodeFamiliesStorage::new(); + assert!(storage + .family_members + .may_load(tester.deps().storage, node_id)? + .is_none()); + let updated = storage.families.load(tester.deps().storage, family.id)?; + assert_eq!(updated.members, 0); + let past = storage + .past_family_members + .load(tester.deps().storage, ((family.id, node_id), 0))?; + assert_eq!(past.removed_at, env.block.time.seconds()); + Ok(()) + } + + #[test] + fn sweeps_pending_invitations_addressed_to_node() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let bob = tester.addr_make("bob"); + let alice_family = tester.make_family(&alice); + let bob_family = tester.make_family(&bob); + + let node_id = tester.bond_dummy_nymnode()?; + tester.invite_to_family(alice_family.id, node_id); + tester.invite_to_family(bob_family.id, node_id); + + let env = tester.env(); + let mixnet = mixnet_addr(&tester); + try_handle_node_unbonding( + tester.deps_mut(), + env.clone(), + message_info(&mixnet, &[]), + node_id, + )?; + + let storage = NodeFamiliesStorage::new(); + // both pending invitations are gone + assert!(storage + .pending_family_invitations + .may_load(tester.deps().storage, (alice_family.id, node_id))? + .is_none()); + assert!(storage + .pending_family_invitations + .may_load(tester.deps().storage, (bob_family.id, node_id))? + .is_none()); + + // both archived as Rejected + for fam_id in [alice_family.id, bob_family.id] { + let past = storage + .past_family_invitations + .load(tester.deps().storage, ((fam_id, node_id), 0))?; + assert!(matches!( + past.status, + FamilyInvitationStatus::Rejected { at } if at == env.block.time.seconds() + )); + } + Ok(()) + } + + #[test] + fn handles_membership_and_invitation_sweep_together() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let bob = tester.addr_make("bob"); + let alice_family = tester.make_family(&alice); + let bob_family = tester.make_family(&bob); + + let node_id = tester.bond_dummy_nymnode()?; + // node is a member of alice's family AND has a pending invite from bob's + tester.add_to_family(alice_family.id, node_id); + tester.invite_to_family(bob_family.id, node_id); + + let env = tester.env(); + let mixnet = mixnet_addr(&tester); + try_handle_node_unbonding( + tester.deps_mut(), + env.clone(), + message_info(&mixnet, &[]), + node_id, + )?; + + let storage = NodeFamiliesStorage::new(); + // membership gone + assert!(storage + .family_members + .may_load(tester.deps().storage, node_id)? + .is_none()); + assert_eq!( + storage + .families + .load(tester.deps().storage, alice_family.id)? + .members, + 0 + ); + // past member record stamped + let past_member = storage + .past_family_members + .load(tester.deps().storage, ((alice_family.id, node_id), 0))?; + assert_eq!(past_member.removed_at, env.block.time.seconds()); + + // pending invitation from bob's family is swept + assert!(storage + .pending_family_invitations + .may_load(tester.deps().storage, (bob_family.id, node_id))? + .is_none()); + let archived = storage + .past_family_invitations + .load(tester.deps().storage, ((bob_family.id, node_id), 0))?; + assert!(matches!( + archived.status, + FamilyInvitationStatus::Rejected { at } if at == env.block.time.seconds() + )); + Ok(()) + } + + #[test] + fn unrelated_invitations_are_left_untouched() -> anyhow::Result<()> { + let mut tester = init_contract_tester(); + let alice = tester.addr_make("alice"); + let alice_family = tester.make_family(&alice); + + let unbonding_node = tester.bond_dummy_nymnode()?; + let other_node = tester.bond_dummy_nymnode()?; + tester.invite_to_family(alice_family.id, unbonding_node); + tester.invite_to_family(alice_family.id, other_node); + + let env = tester.env(); + let mixnet = mixnet_addr(&tester); + try_handle_node_unbonding( + tester.deps_mut(), + env, + message_info(&mixnet, &[]), + unbonding_node, + )?; + + let storage = NodeFamiliesStorage::new(); + // unbonding node's invitation is gone + assert!(storage + .pending_family_invitations + .may_load(tester.deps().storage, (alice_family.id, unbonding_node))? + .is_none()); + // the unrelated invitation is still pending + assert!(storage + .pending_family_invitations + .may_load(tester.deps().storage, (alice_family.id, other_node))? + .is_some()); + Ok(()) + } + } +} diff --git a/contracts/performance/Cargo.toml b/contracts/performance/Cargo.toml index 78069a02de..18d434f578 100644 --- a/contracts/performance/Cargo.toml +++ b/contracts/performance/Cargo.toml @@ -36,6 +36,13 @@ nym-contracts-common-testing = { workspace = true } nym-mixnet-contract = { workspace = true, features = ["testable-mixnet-contract"] } nym-crypto = { workspace = true, features = ["asymmetric", "rand"] } +# Needed only by the test harness: the embedded mixnet contract dispatches an +# `OnNymNodeUnbond` WasmMsg on `try_remove_nym_node` and the target must be a +# real contract. We instantiate the families contract alongside so the call +# lands somewhere that knows how to handle it. +node-families = { workspace = true, features = ["testable-node-families-contract"] } +nym-node-families-contract-common = { workspace = true } + [features] schema-gen = ["nym-performance-contract-common/schema", "cosmwasm-schema"] diff --git a/contracts/performance/src/helpers.rs b/contracts/performance/src/helpers.rs deleted file mode 100644 index 8219910787..0000000000 --- a/contracts/performance/src/helpers.rs +++ /dev/null @@ -1,97 +0,0 @@ -// Copyright 2025 - Nym Technologies SA -// SPDX-License-Identifier: Apache-2.0 - -use cosmwasm_std::{from_json, Binary, CustomQuery, QuerierWrapper, StdError, StdResult}; -use cw_storage_plus::{Key, Namespace, Path, PrimaryKey}; -use nym_mixnet_contract_common::{Interval, NymNodeBond}; -use nym_performance_contract_common::{EpochId, NodeId}; -use serde::de::DeserializeOwned; -use std::ops::Deref; - -pub(crate) trait MixnetContractQuerier { - #[allow(dead_code)] - fn query_mixnet_contract( - &self, - address: impl Into, - msg: &nym_mixnet_contract_common::QueryMsg, - ) -> StdResult; - - fn query_mixnet_contract_storage( - &self, - address: impl Into, - key: impl Into, - ) -> StdResult>>; - - fn query_mixnet_contract_storage_value( - &self, - address: impl Into, - key: impl Into, - ) -> StdResult> { - match self.query_mixnet_contract_storage(address, key)? { - None => Ok(None), - Some(value) => Ok(Some(from_json(&value)?)), - } - } - - fn query_current_mixnet_interval(&self, address: impl Into) -> StdResult { - self.query_mixnet_contract_storage_value(address, b"ci")? - .ok_or(StdError::not_found( - "unable to retrieve interval information from the mixnet contract storage", - )) - } - - fn query_current_absolute_mixnet_epoch_id( - &self, - address: impl Into, - ) -> StdResult { - self.query_current_mixnet_interval(address) - .map(|interval| interval.current_epoch_absolute_id()) - } - - fn check_node_existence(&self, address: impl Into, node_id: NodeId) -> StdResult { - let mixnet_contract_address = address.into(); - - if let Some(nym_node) = self.query_nymnode_bond(mixnet_contract_address.clone(), node_id)? { - return Ok(!nym_node.is_unbonding); - } - - Ok(false) - } - - fn query_nymnode_bond( - &self, - address: impl Into, - node_id: NodeId, - ) -> StdResult> { - // construct proper map key - let pk_namespace = "nn"; - let path: Path = Path::new( - Namespace::from_static_str(pk_namespace).as_slice(), - &node_id.key().iter().map(Key::as_ref).collect::>(), - ); - let storage_key = path.deref(); - - self.query_mixnet_contract_storage_value(address, storage_key) - } -} - -impl MixnetContractQuerier for QuerierWrapper<'_, C> -where - C: CustomQuery, -{ - fn query_mixnet_contract( - &self, - address: impl Into, - msg: &nym_mixnet_contract_common::QueryMsg, - ) -> StdResult { - self.query_wasm_smart(address, msg) - } - - fn query_mixnet_contract_storage( - &self, - address: impl Into, - key: impl Into, - ) -> StdResult>> { - self.query_wasm_raw(address, key) - } -} diff --git a/contracts/performance/src/lib.rs b/contracts/performance/src/lib.rs index 08a2b9651d..d97317410b 100644 --- a/contracts/performance/src/lib.rs +++ b/contracts/performance/src/lib.rs @@ -5,7 +5,6 @@ pub mod contract; pub mod queued_migrations; pub mod storage; -mod helpers; mod queries; mod transactions; diff --git a/contracts/performance/src/queries.rs b/contracts/performance/src/queries.rs index 5fe2c3ed91..c32cbd6a4c 100644 --- a/contracts/performance/src/queries.rs +++ b/contracts/performance/src/queries.rs @@ -316,6 +316,7 @@ pub fn query_last_submission(deps: Deps) -> Result // SPDX-License-Identifier: Apache-2.0 -use crate::helpers::MixnetContractQuerier; use cosmwasm_std::{Addr, Deps, DepsMut, Env, StdError, Storage}; use cw_controllers::Admin; use cw_storage_plus::{Item, Map}; use nym_contracts_common::Percent; +use nym_mixnet_contract_common::MixnetContractQuerier; use nym_performance_contract_common::constants::storage_keys; use nym_performance_contract_common::{ BatchSubmissionResult, EpochId, LastSubmission, LastSubmittedData, NetworkMonitorDetails, @@ -580,6 +580,7 @@ mod tests { mod performance_contract_storage { use super::*; use crate::testing::{init_contract_tester, PerformanceContractTesterExt, PreInitContract}; + use mixnet_contract::testable_mixnet_contract::EmbeddedMixnetContractExt; use nym_contracts_common_testing::{AdminExt, ContractOpts}; #[cfg(test)] @@ -2856,6 +2857,7 @@ mod tests { mod performance_storage { use super::*; use crate::testing::{init_contract_tester, PerformanceContractTesterExt}; + use mixnet_contract::testable_mixnet_contract::EmbeddedMixnetContractExt; use nym_contracts_common_testing::ContractOpts; use std::str::FromStr; diff --git a/contracts/performance/src/testing/mod.rs b/contracts/performance/src/testing/mod.rs index 33e90beaf3..bdf0df09f0 100644 --- a/contracts/performance/src/testing/mod.rs +++ b/contracts/performance/src/testing/mod.rs @@ -2,15 +2,13 @@ // SPDX-License-Identifier: Apache-2.0 use crate::contract::{execute, instantiate, migrate, query}; -use crate::helpers::MixnetContractQuerier; use crate::storage::NYM_PERFORMANCE_CONTRACT_STORAGE; -use cosmwasm_std::testing::{message_info, mock_env, MockApi}; +use cosmwasm_std::testing::{mock_env, MockApi}; use cosmwasm_std::{ - coin, coins, Addr, ContractInfo, Deps, DepsMut, Env, MessageInfo, QuerierWrapper, StdError, - StdResult, + coin, Addr, ContractInfo, Deps, DepsMut, Env, QuerierWrapper, StdError, StdResult, }; -use mixnet_contract::testable_mixnet_contract::MixnetContract; -use nym_contracts_common::signing::{ContractMessageContent, MessageSignature}; +use mixnet_contract::testable_mixnet_contract::{EmbeddedMixnetContractExt, MixnetContract}; +use node_families_contract::testing::NodeFamiliesContract; use nym_contracts_common::Percent; use nym_contracts_common_testing::{ addr, AdminExt, ArbitraryContractStorageReader, ArbitraryContractStorageWriter, BankExt, @@ -18,19 +16,15 @@ use nym_contracts_common_testing::{ ContractTesterBuilder, DenomExt, PermissionedFn, QueryFn, RandExt, TestableNymContract, TEST_DENOM, }; -use nym_crypto::asymmetric::ed25519; -use nym_mixnet_contract_common::nym_node::{NodeDetailsResponse, NodeOwnershipResponse, Role}; -use nym_mixnet_contract_common::{ - CurrentIntervalResponse, EpochId, Interval, NodeCostParams, NymNode, NymNodeBondingPayload, - RoleAssignment, SignableNymNodeBondingMsg, DEFAULT_INTERVAL_OPERATING_COST_AMOUNT, - DEFAULT_PROFIT_MARGIN_PERCENT, +use nym_mixnet_contract_common::{ContractState, EpochId}; +use nym_node_families_contract_common::{ + Config as NodeFamiliesConfig, InstantiateMsg as NodeFamiliesInstantiateMsg, }; use nym_performance_contract_common::constants::storage_keys; use nym_performance_contract_common::{ ExecuteMsg, InstantiateMsg, MigrateMsg, NodeId, NodePerformance, NodeResults, NymPerformanceContractError, QueryMsg, }; -use serde::de::DeserializeOwned; use serde::Serialize; use std::str::FromStr; @@ -80,6 +74,22 @@ impl TestableNymContract for PerformanceContract { .unwrap() .clone(); + // The embedded mixnet's `try_remove_nym_node` always emits an + // `OnNymNodeUnbond` WasmMsg to the configured families contract; + // instantiate one alongside so that target exists and accepts the call. + // `init_contract_tester` patches the mixnet's stored families address + // to point at this instance after the build completes. + let builder = + builder.instantiate::(Some(NodeFamiliesInstantiateMsg { + config: NodeFamiliesConfig { + create_family_fee: coin(100_000000, TEST_DENOM), + family_name_length_limit: 20, + family_description_length_limit: 200, + default_invitation_validity_secs: 24 * 60 * 60, + }, + mixnet_contract_address: mixnet_address.to_string(), + })); + builder .instantiate::(Some(InstantiateMsg { mixnet_contract_address: mixnet_address.to_string(), @@ -89,9 +99,35 @@ impl TestableNymContract for PerformanceContract { } } +/// Storage key the mixnet contract uses for its `ContractState` `Item` +/// (mirrors `mixnet/src/constants.rs::CONTRACT_STATE_KEY`). +const MIXNET_CONTRACT_STATE_STORAGE_KEY: &str = "state"; + pub fn init_contract_tester() -> ContractTester { - PerformanceContract::init() - .with_common_storage_key(CommonStorageKeys::Admin, storage_keys::CONTRACT_ADMIN) + let mut tester = PerformanceContract::init() + .with_common_storage_key(CommonStorageKeys::Admin, storage_keys::CONTRACT_ADMIN); + + // Chicken-and-egg: the mixnet contract was instantiated first with a + // placeholder `node_families_contract_address`. Now that the families + // contract exists, patch the mixnet's `ContractState` so its + // `OnNymNodeUnbond` dispatch lands on the real contract instead of the + // placeholder. In production this fixup happens via a contract migration; + // here we go straight to storage to avoid the cw2 version check that + // blocks migrating a freshly-instantiated contract. + let families_address = tester + .well_known_contracts + .get(NodeFamiliesContract::NAME) + .expect("families contract should have been instantiated") + .clone(); + let mut mixnet_state: ContractState = tester + .read_from_mixnet_contract_storage(MIXNET_CONTRACT_STATE_STORAGE_KEY) + .expect("mixnet contract state should be loadable"); + mixnet_state.node_families_contract_address = families_address; + tester + .write_to_mixnet_contract_storage_value(MIXNET_CONTRACT_STATE_STORAGE_KEY, &mixnet_state) + .expect("should be able to patch mixnet contract state"); + + tester } // we need to be able to test instantiation, but for that we require @@ -214,136 +250,8 @@ pub(crate) trait PerformanceContractTesterExt: + BankExt + ArbitraryContractStorageReader + ArbitraryContractStorageWriter + + EmbeddedMixnetContractExt { - fn mixnet_contract_address(&self) -> StdResult { - NYM_PERFORMANCE_CONTRACT_STORAGE - .mixnet_contract_address - .load(self.deps().storage) - } - - fn execute_mixnet_contract( - &mut self, - sender: MessageInfo, - msg: &nym_mixnet_contract_common::ExecuteMsg, - ) -> StdResult<()> { - let address = self.mixnet_contract_address()?; - - self.execute_arbitrary_contract(address, sender, msg) - .map_err(|err| { - StdError::generic_err(format!("mixnet contract execution failure: {err}")) - })?; - Ok(()) - } - - fn read_from_mixnet_contract_storage( - &self, - key: impl AsRef<[u8]>, - ) -> StdResult { - let address = self.mixnet_contract_address()?; - - self.must_read_value_from_contract_storage(address, key) - } - - fn write_to_mixnet_contract_storage( - &mut self, - key: impl AsRef<[u8]>, - value: impl AsRef<[u8]>, - ) -> StdResult<()> { - let address = self.mixnet_contract_address()?; - - ::set_contract_storage(self, address, key, value); - Ok(()) - } - - fn write_to_mixnet_contract_storage_value( - &mut self, - key: impl AsRef<[u8]>, - value: &T, - ) -> StdResult<()> { - let address = self.mixnet_contract_address()?; - - self.set_contract_storage_value(address, key, value) - } - - fn current_mixnet_epoch(&self) -> StdResult { - let address = self.mixnet_contract_address()?; - - Ok(self - .deps() - .querier - .query_current_mixnet_interval(address.clone())? - .current_epoch_absolute_id()) - } - - fn advance_mixnet_epoch(&mut self) -> StdResult<()> { - let interval_details: CurrentIntervalResponse = self.query_arbitrary_contract( - self.mixnet_contract_address()?, - &nym_mixnet_contract_common::QueryMsg::GetCurrentIntervalDetails {}, - )?; - let until_end = interval_details.time_until_current_epoch_end().as_secs(); - let timestamp = self.env().block.time.plus_seconds(until_end + 1); - self.set_block_time(timestamp); - self.next_block(); - - // this was hardcoded in mixnet init - let mixnet_rewarder = self.addr_make("rewarder"); - let rewarder = message_info(&mixnet_rewarder, &[]); - self.execute_mixnet_contract( - rewarder.clone(), - &nym_mixnet_contract_common::ExecuteMsg::BeginEpochTransition {}, - )?; - self.execute_mixnet_contract( - rewarder.clone(), - &nym_mixnet_contract_common::ExecuteMsg::ReconcileEpochEvents { limit: None }, - )?; - - for role in [ - Role::ExitGateway, - Role::EntryGateway, - Role::Layer1, - Role::Layer2, - Role::Layer3, - Role::Standby, - ] { - self.execute_mixnet_contract( - rewarder.clone(), - &nym_mixnet_contract_common::ExecuteMsg::AssignRoles { - assignment: RoleAssignment { - role, - nodes: vec![], - }, - }, - )?; - } - Ok(()) - } - - fn set_mixnet_epoch(&mut self, epoch_id: EpochId) -> StdResult<()> { - let address = self.mixnet_contract_address()?; - - let interval = self - .deps() - .querier - .query_current_mixnet_interval(address.clone())?; - - let mut to_update = if interval.current_epoch_absolute_id() <= epoch_id { - interval - } else { - Interval::init_interval( - interval.epochs_in_interval(), - interval.epoch_length(), - &mock_env(), - ) - }; - - let current = to_update.current_epoch_absolute_id(); - let diff = epoch_id - current; - for _ in 0..diff { - to_update = to_update.advance_epoch(); - } - self.set_contract_storage_value(&address, b"ci", &to_update) - } - fn authorise_network_monitor( &mut self, addr: &Addr, @@ -435,68 +343,6 @@ pub(crate) trait PerformanceContractTesterExt: .load(self.deps().storage, (epoch_id, node_id))?; Ok(scores) } - - fn bond_dummy_nymnode(&mut self) -> Result { - let node_owner = self.generate_account_with_balance(); - let pledge = coins(100_000000, TEST_DENOM); - let keypair = ed25519::KeyPair::new(self.raw_rng()); - let identity_key = keypair.public_key().to_base58_string(); - - let node = NymNode { - host: "1.2.3.4".to_string(), - custom_http_port: None, - identity_key, - }; - let cost_params = NodeCostParams { - profit_margin_percent: Percent::from_percentage_value(DEFAULT_PROFIT_MARGIN_PERCENT) - .unwrap(), - interval_operating_cost: coin(DEFAULT_INTERVAL_OPERATING_COST_AMOUNT, TEST_DENOM), - }; - // initial signing nonce is 0 for a new address - let signing_nonce = 0; - - let payload = NymNodeBondingPayload::new(node.clone(), cost_params.clone()); - let content = ContractMessageContent::new(node_owner.clone(), pledge.clone(), payload); - let msg = SignableNymNodeBondingMsg::new(signing_nonce, content); - - let owner_signature = keypair.private_key().sign(msg.to_plaintext()?); - let owner_signature = MessageSignature::from(owner_signature.to_bytes().as_ref()); - - self.execute_mixnet_contract( - message_info(&node_owner, &pledge), - &nym_mixnet_contract_common::ExecuteMsg::BondNymNode { - node, - cost_params, - owner_signature, - }, - )?; - - let bond: NodeOwnershipResponse = self.query_arbitrary_contract( - self.mixnet_contract_address()?, - &nym_mixnet_contract_common::QueryMsg::GetOwnedNymNode { - address: node_owner.to_string(), - }, - )?; - - Ok(bond.details.unwrap().bond_information.node_id) - } - - fn unbond_nymnode(&mut self, node_id: NodeId) -> Result<(), NymPerformanceContractError> { - let bond: NodeDetailsResponse = self.query_arbitrary_contract( - self.mixnet_contract_address()?, - &nym_mixnet_contract_common::QueryMsg::GetNymNodeDetails { node_id }, - )?; - - let node_owner = bond.details.unwrap().bond_information.owner; - - self.execute_mixnet_contract( - message_info(&node_owner, &[]), - &nym_mixnet_contract_common::ExecuteMsg::UnbondNymNode {}, - )?; - - self.advance_mixnet_epoch()?; - Ok(()) - } } impl PerformanceContractTesterExt for ContractTester {} diff --git a/contracts/performance/src/transactions.rs b/contracts/performance/src/transactions.rs index 86a2d99772..3f9fe4aaed 100644 --- a/contracts/performance/src/transactions.rs +++ b/contracts/performance/src/transactions.rs @@ -132,6 +132,7 @@ mod tests { use crate::storage::retrieval_limits; use crate::testing::{init_contract_tester, PerformanceContractTesterExt}; use cosmwasm_std::from_json; + use mixnet_contract::testable_mixnet_contract::EmbeddedMixnetContractExt; use nym_contracts_common_testing::{AdminExt, ContractOpts}; use nym_performance_contract_common::RemoveEpochMeasurementsResponse; diff --git a/contracts/vesting/src/transactions.rs b/contracts/vesting/src/transactions.rs index 09e3ac9a8a..9c37cd8884 100644 --- a/contracts/vesting/src/transactions.rs +++ b/contracts/vesting/src/transactions.rs @@ -17,9 +17,9 @@ use mixnet_contract_common::{ use vesting_contract_common::events::{ new_ownership_transfer_event, new_periodic_vesting_account_event, new_staking_address_update_event, new_track_gateway_unbond_event, - new_track_migrate_mixnode_event, new_track_mixnode_pledge_decrease_event, - new_track_mixnode_unbond_event, new_track_reward_event, new_track_undelegation_event, - new_vested_coins_withdraw_event, + new_track_migrate_delegation_event, new_track_migrate_mixnode_event, + new_track_mixnode_pledge_decrease_event, new_track_mixnode_unbond_event, + new_track_reward_event, new_track_undelegation_event, new_vested_coins_withdraw_event, }; use vesting_contract_common::{Account, PledgeCap, VestingContractError, VestingSpecification}; @@ -247,7 +247,7 @@ pub fn try_track_migrate_delegation( } let account = account_from_address(owner, deps.storage, deps.api)?; account.track_migrated_delegation(mix_id, deps.storage)?; - Ok(Response::new().add_event(new_track_migrate_mixnode_event())) + Ok(Response::new().add_event(new_track_migrate_delegation_event())) } /// Bond a mixnode, sends [mixnet_contract_common::ExecuteMsg::BondMixnodeOnBehalf] to [crate::storage::MIXNET_CONTRACT_ADDRESS]. diff --git a/crate-publishing.md b/crate-publishing.md index 86dc0988d2..037143712d 100644 --- a/crate-publishing.md +++ b/crate-publishing.md @@ -1,34 +1,67 @@ # Publishing workspace dependencies + ## Rationale re: versioning + We publish the majority of our workspace dependencies (essentially everything in the repo aside from binaries, smart contracts, and some internal tooling) to [crates.io](https://crates.io). -In order to make this easy to maintain, the versions of these workspace dependencies and the `nym-sdk` crate are kept in sync. +In order to make this easy to maintain, the versions of these workspace dependencies and the `nym-sdk` crate are kept in sync. The same is done to newer crates such as `smolmix`. This version is defined in the `[workspace.package]` section of the root monorepo `Cargo.toml` file. Each of the workspace dependencies have their paths and versions (this has to be individually defined at the moment per-dependency, **this version needs to stay the same as the `workspace.package` version**) defined in the `[workspace.dependencies]` section of the root monorepo `Cargo.toml` file. +The `contracts/` directory has its own separate `[workspace]`. Those crates are CosmWasm smart contracts deployed as WASM to chain, not published to crates.io. The shared types between the two workspaces (e.g. `nym-contracts-common`) live in `common/cosmwasm-smart-contracts/` within the root workspace and are published. The contracts workspace depends on them from crates.io. + ## When Developing + If you add a workspace dependency to the SDK when developing, make sure to add this to the workspace dependencies in the root monorepo `Cargo.toml`. +Crates that should not be published to crates.io must have `publish = false` in their `Cargo.toml`. The preflight check (`tools/internal/check_publish_preflight.py`) will flag publishable crates with missing metadata. + ## Check local publication + ``` # List crates to publish cargo workspaces list +# Check publishability (metadata, deps, non-publishable chains) +python3 tools/internal/check_publish_preflight.py + # Dry run locally - check for compilation or other problems cargo workspaces publish --no-git-commit --dry-run ``` ## CI -There are several workflows that should be run in the following order: -- `ci-crates-publish-dry-run`: **run this first!** This is a remote dry-run on a runner. This greps for any errors that would be a problem when we're not dry-running. It doesn't catch all errors, as `dry-run` has a known issue where, assuming that 2 new crates are being uploaded, and crate B relies on crate A, if crate A isn't on crates.io (which it won't be, since you're dry-running publication), then since `cargo workspaces publish` only checks for available versions on crates.io, it will error. We don't want the CI to fail in that case. -- `ci-crates-version-bump`: this bumps the versions of the workspace + dependencies to the passed version, and then commits the change. This is its own CI job so that we don't get into sticky situations whereby the version bump + commit happens, but the publication step fails. -- `ci-crates-publish`: this publishes the crates. So long as you're not uploading more than 5 new crates, pass `60` as the `publish_interval`. This is to get around [crates.io rate limiting](https://github.com/rust-lang/crates.io/blob/ad7e58e1afd65b9137e58a7bca3e1fb7f5546682/src/rate_limiter.rs#L24). Pass the Github handle of whoever should be the backup author of the crate for security redundency (see the section below) as the second arg. -> There is also `ci-crates-publish-resume` which is there in case a publication run fails and needs to be restarted part way through the list of unbumped/unpublished crates. Pass the previously bumped/published crate in the list output of `cargo workspaces list` +There are several workflows that should be run in the following order: + +1. **`ci-crates-publish-dry-run`**: Run this first. This is a remote dry-run on a runner that greps for real packaging errors (manifest issues, missing metadata). It ignores cascading dependency errors, which are expected in dry-run mode because upstream crates aren't actually uploaded to crates.io. + +2. **`ci-crates-version-bump`**: Bumps the versions of the workspace + dependencies to the passed version. This is a separate job so that if the version bump succeeds but publication fails, the versions aren't left in a bad state. **This creates a PR that must be merged into the branch you're publishing from before running publish.** + +3. **`ci-crates-publish`**: Publishes the crates using `cargo workspaces publish --publish-as-is`. The `--publish-as-is` flag tells cargo-workspaces to publish with the current versions in the repo (already bumped by step 2) without doing any version changes itself. + + - `publish_interval`: seconds to wait between publishes for crates.io indexing. Use `600` for first-time publication of many new crates, `60` after that. This is to get around [crates.io rate limiting](https://github.com/rust-lang/crates.io/blob/ad7e58e1afd65b9137e58a7bca3e1fb7f5546682/src/rate_limiter.rs#L24). + - `backup_author`: Github handle of who should be added as backup crate owner (defaults to `jstuczyn`). + +> There is also `ci-crates-publish-resume` which is there in case a publication run fails and needs to be restarted part way through the list of unpublished crates. + +### Important: workflow sequencing + +The version-bump workflow creates a PR due to branch protection rules. **You must merge that PR before running the publish workflow**, otherwise publish will run against the unbumped branch and fail with "already exists" errors for the old version. + +## How cargo-workspaces publish works + +`cargo workspaces publish` handles several things that raw `cargo publish` does not: + +- **Topological ordering**: publishes crates in dependency order. +- **Dev-dep removal**: by default, dev-dependencies are stripped from each crate's `Cargo.toml` before publishing. This avoids packaging failures where a dev-dep on a workspace sibling hasn't been uploaded yet. +- **Cargo.toml rewriting**: replaces `workspace = true` references with concrete values before calling `cargo publish`. + +Do not replace this with a manual `cargo publish -p` loop -- it will fail during packaging because `cargo publish` tries to resolve all deps (including dev-deps) against the crates.io index, and workspace siblings at the new version won't exist yet. ## Crates.io Authors -Since Github teams have [limited ownership / mod rights](https://doc.rust-lang.org/cargo/reference/publishing.html#cargo-owner) of crates, and we cannot create a `CARGO_REGISTRY_TOKEN` on behalf of the Nym Github org. As such, we are currently using personal cargo tokens generated by team members (currently Max), and adding the Nym Github org as an owner in the CI job. -However, since the Github org cannot add or modify owners, are also adding a second user author as a redundency, on the offchance that Max loses access to his Crates.io / Github account, gets struck by lightning, etc. This is the author passed as the second argument to the `ci-crates-publish` CI, and if none is passed, defaults to [https://github.com/jstuczyn](https://github.com/jstuczyn) since he is the Github org owner. +Since Github teams have [limited ownership / mod rights](https://doc.rust-lang.org/cargo/reference/publishing.html#cargo-owner) of crates, and we cannot create a `CARGO_REGISTRY_TOKEN` on behalf of the Nym Github org, we are currently using personal cargo tokens generated by team members (currently Max), and adding the Nym Github org as an owner in the CI job. + +However, since the Github org cannot add or modify owners, we are also adding a second user as a redundancy, on the offchance that Max loses access to his Crates.io / Github account, gets struck by lightning, etc. This is the author passed as the second argument to the `ci-crates-publish` CI, and if none is passed, defaults to [jstuczyn](https://github.com/jstuczyn) since he is the Github org owner. Authors can also be changed by running `scripts/add-crates-owners.sh`. diff --git a/documentation/.gitignore b/documentation/.gitignore index 4329a0d6c0..cf1c85310b 100644 --- a/documentation/.gitignore +++ b/documentation/.gitignore @@ -1,2 +1,3 @@ todo.md scripts/generate-api +RELEASE_TASKS.md diff --git a/documentation/docs/components/code-verified.tsx b/documentation/docs/components/code-verified.tsx index f3390c6668..1742160b4c 100644 --- a/documentation/docs/components/code-verified.tsx +++ b/documentation/docs/components/code-verified.tsx @@ -1,22 +1,33 @@ import { Callout } from "nextra/components"; +import { NYM_SDK_VERSION, SMOLMIX_VERSION } from "./versions"; -const COMMIT_SHORT = "97068b2"; -const COMMIT_FULL = "97068b2aa"; -const EXAMPLES_URL = - "https://github.com/nymtech/nym/tree/develop/sdk/rust/nym-sdk/examples"; +const VERSIONS: Record = { + "nym-sdk": NYM_SDK_VERSION, + smolmix: SMOLMIX_VERSION, +}; -export const CodeVerified = () => ( +const EXAMPLES_URLS: Record = { + "nym-sdk": + "https://github.com/nymtech/nym/tree/develop/sdk/rust/nym-sdk/examples", + smolmix: + "https://github.com/nymtech/nym/tree/develop/smolmix/core/examples", +}; + +interface CodeVerifiedProps { + /** Crate name to display. Defaults to "nym-sdk". */ + crate?: keyof typeof VERSIONS; +} + +export const CodeVerified = ({ crate: crateName = "nym-sdk" }: CodeVerifiedProps) => ( - Code verified against commit{" "} + Code verified against{" "} + {crateName} v{VERSIONS[crateName]}. If the API has changed + since then, check the{" "} - {COMMIT_SHORT} - - . If the API has changed since then, check the{" "} - examples in the repo {" "} for the latest usage. diff --git a/documentation/docs/components/crates-paused.tsx b/documentation/docs/components/crates-paused.tsx deleted file mode 100644 index 1194a5222e..0000000000 --- a/documentation/docs/components/crates-paused.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { Callout } from "nextra/components"; - -const CRATES_VERSION = "1.20.4"; -const INSTALL_PATH = "/developers/rust/importing"; - -export const CratesPaused = () => ( - - Crate publication is paused. The crates.io release (v - {CRATES_VERSION}) doesn't include the Stream module or other recent work. - Publication resumes with the Lewes Protocol. Import from Git for now — see{" "} - Installation. - -); diff --git a/documentation/docs/components/landing-page.tsx b/documentation/docs/components/landing-page.tsx index 9df5a95192..c1013c6618 100644 --- a/documentation/docs/components/landing-page.tsx +++ b/documentation/docs/components/landing-page.tsx @@ -11,8 +11,6 @@ const asciiStyle: React.CSSProperties = { margin: 0, }; -// ── Animation components ── - const randomRow = () => Math.floor(Math.random() * 3); const randomPath = () => [randomRow(), randomRow(), randomRow()]; @@ -243,8 +241,6 @@ const ApiAnimation = () => { ); }; -// ── Section data ── - const sections = [ { title: "Network", @@ -291,17 +287,29 @@ const AnimationBlock = ({ type }: { type: string }) => { const sdks = [ { - name: "Rust", + name: "Rust SDK", description: - "Native SDK with async Mixnet client, streams, and TcpProxy modules.", + "Async Mixnet client with AsyncRead/AsyncWrite streams over the Mixnet.", href: "/developers/rust", }, { - name: "TypeScript", + name: "smolmix", description: - "Browser-based SDK with fetch API replacement and message-based WebSocket transport.", + "TCP/UDP tunnel over the Mixnet. Userspace smoltcp stack exposing AsyncRead/AsyncWrite TcpStream and UdpSocket types.", + href: "/developers/smolmix", + }, + { + name: "TypeScript SDK", + description: + "Browser-side Mixnet Client for raw messaging via WebSocket, plus Nyx smart contract bindings.", href: "/developers/typescript", }, + { + name: "mix-fetch", + description: + "fetch()-compatible API that routes HTTP(S) requests through the Mixnet. Browsers and Node.js.", + href: "/developers/mix-fetch", + }, ]; export const LandingPage = () => { @@ -309,7 +317,6 @@ export const LandingPage = () => {
- {/* ── Section cards ── */}
{ ))}
- {/* ── SDKs ── */}
{ padding: 0, }} > - SDKs + Libraries

{ lineHeight: 1.6, }} > - Integrate Mixnet privacy into your application with our Rust and - TypeScript SDKs. + Rust and TypeScript libraries for Mixnet integration.

@@ -466,7 +471,6 @@ export const LandingPage = () => {
- {/* ── Links ── */}
+ * + * + */ + +type Variant = "latency" | "cryptography" | "acks"; + +interface LewesPendingProps { + variant: Variant; +} + +interface VariantEntry { + type: "info" | "warning"; + body: ReactNode; +} + +const VARIANTS: Record = { + latency: { + type: "info", + body: "Updated latency measurements will be published after the Lewes Protocol release.", + }, + cryptography: { + type: "info", + body: ( + <> + Cryptographic details on this page will be updated for the Lewes + Protocol release. For the current algorithm overview, see the{" "} + + Nym Trust Center: Cryptography + + . + + ), + }, + acks: { + type: "warning", + body: "The upcoming Lewes Protocol release will introduce changes to how acknowledgements are handled. The current hop-by-hop ACK mechanism described above may be revised as part of broader protocol improvements. Details will be documented here once the changes are finalised.", + }, +}; + +export const LewesPending = ({ variant }: LewesPendingProps) => { + const { type, body } = VARIANTS[variant]; + return {body}; +}; diff --git a/documentation/docs/components/mix-fetch.tsx b/documentation/docs/components/mix-fetch.tsx index a754ba7f62..0fb6298e37 100644 --- a/documentation/docs/components/mix-fetch.tsx +++ b/documentation/docs/components/mix-fetch.tsx @@ -153,115 +153,116 @@ export const MixFetch = () => { error: `Error: ${errorMsg}`, }; const statusColor: Record = { - idle: "gray", + idle: "#9e9e9e", starting: "orange", - ready: "green", - error: "red", + ready: "#85E89D", + error: "#ff6b6b", }; return ( -
- {/* --- Start MixFetch Section --- */} - - - + {status === "starting" && } + + {statusText[status]} + + + + {/* --- Fetch Controls (disabled until ready) --- */} + - Start MixFetch - - {status === "starting" && } - - {statusText[status]} - + {/* Single fetch */} + + setUrl(e.target.value)} + size="small" + /> + + + {busy && ( + + + + )} + {html && ( + <> + + Response + + + + {html} + + + + )} + + {/* Concurrent fetch demo */} + + Concurrent Requests + + + + + {concurrentBusy && ( + + + + )} + {concurrentResults.length > 0 && ( + + {concurrentResults.map((result, i) => ( + + {result} + + ))} + + )} + - {/* --- Fetch Controls (disabled until ready) --- */} - - {/* Single fetch */} - - setUrl(e.target.value)} - /> - - - {busy && ( - - - - )} - {html && ( - <> - - Response - - - - {html} - - - - )} - - {/* Concurrent fetch demo */} - - Concurrent Requests - - - - - {concurrentBusy && ( - - - - )} - {concurrentResults.length > 0 && ( - - {concurrentResults.map((result, i) => ( - - {result} - - ))} - - )} - - {/* --- Log Panel --- */} {logs.length > 0 && ( Log {logs.map((entry, i) => ( @@ -276,6 +277,6 @@ export const MixFetch = () => { ))} )} -
+ ); }; diff --git a/documentation/docs/components/operators/snippets/ntm-accordion-explanation.mdx b/documentation/docs/components/operators/snippets/ntm-accordion-explanation.mdx new file mode 100644 index 0000000000..ec5c54d173 --- /dev/null +++ b/documentation/docs/components/operators/snippets/ntm-accordion-explanation.mdx @@ -0,0 +1,18 @@ +import { AccordionTemplate } from 'components/accordion-template.tsx'; + + + +[Nym network tunnel manager](https://raw.githubusercontent.com/nymtech/nym/refs/heads/develop/scripts/nym-node-setup/network-tunnel-manager.sh) (NTM) `complete_networking_configuration` runs four phases: + +1. **Tunnel setup** - deduplicates existing iptables rules, enables IPv4/IPv6 forwarding persistently via `/etc/sysctl.d/99-nym-forwarding.conf`, applies NAT MASQUERADE and FORWARD rules for both `nymtun0` and `nymwg` + +2. **Host firewall** - opens the required INPUT ports for all Nym services: TCP `22, 80, 443, 1789, 1790, 8080, 9000, 9001, 41264` and UDP `4443, 51822, 51264` + +3. **Exit policy** - creates the `NYM-EXIT` iptables chain, applies the full port allowlist, rate-limits SMTPS (TCP/465), applies the Spamhaus blocklist, adds a default REJECT at the end of the chain + +4. **Verification** - runs automated tests against all firewall rules and reports any issues + +All rules are saved to `/etc/iptables/rules.v4` and `/etc/iptables/rules.v6` via `iptables-persistent`, so they survive future reboots. + +The script is idempotent - safe to re-run multiple times. + \ No newline at end of file diff --git a/documentation/docs/components/operators/snippets/troubleshooting-linux-vulnerability.mdx b/documentation/docs/components/operators/snippets/troubleshooting-linux-vulnerability.mdx new file mode 100644 index 0000000000..d4ef71ab1e --- /dev/null +++ b/documentation/docs/components/operators/snippets/troubleshooting-linux-vulnerability.mdx @@ -0,0 +1,292 @@ +import { Callout } from 'nextra/components'; +import { Steps } from 'nextra/components'; +import { Tabs } from 'nextra/components'; +import { MyTab } from 'components/generic-tabs.tsx'; +import { AccordionTemplate } from 'components/accordion-template.tsx'; +import NTMExplanation from 'components/operators/snippets/ntm-accordion-explanation.mdx'; + +## Security Patch: `CopyFail` & `DirtyFrag` + +In spring 2026 two critical Linux kernel vulnerabilities had been disclosed. Both allow any local user or process to gain root access on the machine - no special permissions needed. + + +**What is exposed?** An attacker (or any compromised process) can take full control of the machine: read or exfiltrate everything, modify files, install backdoors, or escape containers onto the host. + + +**Security patch required! Follow these steps to patch the vulnerability as soon as possible!** + +**CVEs covered** + +| CVE | Name | Affected module | +|---|---|---| +| `CVE-2026-31431` | `CopyFail` | `algif_aead` | +| `CVE-2026-43284` | `DirtyFrag` | `esp4`, `esp6` | +| `CVE-2026-43500` | `DirtyFrag` | `rxrpc` | + +
+ Directly on the hosting server, + Using Ansible, + ]} defaultIndex="0"> + + + + +**This guide is written from root perspective, please add `sudo` to your commands if you are non-root user.** + + +###### 1. Check if mitigations are already applied + +Many hosting providers already applied these mitigations automatically via `unattended-upgrades`. Run these checks first: + +```sh +cat /etc/modprobe.d/disable-algif_aead.conf 2>/dev/null && echo "Already mitigated" || echo "Not yet mitigated" +cat /etc/modprobe.d/dirtyfrag.conf 2>/dev/null && echo "Already mitigated" || echo "Not yet mitigated" +``` + +Compare your output to this table: + +| Check | Safe output | Action if not safe | +|---|---|---| +| `disable-algif_aead.conf` | `Already mitigated` | Go to [Step 2](#2-apply-the-interim-mitigation---disable-algif_aead) | +| `dirtyfrag.conf` | `Already mitigated` | Go to [Step 3](#3-apply-the-interim-mitigation---disable-esp4-esp6-and-rxrpc) | + +If both checks are `Already mitigated` - **you are done, no further action needed.** + + +###### 2. Apply the interim mitigation - disable `algif_aead` + +The fastest fix on Debian/Ubuntu is to update the `kmod` package - this is how [providers applied it](https://ubuntu.com/blog/copy-fail-vulnerability-fixes-available) automatically and avoids a reboot: + +```sh +apt update && apt install --only-upgrade kmod +rmmod algif_aead 2>/dev/null || true +``` + +- Alternatively, apply manually: + +```sh +echo "install algif_aead /bin/false" | tee /etc/modprobe.d/disable-algif_aead.conf +rmmod algif_aead 2>/dev/null || true +``` + +- Drop page cache to clear any contamination: + +```sh +echo 3 > /proc/sys/vm/drop_caches +``` + + +No reboot is required unless the module was already in active use by a running process. The `rmmod` command above unloads it live. + + + +**RHEL, AlmaLinux, Rocky, CentOS** only: this module is baked into the kernel on these distros - the command above does nothing. You must wait for a patched kernel from your distro and apply it when available. + + +Continue to [Step 4](#4-restore-gateway-egress-routing--wireguard-functionality). + +###### 3. Apply the interim mitigation - disable `esp4`, `esp6`, and `rxrpc` + +```sh +sh -c "printf 'install esp4 /bin/false\ninstall esp6 /bin/false\ninstall rxrpc /bin/false\n' > /etc/modprobe.d/dirtyfrag.conf" +rmmod esp4 esp6 rxrpc 2>/dev/null || true +``` + +- Drop page cache to clear any contamination: + +```sh +echo 3 > /proc/sys/vm/drop_caches +``` + +Continue to [Step 4](#4-restore-gateway-egress-routing--wireguard-functionality). + +###### 4. Restore Gateway egress routing & WireGuard functionality + + +This step applies only to Gateway nodes. Skip this step for Mixnodes. + + +If your server was rebooted for any reason, all iptables rules including NAT, forwarding rules, and the `NYM-EXIT` chain are dropped. Unless `iptables-persistent` was already configured, these rules are gone. + +- Run the [Nym network tunnel manager](https://raw.githubusercontent.com/nymtech/nym/refs/heads/develop/scripts/nym-node-setup/network-tunnel-manager.sh) (NTM) to restore and persist everything. NTM will also install `iptables-persistent` if not already present, ensuring rules survive future reboots: + +```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 +``` + +- Run NTM (Standard SSH on port 22 (default)): +```sh +chmod +x ./network-tunnel-manager.sh && +./network-tunnel-manager.sh complete_networking_configuration +``` + +- NTM on non-standart SSH port (replace `2222` with your actual port): +```sh +chmod +x ./network-tunnel-manager.sh && +HOST_SSH_PORT=2222 ./network-tunnel-manager.sh complete_networking_configuration +``` + + + +###### 5. Verify egress routing & WireGuard functionality + +There are a few ways to check that your WireGuard is working correctly: + +- Connect to your node in NymVPN as an exit node in the *Fast* mode, if you rebooted it may take up to one hour for the node to be working properly again + +- Verify WireGuard handshakes are resuming inside the node: + +```sh +watch -n 5 'wg show all | grep -E "peer|latest handshake|transfer"' +``` + +Within 60–90 seconds you should see `latest handshake: X seconds ago` timestamps appearing next to active peers and transfer bytes incrementing. Press `Ctrl+C` to exit the watch loop. + + + + +If you are using Ansible you can use this playbook [mitigate_kernel_CVE.yml](https://github.com/nymtech/nym/tree/develop/ansible/nym-node/playbooks/mitigate_kernel_CVE.yml). + + +###### 1. Create new playbook + +- Navigate to your Nym node Ansible directory with all playbooks (`/playbooks`) + +- You can double check that you are in the right place, running `ls` command and checking this output: + + +``` +ansible.cfg bond.yml deploy.yml group_vars inventory system-maintenance.yml upgrade.yml +``` + +- Download the playbook +```sh +curl -o mitigate_kernel_CVE.yml -L https://raw.githubusercontent.com/nymtech/nym/refs/heads/develop/ansible/nym-node/playbooks/mitigate_kernel_CVE.yml +``` +- To control, run `ls` again + +``` +ansible.cfg bond.yml deploy.yml group_vars inventory mitigate_kernel_CVE.yml system-maintenance.yml upgrade.yml +``` + + +###### 2. Run playbook `mitigate_kernel_CVE.yml` + +- For safety start only with one node: +```sh +ansible-playbook mitigate_kernel_CVE.yml -l node1 +``` + +- Check if everything worked smooth on the node machine + +- Run on all machines in your inventory +```sh +ansible-playbook mitigate_kernel_CVE.yml +``` + +###### 3. Ensure routing & WireGuard functionality + + +This step applies only to Gateway nodes. Skip this step for Mixnodes. + + +In case your hosting servers rebooted all iptables rules including NAT, forwarding rules, and the `NYM-EXIT` chain, are dropped. Unless `iptables-persistent` was already configured before the reboot, these rules are gone. + +- Run the [Nym network tunnel manager](https://raw.githubusercontent.com/nymtech/nym/refs/heads/develop/scripts/nym-node-setup/network-tunnel-manager.sh) (NTM) to restore and persist everything, using Ansible playbook `deploy.yml` pointing to the correct role: + +- For safety start only with one node: +```sh +ansible-playbook deploy.yml -t ntm -l node1 +``` + +- Check if everything worked smooth on the node machine + +- Run on all machines in your inventory +```sh +ansible-playbook deploy.yml -t ntm +``` + +###### 4. Verify egress routing & WireGuard functionality + +- Connect to your node in NymVPN as an exit node in the *Fast* mode, it may take up to one hour for the node to be working properly again + +In case something doesn't work fine, please check the manual steps and [troubleshooting steps](#troubleshooting-wireguard-handshake-fails-after-patching) below + + + + +
+ +### Troubleshooting: WireGuard handshake fails after patching + +If after completing all steps above WireGuard peers are still not handshaking and you can't connect to your node via NymVPN, work through this checklist: + + + +###### 1. Confirm blacklists are in place + +```sh +cat /etc/modprobe.d/disable-algif_aead.conf 2>/dev/null && echo "algif_aead: mitigated" || echo "algif_aead: NOT mitigated" +cat /etc/modprobe.d/dirtyfrag.conf 2>/dev/null && echo "dirtyfrag: mitigated" || echo "dirtyfrag: NOT mitigated" +``` + +- Both files must exist - these are what prevent the modules from loading on demand or after reboot + +- If either is missing, return to [Step 2](#2-apply-the-interim-mitigation---disable-algif_aead) or [Step 3](#3-apply-the-interim-mitigation---disable-esp4-esp6-and-rxrpc) and apply the missing mitigation + +###### 2. Check for stale xfrm state + +```sh +ip xfrm policy list && ip xfrm state list +``` + +- If either returns entries, flush them — WireGuard does not use xfrm and stale entries can intercept handshake packets: + +```sh +ip xfrm state flush && ip xfrm policy flush +``` + +###### 3. Confirm the `nymwg` interface is up + +```sh +ip addr show type wireguard +``` +- If absent, restart the `nym-node service`: + +```sh +systemctl restart nym-node +``` + +###### 4. Check iptables forwarding rules are present + +```sh +iptables -L FORWARD -n -v | grep -E 'nymwg|nymtun' +iptables -L NYM-EXIT -n | head -10 +``` + +- If the `NYM-EXIT` chain is missing or `FORWARD` has no entries for `nymwg`, re-run [Step 4](#4-restore-gateway-egress-routing--wireguard-functionality). + +###### 5. Confirm IP forwarding is enabled + +```sh +sysctl net.ipv4.ip_forward net.ipv6.conf.all.forwarding +``` + +- Both should return `= 1`. If not: `sysctl -w net.ipv4.ip_forward=1 && sysctl -w net.ipv6.conf.all.forwarding=1` + +###### 6. Watch for live handshake packets + +- Install tcpdump if missing + +```sh +apt install tcpdump -y # Debian/Ubuntu +dnf install tcpdump -y # RHEL family + +# replace with your actual WG port from: ss -ulnp | grep nym-node +tcpdump -ni any udp port 51264 +``` + +Trigger a connection attempt from a client. If you see packets arriving but WireGuard is not responding, check `journalctl -u nym-node -n 100` for errors from the binary itself. + + \ No newline at end of file diff --git a/documentation/docs/components/outputs/api-scraping-outputs/nodes-count.json b/documentation/docs/components/outputs/api-scraping-outputs/nodes-count.json index a1c7683698..afc2a063f8 100644 --- a/documentation/docs/components/outputs/api-scraping-outputs/nodes-count.json +++ b/documentation/docs/components/outputs/api-scraping-outputs/nodes-count.json @@ -1,6 +1,6 @@ { - "nodes": 745, - "locations": 77, - "mixnodes": 264, - "exit_gateways": 474 + "nodes": 723, + "locations": 76, + "mixnodes": 249, + "exit_gateways": 466 } 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 47c9becae5..5ecc447407 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, May 6th 2026, 11:11:33 UTC +Wednesday, May 20th 2026, 06:27:43 UTC 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 3451f94b20..c4bddbd0d7 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 @@ -8,7 +8,9 @@ usage: nym-node-cli install [-h] [-V] [-d BRANCH] [-v] [--public-ip PUBLIC_IP] [--host-ssh-port HOST_SSH_PORT] [--nym-node-binary NYM_NODE_BINARY] - [--uplink-dev UPLINK_DEV] [--env KEY=VALUE] + [--uplink-dev UPLINK_DEV] + [--uplink-dev-v4 UPLINK_DEV_V4] + [--uplink-dev-v6 UPLINK_DEV_V6] [--env KEY=VALUE] options: -h, --help show this help message and exit @@ -34,7 +36,13 @@ options: --nym-node-binary NYM_NODE_BINARY URL for nym-node binary (autodetected if omitted) --uplink-dev UPLINK_DEV - Override uplink interface used for NAT/FORWARD (e.g., - 'eth0'; autodetected if omitted) + Backward-compatible override for both IPv4 and IPv6 + uplinks, e.g. 'eth0' + --uplink-dev-v4 UPLINK_DEV_V4 + Override IPv4 uplink interface used for NAT/FORWARD, + e.g. 'eth0' + --uplink-dev-v6 UPLINK_DEV_V6 + Override IPv6 uplink interface used for NAT/FORWARD, + e.g. 'eth1' --env KEY=VALUE (Optional) Extra ENV VARS, e.g. --env CUSTOM_KEY=value ``` 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 6774992a11..67799f0690 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 @@ -72,6 +72,12 @@ Options: --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] + --nr-allow-local-ips Allow the network requester to forward traffic to non-globally-routable addresses. Intended for local development, private-network + deployments, and testnet scenarios. Not recommended on production exit gateway unless you know what you're doing [env: + NYMNODE_NR_ALLOW_LOCAL_IPS=] [possible values: true, false] + --ipr-allow-local-ips Allow the IP packet router to forward traffic to non-globally-routable addresses. Intended for local development, private-network + deployments, and testnet scenarios. Not recommended on production exit gateway unless you know what you're doing [env: + NYMNODE_IPR_ALLOW_LOCAL_IPS=] [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=] diff --git a/documentation/docs/components/version-banner.tsx b/documentation/docs/components/version-banner.tsx new file mode 100644 index 0000000000..26b14d6e5b --- /dev/null +++ b/documentation/docs/components/version-banner.tsx @@ -0,0 +1,15 @@ +import { Callout } from "nextra/components"; +import { NYM_SDK_VERSION } from "./versions"; + +const INSTALL_PATH = "/developers/rust/importing"; + +export const VersionBanner = () => ( + + Code examples target v{NYM_SDK_VERSION} of the Nym crates + on{" "} + + crates.io + + . See Installation for setup instructions. + +); diff --git a/documentation/docs/components/versions.ts b/documentation/docs/components/versions.ts new file mode 100644 index 0000000000..67646aacce --- /dev/null +++ b/documentation/docs/components/versions.ts @@ -0,0 +1,31 @@ +/** + * Centralised version constants for documentation. + * + * Components (VersionBanner, CodeVerified, etc.) import from here so there is a + * single place to update on each release. + * + * Fenced code blocks still hardcode versions for copy-paste friendliness. + * When bumping versions here, also update the Cargo.toml snippets in: + * + * pages/developers/rust/importing.mdx + * pages/developers/rust/tcpproxy.mdx + * pages/developers/rust/mixnet/tutorial.mdx + * pages/developers/rust/stream/tutorial.mdx + * pages/developers/rust/client-pool/tutorial.mdx + * public/llms.txt + * + * RUST_MSRV is imported directly by all pages that display the Rust version, + * so no manual file edits are needed for MSRV bumps: + * + * pages/developers/smolmix.mdx + * pages/developers/rust/importing.mdx + */ + +// nym-sdk / nym-bin-common / nym-network-defaults (Rust SDK crates) +export const NYM_SDK_VERSION = "1.21.0"; + +// smolmix standalone crate +export const SMOLMIX_VERSION = "1.21.0"; + +// Minimum supported Rust version (matches workspace rust-version in root Cargo.toml) +export const RUST_MSRV = "1.87"; diff --git a/documentation/docs/next.config.js b/documentation/docs/next.config.js index 45e93e4089..1b1ace3068 100644 --- a/documentation/docs/next.config.js +++ b/documentation/docs/next.config.js @@ -1178,13 +1178,46 @@ const config = { basePath: false, }, + // Docs reorg: language-based sidebar + // Deleted routing pages → merged into integrations + { + source: "/docs/developers/native", + destination: "/docs/developers/integrations", + permanent: true, + basePath: false, + }, + { + source: "/docs/developers/browsers", + destination: "/docs/developers/integrations", + permanent: true, + basePath: false, + }, + // --- Directory index redirects (directories without index pages) --- + { + source: "/docs/developers/concepts", + destination: "/docs/developers/concepts/message-queue", + permanent: false, + basePath: false, + }, { source: "/docs/developers/typescript/bundling", destination: "/docs/developers/typescript/bundling/bundling", permanent: false, basePath: false, }, + { + source: "/docs/developers/typescript/api/mix-fetch", + destination: "/docs/developers/typescript/api/mix-fetch/globals", + permanent: false, + basePath: false, + }, + { + source: "/docs/developers/typescript/api/sdk", + destination: "/docs/developers/typescript/api/sdk/globals", + permanent: false, + basePath: false, + }, { source: "/docs/developers/typescript/examples", destination: "/docs/developers/typescript/examples/mix-fetch", @@ -1231,7 +1264,8 @@ const config = { }, { source: "/docs/apis/ns-api/ns-api-run-deploy", - destination: "/docs/operators/performance-and-testing/ns-api-deployment", + destination: + "/docs/operators/performance-and-testing/ns-api-deployment", permanent: true, basePath: false, }, diff --git a/documentation/docs/pages/_meta.json b/documentation/docs/pages/_meta.json index b3b6f66d1d..fc9040cc2c 100644 --- a/documentation/docs/pages/_meta.json +++ b/documentation/docs/pages/_meta.json @@ -8,21 +8,7 @@ }, "developers": { "title": "Developers", - "type": "menu", - "items": { - "developers": { - "title": "Integrations", - "href": "/developers/integrations" - }, - "rust": { - "title": "Rust SDK", - "href": "/developers/rust" - }, - "typescript": { - "title": "Typescript SDK", - "href": "/developers/typescript" - } - } + "type": "page" }, "operators": { "title": "Operators", diff --git a/documentation/docs/pages/apis/explorer-api.mdx b/documentation/docs/pages/apis/explorer-api.mdx index 77237bbd97..59b99d2ea1 100644 --- a/documentation/docs/pages/apis/explorer-api.mdx +++ b/documentation/docs/pages/apis/explorer-api.mdx @@ -1,6 +1,6 @@ --- title: "Explorer API (Deprecated)" -description: "Legacy Explorer API reference for the Nym Mixnet Explorer. Deprecated in favor of the Node Status API." +description: "Legacy Explorer API reference for the Nym Mixnet Explorer. Deprecated in favour of the Node Status API." schemaType: "TechArticle" section: "APIs" lastUpdated: "2026-03-15" diff --git a/documentation/docs/pages/apis/ns-api.mdx b/documentation/docs/pages/apis/ns-api.mdx index 619f640b3e..f97878c64c 100644 --- a/documentation/docs/pages/apis/ns-api.mdx +++ b/documentation/docs/pages/apis/ns-api.mdx @@ -19,7 +19,7 @@ The Node Status API serves information about individual `nym-node` instances in - **Mixnet summaries**: active set composition, role distribution, network health -If you're building a service that makes heavy use of this API, consider [running your own instance](/operators/performance-and-testing/ns-api-deployment) to distribute load and promote a robust network of downstream services. +If you're building a service that makes heavy use of this API, consider [running your own instance](/operators/performance-and-testing/ns-api-deployment) to distribute load across the network. ## Quick examples diff --git a/documentation/docs/pages/developers/_meta.json b/documentation/docs/pages/developers/_meta.json index 63a7809c49..f49822eeb5 100644 --- a/documentation/docs/pages/developers/_meta.json +++ b/documentation/docs/pages/developers/_meta.json @@ -1,19 +1,22 @@ { - "index": "Introduction", + "index": "Overview", + "integrations": "Choosing an Approach", + "concepts": "Key Concepts", + "--": { "type": "separator", - "title": "Mixnet Integrations" + "title": "Rust" }, - "integrations": "Overview - Start Here!", - "native": "Native Apps", - "browsers": "Browser Apps", - "concepts": "Nym-Specific Concepts for Integrations", + "smolmix": "smolmix", + "rust": "nym-sdk", + "-": { "type": "separator", - "title": "SDKs" + "title": "TypeScript" }, - "rust": "Rust SDK", - "typescript": "Typescript SDK", + "mix-fetch": "mix-fetch", + "typescript": "TypeScript SDK", + "---": { "type": "separator", "title": "Extras" diff --git a/documentation/docs/pages/developers/archive/nym-connect.mdx b/documentation/docs/pages/developers/archive/nym-connect.mdx index 024eff6ab5..e4b2da93c2 100644 --- a/documentation/docs/pages/developers/archive/nym-connect.mdx +++ b/documentation/docs/pages/developers/archive/nym-connect.mdx @@ -4,7 +4,7 @@ import { Callout } from 'nextra/components' NymConnect is no longer maintained as of early 2024. Nym is developing a new client called [NymVPN](https://nymvpn.com), an application routing all user traffic through the Mixnet. -If you want to route traffic through SOCKS5, use the maintained [Nym Socks5 Client](../clients/socks5/setup). +If you want to route traffic through SOCKS5, use the maintained [Nym Socks5 Client](/developers/clients/socks5/setup). In case you want to run deprecated NymConnect, follow these steps: @@ -29,7 +29,7 @@ Here are some examples of applications which will work behind Socks5 proxy (`nym To download Electrum visit the [official webpage](https://electrum.org/#download). To connect to the Mixnet follow these steps: -1. Start and connect NymConnect (or [`nym-socks5-client`](../clients/socks5/setup)) +1. Start and connect NymConnect (or [`nym-socks5-client`](/developers/clients/socks5/setup)) 2. Start your Electrum Bitcoin wallet 3. Go to: *Tools* -> *Network* -> *Proxy* 4. Set *Use proxy* to ✅, choose `SOCKS5` from the drop-down and add (copy-paste) the values from your NymConnect application @@ -41,7 +41,7 @@ To download Electrum visit the [official webpage](https://electrum.org/#download To download Monero wallet visit [getmonero.org](https://www.getmonero.org/downloads/). To connect to the Mixnet follow these steps: -1. Start and connect NymConnect (or [`nym-socks5-client`](../clients/socks5/setup)) +1. Start and connect NymConnect (or [`nym-socks5-client`](/developers/clients/socks5/setup)) 2. Start your Monero wallet 3. Go to: *Settings* -> *Interface* -> *Socks5 proxy* -> Add values: IP address `127.0.0.1`, Port `1080` (the values copied from NymConnect) 5. Now your Monero wallet runs through the Mixnet and it will be connected only if your NymConnect or `nym-socks5-client` are connected. @@ -51,7 +51,7 @@ To download Monero wallet visit [getmonero.org](https://www.getmonero.org/downlo To download Element (chat client for Matrix) visit [element.io](https://element.io/download). To connect to the Mixnet follow these steps: -1. Start and connect NymConnect (or [`nym-socks5-client`](../clients/socks5/setup)) +1. Start and connect NymConnect (or [`nym-socks5-client`](/developers/clients/socks5/setup)) 2. Start `element-desktop` with `--proxy-server` argument: **Linux** @@ -70,7 +70,7 @@ To make the start of Element over NymConnect simplier, you can add this command ### Telegram via NymConnect -1. Start and connect NymConnect (or [`nym-socks5-client`](../clients/socks5/setup)) +1. Start and connect NymConnect (or [`nym-socks5-client`](/developers/clients/socks5/setup)) 2. Start your Telegram chat application 3. Open the Telegram proxy settings. - Linux: *Settings* -> *Advanced* -> *Connection type* -> *Use custom proxy* diff --git a/documentation/docs/pages/developers/browsers.mdx b/documentation/docs/pages/developers/browsers.mdx deleted file mode 100644 index e61cacd694..0000000000 --- a/documentation/docs/pages/developers/browsers.mdx +++ /dev/null @@ -1,43 +0,0 @@ ---- -title: "Browser-Based App Integration" -description: "Build privacy-preserving browser apps with mixFetch and the Nym WASM SDK. Route HTTP requests and messages through the mixnet from the browser." -schemaType: "TechArticle" -section: "Developers" -lastUpdated: "2026-04-07" ---- - -import { Callout } from 'nextra/components'; - -# Browser-Based Apps - -Browsers are a restricted environment: communication is limited to WebSockets, Web Transport, and WebRTC; mixed content policies enforce HTTPS-only; and there is no access to the filesystem or system calls. The main obstacle for routing traffic through the Mixnet is the lack of access to browser TLS negotiation or the CA certificate store from JavaScript. - -Two integration options are available, both delivered as packages bundled into your web application. - -![](/images/developers/nym-browser-arch.png) - -## mixFetch - -A drop-in replacement for the browser `fetch` API that makes HTTP(S) requests via Exit Gateways using the SOCKS Network Requester. It ships with an embedded CA certificate store to establish a TLS session between `mixFetch` and the remote host, creating a secure channel from the browser to the destination over the Mixnet. - -Internally, `mixFetch` uses the WASM client. - -- [Docs](./typescript#mixfetch) -- [Example](./typescript/playground/mixfetch) - - -`mixFetch` currently supports a maximum of 10 concurrent in-flight requests. `mixFetchv2`, which will function as a general-purpose userspace IP stack, is in development. - - -## WASM Client - -Constructs Sphinx packets and cover traffic in WASM, sent over a WebSocket to the Entry Gateway. Responses arrive the same way. - -This operates in messaging mode only (text or binary payloads) and does not currently support IP packet routing via the Exit Gateway IPR or any stream-like API. For HTTP(S) requests, use `mixFetch`. - -Standard browser CSP and mixed content restrictions (HTTPS only) apply to the WebSocket connection, including in embedded WebViews. - -The client runs in a web worker to keep the UI thread free. - -- [Docs](./typescript#mixnet-client) -- [Example](./typescript/playground/traffic) diff --git a/documentation/docs/pages/developers/chain.md b/documentation/docs/pages/developers/chain.md index e99085cbba..683438a5e6 100644 --- a/documentation/docs/pages/developers/chain.md +++ b/documentation/docs/pages/developers/chain.md @@ -13,11 +13,11 @@ There are two options for interacting with the blockchain to send tokens or inte * `nyxd` binary ## Nym-CLI tool (recommended in most cases) -The `nym-cli` tool is a binary offering a simple interface for interacting with deployed smart contract (for instance, bonding and unbonding a Mix Node from the CLI), as well as creating and managing accounts and keypairs, sending tokens, and querying the blockchain. +The `nym-cli` tool is a binary offering an interface for interacting with deployed smart contracts (e.g. bonding and unbonding a Mix Node from the CLI), creating and managing accounts and keypairs, sending tokens, and querying the blockchain. -Instructions on how to do so can be found on the [`nym-cli` docs page](./tools/nym-cli) +See the [`nym-cli` docs page](./tools/nym-cli) for instructions. ## Nyxd binary -The `nyxd` binary, although more complex to compile and use, offers the full range of commands availiable to users of CosmosSDK chains. Use this if you are (e.g.) wanting to perform more granular queries about transactions from the CLI. +The `nyxd` binary, although harder to compile and use, offers the full range of commands available to users of CosmosSDK chains. Use this when you need more granular queries about transactions from the CLI. -You can use the instructions on how to do this on from the [`gaiad` docs page](https://hub.cosmos.network/main/delegators/delegator-guide-cli.html#querying-the-state). +The [`gaiad` docs page](https://hub.cosmos.network/main/delegators/delegator-guide-cli.html#querying-the-state) covers how to do this. diff --git a/documentation/docs/pages/developers/chain/cli-wallet.md b/documentation/docs/pages/developers/chain/cli-wallet.md index 9b7d25eb91..9ce94d9478 100644 --- a/documentation/docs/pages/developers/chain/cli-wallet.md +++ b/documentation/docs/pages/developers/chain/cli-wallet.md @@ -1,6 +1,6 @@ # CLI Wallet -If you have already read our [validator setup and maintenance documentation](../../operators/nodes/validator-setup) you will have seen that we compile and use the `nyxd` binary primarily for our validators. This binary can however be used for many other tasks, such as creating and using keypairs for wallets, or automated setups that require the signing and broadcasting of transactions. +If you have read our [validator setup and maintenance documentation](../../operators/nodes/validator-setup), you will have seen that we compile and use the `nyxd` binary primarily for our validators. This binary can also be used for other tasks, such as creating and using keypairs for wallets, or automated setups that require the signing and broadcasting of transactions. ### Using `nyxd` binary as a CLI wallet -You can use the `nyxd` as a minimal CLI wallet if you want to set up an account (or multiple accounts). Just compile the binary as per the documentation, **stopping after** the [building your validator](../../operators/nodes/validator-setup#building-your-validator) step is complete. You can then run `nyxd keys --help` to see how you can set up and store different keypairs with which to interact with the Nyx blockchain. +You can use `nyxd` as a minimal CLI wallet if you want to set up one or more accounts. Compile the binary as per the documentation, stopping after the [building your validator](../../operators/nodes/validator-setup#building-your-validator) step is complete. Then run `nyxd keys --help` to see how to set up and store keypairs for interacting with the Nyx blockchain. diff --git a/documentation/docs/pages/developers/chain/ledger-live.md b/documentation/docs/pages/developers/chain/ledger-live.md index dd7f933c2a..a320dccd46 100644 --- a/documentation/docs/pages/developers/chain/ledger-live.md +++ b/documentation/docs/pages/developers/chain/ledger-live.md @@ -1,6 +1,6 @@ # Ledger Live Support -Use the following instructions to interact with the Nyx blockchain - either with deployed smart contracts, or just to send tokens - using your Ledger device to sign transactions. +Use the following instructions to interact with the Nyx blockchain (either with deployed smart contracts, or to send tokens) using your Ledger device to sign transactions. ## Prerequisites * Download and install [Ledger Live](https://www.ledger.com/ledger-live). @@ -8,7 +8,7 @@ Use the following instructions to interact with the Nyx blockchain - either with ## Prepare your Ledger App * Plug in your Ledger device -* Install the `Cosmos (ATOM)` app by following the instructions [here](https://hub.cosmos.network/main/resources/ledger.html). This app allows you to interact with **any** Cosmos SDK chain - you can manage your ATOM, OSMOSIS, NYM tokens, etc. +* Install the `Cosmos (ATOM)` app by following the instructions [here](https://hub.cosmos.network/main/resources/ledger.html). This app works with any Cosmos SDK chain, so you can manage your ATOM, OSMOSIS, NYM tokens, etc. * On the device, navigate to the Cosmos app and open it ## Create a keypair diff --git a/documentation/docs/pages/developers/chain/rpc-node.md b/documentation/docs/pages/developers/chain/rpc-node.md index 091edcd56e..67d915615c 100644 --- a/documentation/docs/pages/developers/chain/rpc-node.md +++ b/documentation/docs/pages/developers/chain/rpc-node.md @@ -8,10 +8,10 @@ lastUpdated: "2026-02-01" # RPC Nodes -RPC Nodes (which might otherwise be referred to as 'Lite Nodes' or just 'Full Nodes') differ from Validators in that they hold a copy of the Nyx blockchain, but do **not** participate in consensus / block-production. +RPC Nodes (sometimes called 'Lite Nodes' or 'Full Nodes') differ from Validators in that they hold a copy of the Nyx blockchain but do **not** participate in consensus / block-production. -You may want to set up an RPC Node for querying the blockchain, or in order to have an endpoint that your app can use to send transactions. +You may want to set up an RPC Node for querying the blockchain, or to provide an endpoint that your app can use to send transactions. -In order to set up an RPC Node, simply follow the instructions to set up a [Validator](../../operators/nodes/validator-setup), but **exclude the `nyxd tx staking create-validator` command**. +To set up an RPC Node, follow the instructions to set up a [Validator](../../operators/nodes/validator-setup), but **exclude the `nyxd tx staking create-validator` command**. If you want to fast-sync your node, check out the Polkachu snapshot and their other [resources](https://polkachu.com/seeds/nym). diff --git a/documentation/docs/pages/developers/clients/socks5.mdx b/documentation/docs/pages/developers/clients/socks5.mdx index bcc1a6458d..e0a70f0164 100644 --- a/documentation/docs/pages/developers/clients/socks5.mdx +++ b/documentation/docs/pages/developers/clients/socks5.mdx @@ -1,6 +1,6 @@ # Socks5 Client (Standalone) -> This client can also be utilised via the [Rust SDK](../rust). +> This client can also be utilised via the [Rust SDK](/developers/rust). Many existing applications are able to use either the SOCKS4, SOCKS4A, or SOCKS5 proxy protocols. If you want to send such an application's traffic through the mixnet, you can use the `nym-socks5-client` to bounce network traffic through the Nym network, like this: @@ -47,4 +47,4 @@ The `nym-socks5-client` allows you to do the following from your local machine: * Chop up the TCP stream into multiple Sphinx packets, assigning sequence numbers to them, while leaving the TCP connection open for more data * Send the Sphinx packets through the Nym Network. Packets are shuffled and mixed as they transit the mixnet. -The `nym-node` then reassembles the original TCP stream using the packets' sequence numbers, and make the intended request. It will then chop up the response into Sphinx packets and send them back through the mixnet to your `nym-socks5-client`. The application will then receive its data, without even noticing that it wasn't talking to a "normal" SOCKS5 proxy! +The `nym-node` then reassembles the original TCP stream using the packets' sequence numbers, and makes the intended request. It chops up the response into Sphinx packets and sends them back through the mixnet to your `nym-socks5-client`. The application receives its data without noticing that it wasn't talking to a "normal" SOCKS5 proxy. diff --git a/documentation/docs/pages/developers/clients/socks5/config.mdx b/documentation/docs/pages/developers/clients/socks5/config.mdx index a70404c9c6..0a9bffdd65 100644 --- a/documentation/docs/pages/developers/clients/socks5/config.mdx +++ b/documentation/docs/pages/developers/clients/socks5/config.mdx @@ -21,7 +21,7 @@ tree $HOME//.nym/socks5-clients/docs-example The `config.toml` file contains client configuration options, while the two `pem` files contain client key information. -The generated files contain the client name, public/private keypairs, and gateway address. The name `` in the example above is just a local identifier so that you can name your clients. +The generated files contain the client name, public/private keypairs, and gateway address. The name `` in the example above is a local identifier so that you can name your clients. ## Configuring your client for Docker By default, the native client listens to host `127.0.0.1`. However this can be an issue if you wish to run a client in a Dockerized environment, where it can be convenenient to listen on a different host such as `0.0.0.0`. diff --git a/documentation/docs/pages/developers/clients/socks5/setup.mdx b/documentation/docs/pages/developers/clients/socks5/setup.mdx index f62388eb06..355bbae215 100644 --- a/documentation/docs/pages/developers/clients/socks5/setup.mdx +++ b/documentation/docs/pages/developers/clients/socks5/setup.mdx @@ -4,7 +4,7 @@ If you are using OSX or a Debian-based operating system, you can download the `nym-socks5-client` binary from our [Github releases page](https://github.com/nymtech/nym/releases). -If you are using a different operating system, or want to build from source, simply use `cargo build --release` from the root of the Nym monorepo. +If you are using a different operating system, or want to build from source, run `cargo build --release` from the root of the Nym monorepo. ## Viewing command help You can check that your binaries are properly compiled with: diff --git a/documentation/docs/pages/developers/clients/socks5/usage.mdx b/documentation/docs/pages/developers/clients/socks5/usage.mdx index e4bc0442d9..22730d6c30 100644 --- a/documentation/docs/pages/developers/clients/socks5/usage.mdx +++ b/documentation/docs/pages/developers/clients/socks5/usage.mdx @@ -57,4 +57,4 @@ Adding `--no-banner` startup flag will prevent Nym banner being printed even if **build-info** -A `build-info` command prints the build information like commit hash, rust version, binary version just like what command `--version` does. However, you can also specify an `--output=json` flag that will format the whole output as a json, making it an order of magnitude easier to parse. +A `build-info` command prints the build information (commit hash, rust version, binary version), the same as `--version`. You can also specify an `--output=json` flag that formats the whole output as JSON, which is much easier to parse. diff --git a/documentation/docs/pages/developers/clients/webassembly-client.mdx b/documentation/docs/pages/developers/clients/webassembly-client.mdx index 9c7241c1ac..e84f0aa211 100644 --- a/documentation/docs/pages/developers/clients/webassembly-client.mdx +++ b/documentation/docs/pages/developers/clients/webassembly-client.mdx @@ -1,22 +1,23 @@ # Webassembly Client ## Overview -The Nym webassembly client allows any webassembly-capable runtime to build and send Sphinx packets to the Nym network, for uses in edge computing and browser-based applications. +The Nym webassembly client lets any webassembly-capable runtime build and send Sphinx packets to the Nym network, for use in edge computing and browser-based applications. -This is currently packaged and distributed for ease of use via the [Nym Typescript SDK library](../typescript). **We imagine most developers will use this client via the SDK for ease.** +It is packaged and distributed via the [Nym Typescript SDK library](/developers/typescript). Most developers consume it through the SDK. -The webassembly client allows for the easy creation of Sphinx packets from within mobile apps and browser-based client-side apps (including Electron or similar). +The client supports building Sphinx packets from mobile apps and browser-based client-side apps (including Electron or similar). ## Building apps with Webassembly Client -Check out the [Typescript SDK docs](../typescript) for examples of usage. +See the [Typescript SDK docs](/developers/typescript) for examples of usage. + +## Think about what you're sending -## Think about what you're sending! import { Callout } from 'nextra/components' - - Think about what information your app sends. That goes for whatever you put into your Sphinx packet messages as well as what your app's environment may leak. + + Think about what information your app sends. That covers whatever you put into your Sphinx packet messages as well as what your app's environment may leak. -Whenever you write client apps using HTML/JavaScript, we recommend that you **do not load external resources from CDNs**. Webapp developers do this all the time, to save load time for common resources, or just for convenience. But when you're writing privacy apps it's better not to make these kinds of requests. Pack everything locally. +When writing client apps in HTML/JavaScript, we recommend you **do not load external resources from CDNs**. Webapp developers often do this to save load time for common resources, or for convenience. For privacy apps it's better not to make these kinds of requests. Pack everything locally. -If you use only local resources within your Electron app or your browser extensions, explicitly encoding request data in a Sphinx packet does protect you from the normal leakage that gets sent in a browser HTTP request. [There's a lot of stuff that leaks when you make an HTTP request from a browser window](https://panopticlick.eff.org/). Luckily, all that metadata and request leakage doesn't happen in Nym, because you're choosing very explicitly what to encode into Sphinx packets, instead of sending a whole browser environment by default. +If you use only local resources within your Electron app or your browser extensions, encoding request data in a Sphinx packet protects you from the normal leakage that gets sent in a browser HTTP request. [A lot of metadata leaks when you make an HTTP request from a browser window](https://panopticlick.eff.org/). That leakage doesn't happen in Nym, because you control what gets encoded into Sphinx packets, rather than sending a whole browser environment by default. diff --git a/documentation/docs/pages/developers/clients/websocket.md b/documentation/docs/pages/developers/clients/websocket.md index 2214c1a108..e2c3e9a7c7 100644 --- a/documentation/docs/pages/developers/clients/websocket.md +++ b/documentation/docs/pages/developers/clients/websocket.md @@ -20,7 +20,7 @@ cargo build --release -p nym-client The binary will be at `target/release/nym-client`. -## Initialize and run +## Initialise and run ```bash # Create a new client identity diff --git a/documentation/docs/pages/developers/clients/websocket/config.md b/documentation/docs/pages/developers/clients/websocket/config.md index 8bd6d8bedb..883fe7275d 100644 --- a/documentation/docs/pages/developers/clients/websocket/config.md +++ b/documentation/docs/pages/developers/clients/websocket/config.md @@ -37,7 +37,7 @@ tree $HOME//.nym/clients/example-client The `config.toml` file contains client configuration options, while the two `pem` files contain client key information. -The generated files contain the client name, public/private keypairs, and gateway address. The name `` in the example above is just a local identifier so that you can name your clients. +The generated files contain the client name, public/private keypairs, and gateway address. The name `` in the example above is a local identifier so that you can name your clients. ### Configuring your client for Docker By default, the native client listens to host `127.0.0.1`. However this can be an issue if you wish to run a client in a Dockerized environment, where it can be convenenient to listen on a different host such as `0.0.0.0`. diff --git a/documentation/docs/pages/developers/clients/websocket/examples.md b/documentation/docs/pages/developers/clients/websocket/examples.md index c06b1707d6..2705ace056 100644 --- a/documentation/docs/pages/developers/clients/websocket/examples.md +++ b/documentation/docs/pages/developers/clients/websocket/examples.md @@ -10,8 +10,4 @@ All of these code examples will do the following: * wait for confirmation that the message hit the native client * wait to receive messages from other Nym apps -By varying the message content, you can easily build sophisticated service provider apps. For example, instead of printing the response received from the mixnet, your service provider might take some action on behalf of the user - perhaps initiating a network request, a blockchain transaction, or writing to a local data store. - - +By varying the message content, you can build service provider apps. For example, instead of printing the response received from the mixnet, your service provider might take some action on behalf of the user (initiating a network request, a blockchain transaction, or writing to a local data store). diff --git a/documentation/docs/pages/developers/clients/websocket/usage.md b/documentation/docs/pages/developers/clients/websocket/usage.md index 9f60a48e49..5219f9611a 100644 --- a/documentation/docs/pages/developers/clients/websocket/usage.md +++ b/documentation/docs/pages/developers/clients/websocket/usage.md @@ -4,7 +4,7 @@ The Nym native client exposes a websocket interface that your code connects to. Once you have a websocket connection, interacting with the client involves piping messages down the socket and listening for incoming messages. ## Message Requests -There are a number of message types that you can send up the websocket as defined [here](https://github.com/nymtech/nym/blob/master/clients/native/websocket-requests/src/responses.rs#L48). +The message types you can send up the websocket are defined [here](https://github.com/nymtech/nym/blob/master/clients/native/websocket-requests/src/responses.rs#L48). ### Getting your own address When you start your app, it is best practice to ask the native client to tell you what your own address is (from the generated configuration files . If you are running a service, you need to do this in order to know what address to give others. In a client-side piece of code you can also use this as a test to make sure your websocket connection is running smoothly. To do this, send: @@ -26,7 +26,7 @@ You'll receive a response of the format: See [here](https://github.com/nymtech/nym/blob/93cc281abc2cc951023b51746fa6f2ead1f56c46/clients/native/examples/python-examples/websocket/textsend.py#L16C9-L16C9) for an example of this being used. -> Note that all the pieces of native client example code begin with printing the selfAddress. Examples exist for Rust, Go, Javascript, and Python. +> All native client example code begins with printing the selfAddress. Examples exist for Rust, Go, Javascript, and Python. ### Sending text If you want to send text information through the mixnet, format a message like this one and poke it into the websocket: @@ -52,11 +52,11 @@ In some applications, e.g. where people are chatting with friends who they know, } ``` -**If that fits your security model, good. However, will probably be the case that you want to send anonymous replies using Single Use Reply Blocks (SURBs)**. +If that fits your security model, good. Otherwise, send anonymous replies using Single Use Reply Blocks (SURBs). -You can read more about SURBs [here](/network/mixnet-mode/anonymous-replies) but in short they are ways for the receiver of this message to anonymously reply to you - the sender - **without them having to know your client address**. +More on SURBs in the [anonymous replies docs](/network/mixnet-mode/anonymous-replies). In short, they let the receiver of a message reply to you without needing to know your client address. -Your client will send along a number of `replySurbs` to the recipient of the message. These are pre-addressed Sphinx packets that the recipient can write to the payload of (i.e. write response data to), but not view the final destination of. If the recipient is unable to fit the response data into the bucket of SURBs sent to it, it will use a SURB to request more SURBs be sent to it from your client. +Your client will send some `replySurbs` along to the recipient of the message. These are pre-addressed Sphinx packets that the recipient can write response data into, but cannot see the final destination of. If the recipient cannot fit its response into the bucket of SURBs sent to it, it will use a SURB to request more SURBs from your client. ```json { @@ -69,7 +69,7 @@ Your client will send along a number of `replySurbs` to the recipient of the mes See ['Replying to SURB Messages'](#replying-to-surb-messages) below for an example of how to deal with incoming messages that have SURBs attached. -Deciding on the amount of SURBs to generate and send along with outgoing messages depends on the expected size of the reply. You might want to send a lot of SURBs in order to make sure you get your response as quickly as possible (but accept the minor additional latency when sending, as your client has to generate and encrypt the packets), or you might just send a few (e.g. 20) and then if your response requires more SURBs, send them along, accepting the additional latency in getting your response. +How many SURBs to generate and send with outgoing messages depends on the expected size of the reply. Send many SURBs to get the response as quickly as possible (accepting minor additional latency at send time, since your client has to generate and encrypt the packets), or send a few (e.g. 20) and let the recipient request more if needed (accepting additional latency in receiving the response). ### Sending binary data You can also send bytes instead of JSON. For that you have to send a binary websocket frame containing a binary encoded diff --git a/documentation/docs/pages/developers/concepts/message-queue.mdx b/documentation/docs/pages/developers/concepts/message-queue.mdx index c93dc5bf3c..1e1d4c66c5 100644 --- a/documentation/docs/pages/developers/concepts/message-queue.mdx +++ b/documentation/docs/pages/developers/concepts/message-queue.mdx @@ -11,7 +11,7 @@ import { Callout } from 'nextra/components' # Message Queue - Although useful for understanding how the Nym Client works internally, this information is only of practical use if you are using the [`Mixnet`](../rust/mixnet) module of the Rust SDK and interacting with the client at a low level. Most of this is abstracted away by the [`Stream`](../rust/stream) module (`AsyncRead + AsyncWrite` channels) and the [`TcpProxy`](../rust/tcpproxy) module (TCP tunnelling with message ordering). + Useful for understanding how the Nym Client works internally, but only of practical interest if you are using the [`Mixnet`](/developers/rust/mixnet) module of the Rust SDK and interacting with the client at a low level. The [`Stream`](/developers/rust/stream) module (`AsyncRead + AsyncWrite` channels) abstracts most of this away. ## Sphinx Packet Streams @@ -85,6 +85,6 @@ sequenceDiagram When passing a message to a client (however you do it, either piping messages from an app to a standalone client or via one of the `send` functions exposed by the SDKs), you are **putting that message into the queue** to be source encrypted and sent in the future, in order to ensure that traffic leaving the client does so in a manner that to an external observer is uniform / does not create any 'burst' or change in traffic timings that could aid traffic analysis. ## Note on Client Shutdown -Accidentally dropping a client before your message has been sent is something that is possible and should be avoided (see the [troubleshooting guide](../rust/mixnet/troubleshooting) for more on this) but is easy to avoid simply by remembering to: +Accidentally dropping a client before your message has been sent is possible and should be avoided (see the [troubleshooting guide](/developers/rust/mixnet/troubleshooting) for more on this). To avoid it: - keep your client process alive, even if you are not expecting a reply to your message -- (in the case of the SDKs) properly disconnecting your client in order to make sure that the message queue is flushed of Sphinx packets with actual payloads. +- (with the SDKs) disconnect your client properly so that the message queue is flushed of Sphinx packets with real payloads. diff --git a/documentation/docs/pages/developers/index.mdx b/documentation/docs/pages/developers/index.mdx index 846afb616a..40bdcfa3a1 100644 --- a/documentation/docs/pages/developers/index.mdx +++ b/documentation/docs/pages/developers/index.mdx @@ -1,27 +1,30 @@ --- -title: "Nym Developer Portal: SDKs & Tools" -description: "Developer documentation for building privacy-enhanced applications on the Nym mixnet. Covers Rust SDK, TypeScript SDK, blockchain interaction & CLI tools." +title: "Overview" +description: "Developer documentation index for the Nym mixnet: Rust and TypeScript SDKs, smolmix, mix-fetch, chain interaction, and CLI tools." schemaType: "TechArticle" section: "Developers" -lastUpdated: "2026-02-01" +lastUpdated: "2026-05-12" --- -# Developer Documentation +# Overview -Build applications that protect user metadata using the Nym Mixnet. This section covers SDK integration, blockchain interaction, and developer tools. +This section covers the SDKs, standalone crates, blockchain interaction, and developer tools for building on the Nym mixnet. -## Where to start +## Start here -**Choosing an integration approach:** read [Integrations](/developers/integrations) to understand the architectural trade-offs (native SDK vs proxy vs mixFetch), then pick your path: +If you're new, read **[Choosing an Approach](/developers/integrations)** first. It maps your runtime (native vs browser vs mobile) and your architecture (end-to-end vs proxy) onto the right crate/library. -- **[Rust SDK](/developers/rust):** full-featured SDK with message passing, `AsyncRead`/`AsyncWrite` streams, and client pooling. Start with the [Tour](/developers/rust/tour). -- **[TypeScript SDK](/developers/typescript):** browser and Node.js SDK for mixFetch, Mixnet client, and smart contract interaction. -- **[Standalone Clients](/developers/clients):** language-agnostic SOCKS5 and WebSocket proxies for piping traffic through the Mixnet without an SDK. +## Crates/Libraries -## Blockchain interaction +| Crate/library | Language | Use it for | +|---|---|---| +| [`nym-sdk`](/developers/rust) | Rust | E2E messaging, `AsyncRead`/`AsyncWrite` streams, client pooling. Start with the [Tour](/developers/rust/tour). | +| [`smolmix`](/developers/smolmix) | Rust | `TcpStream` and `UdpSocket` over the Mixnet via a userspace IP stack. Compatible with `tokio-rustls`, `hyper`, `tungstenite`. | +| [`mix-fetch`](/developers/mix-fetch) | TypeScript | `fetch()`-compatible API for browser HTTP(S) requests over the Mixnet. | +| [TypeScript SDK](/developers/typescript) | TypeScript | Browser-side Mixnet Client (raw messaging) and Nyx Smart Contracts. | +| [Standalone Clients](/developers/clients) | Language-agnostic | SOCKS5 and WebSocket binaries for piping traffic through the Mixnet without an SDK. | -The Nym Network is coordinated by the [Nyx blockchain](/network/infrastructure/nyx). To query chain state, submit transactions, or interact with smart contracts, see [Chain Interaction](/developers/chain). +## Other sections -## API reference - -Auto-generated API specs for Nym infrastructure endpoints are in the [APIs section](/apis/introduction). +- **[Chain interaction](/developers/chain)**: query Nyx state, submit transactions, and call Nym smart contracts. +- **[APIs](/apis/introduction)**: auto-generated reference for Nym infrastructure HTTP endpoints. diff --git a/documentation/docs/pages/developers/integrations.mdx b/documentation/docs/pages/developers/integrations.mdx index 713d5cd2a1..83174c554e 100644 --- a/documentation/docs/pages/developers/integrations.mdx +++ b/documentation/docs/pages/developers/integrations.mdx @@ -1,23 +1,38 @@ --- -title: "Integrating With Nym" -description: "Choose an integration path for sending application traffic through the Nym mixnet, depending on your runtime environment and architecture." +title: "Choosing an Approach" +description: "Decide which Nym integration path fits your project. Compare nym-sdk, smolmix, mix-fetch, and the TypeScript SDK by runtime environment and architecture." schemaType: "TechArticle" section: "Developers" -lastUpdated: "2026-04-07" +lastUpdated: "2026-05-12" --- import { Callout } from 'nextra/components'; -# Integrating With Nym +# Choosing an Approach -Any application that integrates with Nym sends its traffic through the Mixnet via a Nym client. The right integration path depends on two factors: **environment** and **architecture**. +Any application that integrates with Nym sends its traffic through the Mixnet via a Nym client. The right product depends on two factors: your **environment** (where your code runs) and your **architecture** (whether you control both sides of the communication). + +## At a glance + +| | **End-to-end** (both sides run Nym) | **Proxy mode** (Nym → clearnet exit) | +|---|---|---| +| **Rust** (native / desktop / CLI) | [`nym-sdk`](/developers/rust) (Stream, Mixnet, Client Pool) | [`smolmix`](/developers/smolmix) (TCP / UDP) · [`nym-sdk`](/developers/rust) SOCKS client | +| **TypeScript** (browser) | [TypeScript SDK](/developers/typescript) (WASM Mixnet Client, messaging only) | [`mix-fetch`](/developers/mix-fetch) (HTTP) | +| **Mobile** (iOS / Android) | via [`nym-vpn-client`](#mobile) (uniffi + cargo-swift / cargo-ndk) | via [`nym-vpn-client`](#mobile) (uniffi + cargo-swift / cargo-ndk) | ## Environment Different runtimes have different transport constraints: a browser cannot open raw sockets or access the filesystem, while a desktop app can. -- **Native / Desktop**: full access to system networking and persistent storage. Use the [Rust SDK](./rust). -- **Browser**: restricted to WebSockets, Web Transport, and `fetch`, with HTTPS-only mixed content rules and no filesystem access. Use the [TypeScript SDK](./typescript). +- **Native / Desktop / CLI**: full access to system networking and persistent storage. Use [`nym-sdk`](/developers/rust) (the Rust SDK) for E2E messaging or byte streams, or [`smolmix`](/developers/smolmix) for TCP/UDP socket-shaped access in proxy mode. +- **Browser**: restricted to WebSockets, Web Transport, and `fetch`; HTTPS-only mixed-content rules; no filesystem access. Use [`mix-fetch`](/developers/mix-fetch) for HTTP(S) requests, or the [TypeScript SDK](/developers/typescript)'s WASM Mixnet Client for raw message passing. + +### Mobile + +There is no first-party mobile SDK, but [`nym-vpn-client`](https://github.com/nymtech/nym-vpn-client) ships production iOS and Android apps built around the Nym stack and is the reference we'd point you at. The relevant pieces are `nym-vpn-core/crates/nym-vpn-lib-uniffi` ([`uniffi`](https://mozilla.github.io/uniffi-rs/) FFI wrapper), `nym-vpn-core/iOS.mk` ([`cargo-swift`](https://github.com/antoniusnaumann/cargo-swift) → XCFramework + SwiftPM), and `nym-vpn-core/Android.mk` ([`cargo-ndk`](https://github.com/bbqsrc/cargo-ndk) → `jniLibs/`, driven from Gradle). + + +If you try this and hit (or solve) blockers, drop a note in the [Nym dev channel on Matrix](https://matrix.to/#/#dev:nymtech.chat) or open an issue on [GitHub](https://github.com/nymtech/nym). ## Architecture @@ -25,10 +40,24 @@ The second factor is whether you control both sides of the communication. **End-to-end (E2E)**: both sides run Nym clients. All traffic stays Sphinx-encrypted the entire way. Appropriate for peer-to-peer setups or any case where you control both endpoints. -**Proxy**: only the client side runs Nym. Traffic exits the Mixnet at an Exit Gateway and continues to the destination as normal internet traffic. Appropriate when connecting to third-party services (blockchain RPCs, external APIs) that you do not control. +![](/images/developers/nym-arch-client-to-client.png) + +**Proxy**: only the client side runs Nym. Traffic exits the Mixnet at an Exit Gateway and continues to the destination over the public internet. The mixnet anonymises the sender; payload protection (TLS, Noise, etc.) is your application's job, as on a direct connection. Appropriate when connecting to third-party services such as blockchain RPCs or external APIs. + +![](/images/developers/nym-arch-ip-routing.png) -In proxy mode, the last hop from Exit Gateway to the remote host travels as standard internet traffic. This is weaker than E2E against a global passive adversary, but still provides timing obfuscation and sender-receiver unlinkability. +Once traffic leaves the Exit Gateway, it travels over the public internet to the destination, exactly like any other server-initiated connection. The mixnet anonymises the sender but does not encrypt the payload past the gateway. Use TLS or another application-layer cipher as you would on a direct connection. See [Exit Gateway Services](/network/infrastructure/exit-services) for what the exit can and cannot observe. -See the [Native / Desktop](./native) and [Browser](./browsers) pages for the specific modules available in each environment. +**Browser apps**: both proxy and E2E modes work slightly differently in a browser setting. The Nym client runs as a WASM blob inside a Web Worker, and your application communicates with it via JS bindings rather than direct function calls. The mixnet behaviour is identical; the integration shape differs. + +![](/images/developers/nym-browser-arch.png) + +## Where to go next + +- **Rust, E2E messaging or byte streams**: [`nym-sdk`](/developers/rust) +- **Rust, TCP/UDP socket replacements**: [`smolmix`](/developers/smolmix) +- **Browser, HTTP(S) requests**: [`mix-fetch`](/developers/mix-fetch) +- **Browser, raw mixnet messaging or Nyx smart contracts**: [TypeScript SDK](/developers/typescript) +- **Background on Sphinx, gateways, and the mixnet itself**: [Key Concepts](/developers/concepts) diff --git a/documentation/docs/pages/developers/mix-fetch.mdx b/documentation/docs/pages/developers/mix-fetch.mdx new file mode 100644 index 0000000000..9aa7223679 --- /dev/null +++ b/documentation/docs/pages/developers/mix-fetch.mdx @@ -0,0 +1,130 @@ +--- +title: "mix-fetch: fetch() Over the Nym Mixnet" +description: "Package providing a fetch()-compatible API that routes HTTP(S) requests through the Nym mixnet via a Network Requester. Available for browsers and Node.js." +schemaType: "TechArticle" +section: "Developers" +lastUpdated: "2026-05-12" +--- + +import { Callout } from 'nextra/components' + +# mix-fetch + +`mix-fetch` is a replacement for [`fetch`](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch) that routes HTTP(S) requests through the Nym mixnet. The call signature is identical; underneath, the request is tunnelled through a WASM Nym client to a Network Requester (a Nym service provider, typically operated by an Exit Gateway), which decodes a SOCKS5-shaped connect request and opens a TCP connection to the destination. TLS runs end-to-end between the WASM bundle and the destination server. + +Available for browsers and Node.js, with the WASM core shared between both. + +```text +┌────────────────────────────────────────────────────────────────────┐ +│ Your app (browser or Node.js) │ +│ └─ mixFetch('https://...') (fetch() replacement) │ +│ └─ Go-WASM HTTP/TLS client (embedded Mozilla CA bundle) │ +│ └─ Rust-WASM SOCKS5 framing + Nym mixnet transport │ +│ └─ WebSocket → entry gateway │ +│ └─ mixnet (Sphinx, 3 mix hops by default) │ +│ └─ Network Requester decodes SOCKS5 │ +│ request, opens TCP to dest │ +│ └─ destination server │ +│ (TLS handshake here) │ +└────────────────────────────────────────────────────────────────────┘ +``` + +Two WASM modules sit in the bundle: a Go module that handles HTTP and TLS (Go's `crypto/tls` compiled to WASM, with an embedded Mozilla root CA list), and a Rust module that handles SOCKS5 request framing and the Nym mixnet client itself. They communicate via JS bindings. + +Because TLS terminates at the destination (not at the Network Requester or any node before it), every hop from the entry gateway onwards only sees TLS ciphertext for HTTPS targets. This is the same trust model as a normal HTTPS request through a SOCKS proxy. + + +The "SOCKS5" framing here is Nym's binary `Socks5Request` format wrapped in Sphinx packets, not RFC 1928 SOCKS5 over a TCP socket. The Network Requester decodes it on the mixnet side and proxies onwards as regular TCP. + + +## Runtime and platform support + +### Browser + +The WASM core runs in a Web Worker and needs: +- WebSocket support, for the entry-gateway connection +- WebAssembly +- A CSP that permits `wss://` connections and `worker-src 'self'` (or `blob:` for the `*-full-fat` variants, which load workers as inline blobs) + +Mixed-content rules apply: target URLs must be HTTPS. + +### Node.js + +The same WASM core runs in a `worker_threads` worker. The `ws` package polyfills `WebSocket`, and a Node-flavoured `comlink` adapter (`mix-fetch-node/src/node-adapter.ts`) bridges `worker_threads` to the same Worker-like API surface. + +## Installation + +### Browser variants + +| Variant | Package | When to use | +|---|---|---| +| ESM | `@nymproject/mix-fetch` | Modern project, you can configure your bundler | +| ESM full-fat | `@nymproject/mix-fetch-full-fat` | Modern project, can't configure your bundler | +| CommonJS | `@nymproject/mix-fetch-commonjs` | Legacy project, you can configure your bundler | +| CommonJS full-fat | `@nymproject/mix-fetch-commonjs-full-fat` | Legacy project, can't configure your bundler | + +### Node.js variant + +| Variant | Package | When to use | +|---|---|---| +| CommonJS | `@nymproject/mix-fetch-node-commonjs` | Node.js (currently the only published Node variant) | + +The standard browser variants need your bundler to handle WASM and web workers (see [Bundling](/developers/typescript/bundling)). The `*-full-fat` variants inline both as Base64 so no bundler configuration is needed. + + +The `*-full-fat` variants are large (~18 MB), since they inline ~10 MB of WASM (Go runtime + Rust core) and the web-worker source as Base64. Prefer a standard variant if bundle size matters. + + +```bash +# Browser +npm install @nymproject/mix-fetch-full-fat + +# Node.js +npm install @nymproject/mix-fetch-node-commonjs +``` + + +`mixFetch` caps concurrent connections at **10 per destination host** (Go `http.Transport`'s `MaxConnsPerHost`, see `wasm/mix-fetch/go-mix-conn/internal/mixfetch/mixfetch.go:214`). Keep-alive is disabled, so each request opens a fresh TCP connection through the mixnet; extra concurrent requests to the same host queue until a slot frees. Different hosts are independent. + + +## Playground and examples + +See the [interactive playground](/developers/typescript/playground/mixfetch) for a working `mixFetch` example you can run in the browser. + +The first call bootstraps the WASM Nym client (gateway handshake, key generation, cover traffic). Subsequent calls reuse the active client; the Rust side holds it in a `OnceLock` singleton, so there is one client per page (or per Node process). + +## When to use mix-fetch + +| | mix-fetch | WASM Mixnet Client | smolmix | Plain fetch (no mixnet) | +|---|---|---|---|---| +| **Runtime** | Browser, Node.js | Browser | Native (Rust) | Anywhere | +| **Architecture** | Proxy (Network Requester → destination) | E2E (both sides Nym) | Proxy | Direct | +| **API shape** | `fetch()` replacement | Send/recv text or binary messages | `TcpStream` / `UdpSocket` | `fetch()` | +| **HTTP support** | Yes | No | Yes (via `hyper` over `TcpStream`) | Yes | +| **Sender unlinkability** | Strong (mixnet) | Strong (mixnet) | Strong (mixnet) | None | +| **Concurrency** | 10 per host | Unlimited | Unlimited | Unlimited | + +## Security model + + +Use HTTPS targets. Plaintext HTTP requests are visible to the Network Requester and to any router between it and the destination. + + +### What's protected + +| Segment | Mixnet encryption | What's visible | +|---|---|---| +| App → entry gateway | Sphinx (layered) over a WebSocket | Entry gateway sees your IP, not the destination | +| Inside the mixnet | Sphinx (layered) | Each node only knows previous / next hop | +| Network Requester | Sphinx removed; SOCKS5 connect request decoded | The Requester sees destination hostname + port; payload is application-layer TLS | +| Network Requester → destination | None (regular TCP) | TLS handshake + ciphertext (with HTTPS targets); cleartext (with HTTP targets) | + +### Why mix-fetch ships its own CA store + +The browser's TLS stack and CA store aren't accessible from JavaScript or from a WASM SOCKS client; on Node, the TLS stack lives outside the Web Worker that hosts the mixnet client. `mix-fetch` therefore performs TLS itself, inside the WASM bundle, against the destination server. The bundle ships with an embedded Mozilla root CA list (refreshed from [curl.se's bundle](https://curl.se/docs/caextract.html), verified by SHA-256 in `wasm/mix-fetch/go-mix-conn/scripts/update-root-certs.sh`) and an in-WASM TLS implementation (Go's `crypto/tls`, configured at `wasm/mix-fetch/go-mix-conn/internal/sslhelpers/ssl_helper.go`). The mixnet path sees encrypted TLS ciphertext, not plaintext. + +`mix-fetch` handles the TLS layer for you: HTTPS targets are protected end-to-end between the WASM bundle and the destination, as if a browser had initiated the TLS handshake directly. Plaintext HTTP targets remain visible to the Network Requester and to any router beyond it. See [Exit Gateway Services](/network/infrastructure/exit-services) for what the exit can and cannot observe. + +## API reference + +Generated reference: [typedoc output](/developers/typescript/api/mix-fetch). diff --git a/documentation/docs/pages/developers/native.mdx b/documentation/docs/pages/developers/native.mdx deleted file mode 100644 index 499c65fb95..0000000000 --- a/documentation/docs/pages/developers/native.mdx +++ /dev/null @@ -1,63 +0,0 @@ ---- -title: "Native and Desktop App Integration" -description: "Integrate privacy into native desktop apps and CLIs using the Nym Rust SDK. Choose between end-to-end mixnet messaging or TCP proxy approaches." -schemaType: "TechArticle" -section: "Developers" -lastUpdated: "2026-03-15" ---- - -import { Callout } from 'nextra/components'; - -# Native / Desktop Apps - -Desktop apps and CLIs integrate via the [Rust SDK](./rust), with two broad approaches: embedding Nym clients on both sides of the communication (E2E), or using the Mixnet as a proxy to reach external services. - -## Option 1: Mixnet End-To-End -Both sides of your app run Nym clients. All traffic stays Sphinx-encrypted the entire way. Works for peer-to-peer setups or any case where you control both ends. - -![](/images/developers/nym-arch-client-to-client.png) - -### Stream Module -The [Stream module](./rust/stream) provides `AsyncRead + AsyncWrite` byte streams multiplexed over the mixnet, the closest analogue to TCP sockets. - -- [docs](./rust/stream) -- [tutorial](./rust/stream/tutorial) - -### Mixnet & Client Pool Modules -The [Mixnet module](./rust/mixnet) exposes the raw message API and `MixnetClient`. The [Client Pool](./rust/client-pool) maintains pre-connected clients for bursty workloads. These are appropriate when you need full control over the communication model. - -- [docs](./rust/mixnet) -- [tutorial](./rust/mixnet/tutorial) - -### TcpProxy Module (Unmaintained) - - -**This module is unmaintained.** Use the [Stream module](./rust/stream) for new projects. Existing users should plan to migrate when possible. - - -Exposes localhost TCP sockets that proxy traffic through the mixnet. - -- [docs](./rust/tcpproxy) - -## Option 2: Mixnet-As-Proxy -For cases where you only control the client side and need to reach a third-party service such as a blockchain RPC or remote API. - -![](/images/developers/nym-arch-ip-routing.png) - - - -### Security Considerations - -Traffic is Sphinx-encrypted until the Exit Gateway, where it's unwrapped into HTTPS (Network Requester) or raw IP (IP Packet Router). The last hop to the remote host **travels as normal internet traffic**. - -Weaker than E2E against a global passive adversary, but you still get timing obfuscation and sender-receiver unlinkability between your client and the remote service. - - -### SOCKS Client -Applications that support SOCKS4, 4a, or 5 can use the Socks Client exposed by the Mixnet module. Traffic is routed through the Exit Gateway's Network Requester, which uses SURBs to reply to the sender anonymously. - -- [docs](./rust/mixnet) - - -Development is in progress to allow for this proxy method from native Rust, C, and Go without requiring a separate SOCKS client. Stay tuned. - diff --git a/documentation/docs/pages/developers/nymvpncli.mdx b/documentation/docs/pages/developers/nymvpncli.mdx index 7c15cfe88d..318d103a2b 100644 --- a/documentation/docs/pages/developers/nymvpncli.mdx +++ b/documentation/docs/pages/developers/nymvpncli.mdx @@ -74,7 +74,7 @@ yay -S gcc make protobuf base-devel clang apt install gcc make protobuf-compiler pkconfig libdbus-1-dev build-essential clang ``` - + Older Debian/Ubuntu versions need to manually install `protobuf-compiler` >= v3.21.12 @@ -169,7 +169,7 @@ nym-vpnc device get You can fund your VPN usage directly from your own wallet instead of going through the NymVPN account system. You deposit `$NYM` into the ticketbook smart contract and receive zk-nym ticketbooks that authenticate you on the network. - + If you already have an account stored in `nym-vpnc`, you must remove it first with `nym-vpnc account forget` before setting a new mnemonic. diff --git a/documentation/docs/pages/developers/rust.mdx b/documentation/docs/pages/developers/rust.mdx index 93affb53ae..04229f6175 100644 --- a/documentation/docs/pages/developers/rust.mdx +++ b/documentation/docs/pages/developers/rust.mdx @@ -1,34 +1,53 @@ --- -title: "Nym Rust SDK: Privacy Apps for the Mixnet" -description: "Rust SDK reference for building privacy applications on the Nym mixnet. Covers the Mixnet client, Stream multiplexing, Client Pool, and code examples." +title: "nym-sdk: Rust SDK for the Nym Mixnet" +description: "Rust SDK reference for building privacy applications on the Nym mixnet. Covers the Mixnet client, Stream multiplexing, Client Pool, FFI bindings, and code examples." schemaType: "TechArticle" section: "Developers" -lastUpdated: "2026-03-13" +lastUpdated: "2026-05-12" --- -# Rust SDK +# nym-sdk import { Callout } from 'nextra/components' -import { CratesPaused } from '../../components/crates-paused' -All modules share a common `MixnetClient` that manages gateway connections, Sphinx packet encryption, routing, and cover traffic. +`nym-sdk` is the Rust SDK for the Nym mixnet. All modules share a common `MixnetClient` that manages gateway connections, Sphinx encryption, and cover traffic. + +```text +┌──────────────────────────────────────────────────────────────┐ +│ Your Rust app (alice) │ +│ └─ MixnetClient (Sphinx layering, cover traffic) │ +│ └─ WebSocket to entry gateway │ +│ └─ Nym mixnet (entry → 3 mix layers → exit) │ +│ └─ MixnetClient (bob) │ +│ └─ Your Rust app (bob) │ +└──────────────────────────────────────────────────────────────┘ +``` + +Both sides run a `MixnetClient`. Sphinx encryption protects every hop end-to-end; neither gateway nor any mix node can link sender to receiver. Full API reference: [**docs.rs/nym-sdk**](https://docs.rs/nym-sdk/latest/nym_sdk/) - - For an overview of what the SDK can do, see the **[Tour](./rust/tour)**. For setup instructions, see [Installation](./rust/importing). ## Modules -- **[Stream](./rust/stream)**: multiplexed `AsyncRead + AsyncWrite` byte streams over the Mixnet. **If you're used to TCP sockets, start here.** +| Module | What it does | Status | +|---|---|---| +| [**Stream**](./rust/stream) | Multiplexed `AsyncRead + AsyncWrite` byte streams over the Mixnet, the closest analogue to TCP sockets. | Recommended | +| [**Mixnet**](./rust/mixnet) | Raw message payloads, independently routed, no connections or ordering. Full control over the communication model. | Stable | +| [**Client Pool**](./rust/client-pool) | Keeps ready-to-use `MixnetClient` instances warm for bursty workloads. | Stable | +| [**TcpProxy**](./rust/tcpproxy) | TCP socket proxying with session management and message ordering. | Deprecated | +| [**FFI**](./rust/ffi) | Go and C/C++ bindings. | Stable | -- **[Mixnet](./rust/mixnet)**: raw message payloads, independently routed, no connections or ordering. Use this when you want full control over the communication model. + +**TcpProxy is deprecated.** Use the [Stream module](./rust/stream) for new projects. + -- **[Client Pool](./rust/client-pool)**: keeps ready-to-use `MixnetClient` instances warm for bursty workloads. +## Proxy-mode crates -- **[TcpProxy](./rust/tcpproxy)** *(deprecated)*: TCP socket proxying with session management and message ordering. Use Stream for new projects. +For proxy-mode integrations (reaching third-party services through an Exit Gateway), see also: -- **[FFI](./rust/ffi)**: Go and C/C++ bindings. +- [**`smolmix`**](/developers/smolmix): `TcpStream` and `UdpSocket` over the Mixnet via a userspace IP stack. Compatible with `tokio-rustls`, `hyper`, `tokio-tungstenite`, and the rest of the async Rust ecosystem. +- [**SOCKS Client**](./rust/mixnet): SOCKS4/4a/5 proxy via the Exit Gateway's Network Requester. Works with any SOCKS-capable application without code changes. diff --git a/documentation/docs/pages/developers/rust/client-pool.mdx b/documentation/docs/pages/developers/rust/client-pool.mdx index 311bd147be..a6644584b8 100644 --- a/documentation/docs/pages/developers/rust/client-pool.mdx +++ b/documentation/docs/pages/developers/rust/client-pool.mdx @@ -10,7 +10,7 @@ lastUpdated: "2026-03-15" import { Callout } from 'nextra/components' -The `ClientPool` maintains a configurable number of connected ephemeral `MixnetClient` instances, ready for immediate use. This eliminates the connection latency that comes with creating a new client on each request: the gateway handshake, key generation, and topology fetch all happen ahead of time. +The `ClientPool` keeps a configurable number of `MixnetClient` instances pre-connected in a background loop, so callers don't pay the gateway handshake, key generation, and topology fetch cost on the hot path. ## How it works @@ -26,11 +26,11 @@ flowchart LR BG -->|"pool < reserve? create another"| P ``` -1. **Create** the pool with a target reserve size: `ClientPool::new(5)` -2. **Start** the background loop: `pool.start()`. It immediately begins connecting clients -3. **Pop** a client when needed: `pool.get_mixnet_client()` returns `Some(client)` or `None` if the pool is empty -4. **Use** the client normally: send messages, open streams, etc. -5. **Disconnect** the client when done. The background loop notices the pool is below reserve and creates a replacement +1. Create the pool with a target reserve size: `ClientPool::new(5)`. +2. Start the background loop: `pool.start()`. It immediately begins connecting clients. +3. Pop a client when needed: `pool.get_mixnet_client()` returns `Some(client)` or `None` if the pool is empty. +4. Use the client normally: send messages, open streams. +5. Disconnect the client when done. The background loop notices the pool is below reserve and creates a replacement. Clients are **consumed, not returned**. The pool creates new ones to maintain the reserve. If the pool is empty, you can fall back to `MixnetClient::connect_new()` (slower, but keeps things working). @@ -45,7 +45,7 @@ use nym_sdk::client_pool::ClientPool; use nym_network_defaults::setup_env; #[tokio::main] -async fn main() -> anyhow::Result<()> { +async fn main() -> Result<(), Box> { nym_bin_common::logging::setup_tracing_logger(); // Load mainnet network defaults into env vars (required by ClientPool) setup_env(None::); diff --git a/documentation/docs/pages/developers/rust/client-pool/tutorial.mdx b/documentation/docs/pages/developers/rust/client-pool/tutorial.mdx index 525636c226..727a07928d 100644 --- a/documentation/docs/pages/developers/rust/client-pool/tutorial.mdx +++ b/documentation/docs/pages/developers/rust/client-pool/tutorial.mdx @@ -3,15 +3,16 @@ title: "Client Pool Tutorial: Handle Bursty Traffic" description: "Step-by-step Rust tutorial to use Nym ClientPool for handling bursts of concurrent mixnet operations without blocking on client creation." schemaType: "HowTo" section: "Developers" -lastUpdated: "2026-03-26" +lastUpdated: "2026-04-17" --- # Tutorial: Handle Bursty Traffic with Client Pool import { Callout } from 'nextra/components' import { CodeVerified } from '../../../../components/code-verified' +import { RUST_MSRV } from '../../../../components/versions' -In this tutorial you'll build a program that uses `ClientPool` to handle bursts of concurrent Mixnet operations without blocking on client creation. You'll see how the pool pre-creates clients in the background, how to pop them under load, and what happens when demand exceeds supply. +A program that uses `ClientPool` to absorb bursts of concurrent Mixnet operations without paying client-creation latency on the hot path. The pool pre-creates clients in the background; tasks pop them under load; the tutorial also walks through what happens when demand outruns supply. ## What you'll learn @@ -25,7 +26,7 @@ In this tutorial you'll build a program that uses `ClientPool` to handle bursts ## Prerequisites -- Rust toolchain (1.70+) +- Rust toolchain ({RUST_MSRV}+) - A working internet connection ## Step 1: Set up the project @@ -39,11 +40,10 @@ Add dependencies to `Cargo.toml`: ```toml [dependencies] -nym-sdk = { git = "https://github.com/nymtech/nym", rev = "97068b2" } -nym-network-defaults = { git = "https://github.com/nymtech/nym", rev = "97068b2" } -nym-bin-common = { git = "https://github.com/nymtech/nym", rev = "97068b2", features = ["basic_tracing"] } +nym-sdk = "1.21.0" +nym-network-defaults = "1.21.0" +nym-bin-common = { version = "1.21.0", features = ["basic_tracing"] } tokio = { version = "1", features = ["full"] } -blake3 = "=1.7.0" # required pin — see https://nymtech.net/docs/developers/rust/importing ``` ## Step 2: Create and start the pool @@ -75,7 +75,7 @@ async fn main() { pool_bg.start().await.unwrap(); }); - println!("Pool started — waiting for clients to connect..."); + println!("Pool started, waiting for clients to connect..."); tokio::time::sleep(Duration::from_secs(15)).await; // Check how many are ready @@ -106,14 +106,14 @@ When you call `get_mixnet_client()`, the pool removes a client and returns it. T c } None => { - // Pool is empty — fall back to creating one on the fly. + // Pool is empty; fall back to creating one on the fly. // This is slower but keeps things working. println!("Task {i}: pool empty, creating client on the fly..."); nym_sdk::mixnet::MixnetClient::connect_new().await.unwrap() } }; - // Do something with the client — here, send a message to ourselves + // Do something with the client. Here, send a message to ourselves. let addr = *client.nym_address(); client .send_plain_message(addr, format!("hello from task {i}")) @@ -132,7 +132,7 @@ When you call `get_mixnet_client()`, the pool removes a client and returns it. T } } - // Disconnect when done — the pool will create a replacement + // Disconnect when done; the pool will create a replacement. client.disconnect().await; println!("Task {i}: done"); }); @@ -177,7 +177,7 @@ RUST_LOG=info cargo run You'll see output like: ``` -Pool started — waiting for clients to connect... +Pool started, waiting for clients to connect... Pool has 3 clients ready Task 1: got client 8gk4Y...@2xU4d... from pool Task 2: got client F3qR7...@9nK2m... from pool @@ -231,20 +231,33 @@ async fn main() { setup_env(None::); let pool = ClientPool::new(3); - let pool_bg = pool.clone(); - tokio::spawn(async move { pool_bg.start().await.unwrap() }); - println!("Waiting for pool to fill..."); + let pool_bg = pool.clone(); + tokio::spawn(async move { + pool_bg.start().await.unwrap(); + }); + + println!("Pool started, waiting for clients to connect..."); tokio::time::sleep(Duration::from_secs(15)).await; - println!("Pool has {} clients", pool.get_client_count().await); + + let count = pool.get_client_count().await; + println!("Pool has {count} clients ready"); let mut handles = vec![]; + for i in 1..=3 { let pool = pool.clone(); - handles.push(tokio::spawn(async move { + + let handle = tokio::spawn(async move { let mut client = match pool.get_mixnet_client().await { - Some(c) => c, - None => nym_sdk::mixnet::MixnetClient::connect_new().await.unwrap(), + Some(c) => { + println!("Task {i}: got client {} from pool", c.nym_address()); + c + } + None => { + println!("Task {i}: pool empty, creating client on the fly..."); + nym_sdk::mixnet::MixnetClient::connect_new().await.unwrap() + } }; let addr = *client.nym_address(); @@ -254,24 +267,34 @@ async fn main() { .unwrap(); if let Some(msgs) = client.wait_for_messages().await { - for msg in msgs.iter().filter(|m| !m.message.is_empty()) { - println!("Task {i}: {}", String::from_utf8_lossy(&msg.message)); + for msg in msgs { + if !msg.message.is_empty() { + println!( + "Task {i}: received {:?}", + String::from_utf8_lossy(&msg.message) + ); + } } } client.disconnect().await; - })); + println!("Task {i}: done"); + }); + + handles.push(handle); } for h in handles { h.await.unwrap(); } - println!("Waiting for replenishment..."); + println!("\nWaiting for pool to replenish..."); tokio::time::sleep(Duration::from_secs(15)).await; - println!("Pool has {} clients", pool.get_client_count().await); + + let count = pool.get_client_count().await; + println!("Pool has {count} clients ready again"); pool.disconnect_pool().await; - println!("Done"); + println!("Pool shut down"); } ``` diff --git a/documentation/docs/pages/developers/rust/ffi.mdx b/documentation/docs/pages/developers/rust/ffi.mdx index e32071eaa2..6bc44b2ccf 100644 --- a/documentation/docs/pages/developers/rust/ffi.mdx +++ b/documentation/docs/pages/developers/rust/ffi.mdx @@ -19,7 +19,7 @@ ffi └── shared # Shared Rust implementation ``` -Core logic lives in `shared/` and is imported into language-specific wrappers. The shared layer handles thread safety and ensures client operations run on blocking threads on the Rust side of the FFI boundary. +Core logic lives in `shared/` and is imported into language-specific wrappers. The shared layer handles thread safety and runs client operations on blocking threads on the Rust side of the FFI boundary. ## What's exposed @@ -36,7 +36,7 @@ The TcpProxy module is deprecated. For new projects, use the [Stream module](./s ## Quick example (Go) ```go -// Initialize an ephemeral client +// Initialise an ephemeral client bindings.InitEphemeral() // Get our Nym address diff --git a/documentation/docs/pages/developers/rust/importing.mdx b/documentation/docs/pages/developers/rust/importing.mdx index dd9c95c976..3cc34ab082 100644 --- a/documentation/docs/pages/developers/rust/importing.mdx +++ b/documentation/docs/pages/developers/rust/importing.mdx @@ -3,27 +3,24 @@ title: "Install the Nym Rust SDK" description: "Add nym-sdk to your Rust project from Git or crates.io. Covers version requirements, minimum Rust version, and current feature gate status." schemaType: "TechArticle" section: "Developers" -lastUpdated: "2026-03-27" +lastUpdated: "2026-04-17" --- # Installation import { Callout } from 'nextra/components'; -import { CratesPaused } from '../../../components/crates-paused' - - +import { RUST_MSRV } from '../../../components/versions' ```toml [dependencies] -nym-sdk = { git = "https://github.com/nymtech/nym", rev = "97068b2" } -blake3 = "=1.7.0" # pin to avoid a transitive dependency conflict — see note below +nym-sdk = "1.21.0" ``` - -**Temporary pin required.** You must pin `blake3 = "=1.7.0"` in your `Cargo.toml` to avoid a build failure caused by a transitive `digest` version conflict. This will be resolved in a future SDK release. - +**Minimum Rust version:** {RUST_MSRV}+ -You can also track a branch if you want the latest changes: +### From Git + +You can also import directly from Git if you want unreleased changes: ```toml # development branch (latest changes, may be unstable) @@ -33,19 +30,6 @@ nym-sdk = { git = "https://github.com/nymtech/nym", branch = "develop" } nym-sdk = { git = "https://github.com/nymtech/nym", branch = "master" } ``` -**Minimum Rust version:** 1.70+ - -### crates.io (older API only) - -If you don't need the Stream module or other recent additions, you can still use the published crate: - -```toml -[dependencies] -nym-sdk = "1.20.4" -``` - -This version includes the Mixnet message API, Client Pool, and TcpProxy modules. - **No feature gates yet.** Importing `nym-sdk` pulls in everything (mixnet, tcp_proxy, client_pool, etc.) and their full dependency trees. Cargo feature flags are planned. diff --git a/documentation/docs/pages/developers/rust/mixnet.mdx b/documentation/docs/pages/developers/rust/mixnet.mdx index 812a428ca6..8614a6a839 100644 --- a/documentation/docs/pages/developers/rust/mixnet.mdx +++ b/documentation/docs/pages/developers/rust/mixnet.mdx @@ -10,7 +10,7 @@ lastUpdated: "2026-03-13" import { Callout } from 'nextra/components'; -The `mixnet` module is the core of the Nym SDK. It provides [`MixnetClient`](https://docs.rs/nym-sdk/latest/nym_sdk/mixnet/struct.MixnetClient.html) for connecting to the Nym Mixnet, sending messages through Sphinx packet encryption and 5-hop routing, and receiving reconstructed messages on the other side. +The `mixnet` module provides [`MixnetClient`](https://docs.rs/nym-sdk/latest/nym_sdk/mixnet/struct.MixnetClient.html) for connecting to the Nym Mixnet, sending messages through Sphinx packet encryption and 5-hop routing, and receiving reconstructed messages on the other side. Messages are individually routed through the Mixnet with no guaranteed ordering or persistent connections. If you want familiar socket-like I/O (`read`/`write`), use the [Stream module](./stream) instead. See the [Tour](./tour) for how the two approaches compare. diff --git a/documentation/docs/pages/developers/rust/mixnet/examples.mdx b/documentation/docs/pages/developers/rust/mixnet/examples.mdx index 66282a9413..cc46a51090 100644 --- a/documentation/docs/pages/developers/rust/mixnet/examples.mdx +++ b/documentation/docs/pages/developers/rust/mixnet/examples.mdx @@ -23,7 +23,7 @@ cargo run --example | Parallel Send/Receive | [`parallel_sending_and_receiving.rs`](https://github.com/nymtech/nym/blob/develop/sdk/rust/nym-sdk/examples/parallel_sending_and_receiving.rs) | Using `split_sender()` for concurrent tasks | | Sandbox Testnet | [`sandbox.rs`](https://github.com/nymtech/nym/blob/develop/sdk/rust/nym-sdk/examples/sandbox.rs) | Connecting to the Sandbox testnet instead of mainnet | | Bandwidth Credential | [`bandwidth.rs`](https://github.com/nymtech/nym/blob/develop/sdk/rust/nym-sdk/examples/bandwidth.rs) | Acquiring a bandwidth credential for paid mixnet access | -| Custom Topology | [`custom_topology_provider.rs`](https://github.com/nymtech/nym/blob/develop/sdk/rust/nym-sdk/examples/custom_topology_provider.rs) | Implementing the `TopologyProvider` trait to filter or customize node selection | +| Custom Topology | [`custom_topology_provider.rs`](https://github.com/nymtech/nym/blob/develop/sdk/rust/nym-sdk/examples/custom_topology_provider.rs) | Implementing the `TopologyProvider` trait to filter or customise node selection | | Overwrite Topology | [`manually_overwrite_topology.rs`](https://github.com/nymtech/nym/blob/develop/sdk/rust/nym-sdk/examples/manually_overwrite_topology.rs) | Manually constructing a topology with hardcoded nodes | | Control Requests | [`control_requests.rs`](https://github.com/nymtech/nym/blob/develop/sdk/rust/nym-sdk/examples/control_requests.rs) | Sending service provider control requests (health, version, binary info) | | Custom Storage | [`manually_handle_storage.rs`](https://github.com/nymtech/nym/blob/develop/sdk/rust/nym-sdk/examples/manually_handle_storage.rs) | Implementing custom storage backends for keys, gateways, and credentials | diff --git a/documentation/docs/pages/developers/rust/mixnet/tutorial.mdx b/documentation/docs/pages/developers/rust/mixnet/tutorial.mdx index 0453266998..4221cb36f7 100644 --- a/documentation/docs/pages/developers/rust/mixnet/tutorial.mdx +++ b/documentation/docs/pages/developers/rust/mixnet/tutorial.mdx @@ -3,17 +3,18 @@ title: "Mixnet Tutorial: Send Your First Private Message" description: "Step-by-step Rust tutorial to connect to the Nym mixnet, send and receive messages, reply anonymously with SURBs, and persist client identity." schemaType: "HowTo" section: "Developers" -lastUpdated: "2026-03-26" +lastUpdated: "2026-04-17" --- # Tutorial: Send Your First Private Message import { Callout } from 'nextra/components' import { CodeVerified } from '../../../../components/code-verified' +import { RUST_MSRV } from '../../../../components/versions' -By the end of this tutorial you'll have a working program that sends a Sphinx-encrypted message to itself through the Nym Mixnet, receives it, and replies anonymously using SURBs. The later sections cover persistent identity and concurrent send/receive. +A program that sends a Sphinx-encrypted message to itself through the Nym Mixnet, receives it, and replies anonymously using SURBs. Later sections cover persistent identity and concurrent send/receive. -**You'll need:** Rust 1.70+ and an internet connection (clients connect to the live Mixnet). +Requires Rust {RUST_MSRV}+ and an internet connection (clients connect to the live Mixnet). @@ -28,10 +29,9 @@ Add dependencies to `Cargo.toml`: ```toml [dependencies] -nym-sdk = { git = "https://github.com/nymtech/nym", rev = "97068b2" } -nym-bin-common = { git = "https://github.com/nymtech/nym", rev = "97068b2", features = ["basic_tracing"] } +nym-sdk = "1.21.0" +nym-bin-common = { version = "1.21.0", features = ["basic_tracing"] } tokio = { version = "1", features = ["full"] } -blake3 = "=1.7.0" # required pin — see https://nymtech.net/docs/developers/rust/importing ``` ## Step 2: Connect and send @@ -45,21 +45,21 @@ use nym_sdk::mixnet::{self, MixnetMessageSender}; async fn main() { nym_bin_common::logging::setup_tracing_logger(); - // connect_new() creates an ephemeral client — keys are generated in + // connect_new() creates an ephemeral client; keys are generated in // memory and discarded on disconnect. let mut client = mixnet::MixnetClient::connect_new().await.unwrap(); let our_address = client.nym_address(); println!("Connected: {our_address}"); // The message is Sphinx-encrypted and mixed across 5 nodes. - // send_plain_message only blocks until the message is queued — + // send_plain_message only blocks until the message is queued; // encryption and mixing happen in background tasks. client .send_plain_message(*our_address, "hello from the mixnet!") .await .unwrap(); - println!("Sent — waiting for arrival..."); + println!("Sent, waiting for arrival..."); ``` @@ -70,7 +70,7 @@ async fn main() { ```rust // wait_for_messages() returns the next batch of incoming messages. - // Filter empty messages — these are SURB replenishment requests. + // Filter empty messages: these are SURB replenishment requests. let message = loop { if let Some(msgs) = client.wait_for_messages().await { if let Some(msg) = msgs.into_iter().find(|m| !m.message.is_empty()) { @@ -89,7 +89,7 @@ Every message includes a `sender_tag`, an opaque `AnonymousSenderTag` that lets ```rust let sender_tag = message.sender_tag.expect("should have sender tag"); - // send_reply uses the SURB — the sender's address is never revealed. + // send_reply uses the SURB; the sender's address is never revealed. client.send_reply(sender_tag, "hello back, anonymously!").await.unwrap(); let reply = loop { @@ -114,7 +114,7 @@ RUST_LOG=info cargo run ``` Connected: 8gk4Y...@2xU4d... -Sent — waiting for arrival... +Sent, waiting for arrival... Received: hello from the mixnet! Reply: hello back, anonymously! ``` @@ -140,21 +140,38 @@ async fn main() { .await .unwrap(); - println!("Address: {}", client.nym_address()); + let our_address = client.nym_address(); + println!("Address: {our_address}"); - // Same API as before — send, receive, reply. + // Same API as before: send, receive, SURB reply. client - .send_plain_message(*client.nym_address(), "persistent identity!") + .send_plain_message(*our_address, "hello from persistent client!") .await .unwrap(); + println!("Sent, waiting for arrival..."); - if let Some(msgs) = client.wait_for_messages().await { - for m in msgs.into_iter().filter(|m| !m.message.is_empty()) { - println!("Received: {}", String::from_utf8_lossy(&m.message)); + let message = loop { + if let Some(msgs) = client.wait_for_messages().await { + if let Some(msg) = msgs.into_iter().find(|m| !m.message.is_empty()) { + break msg; + } } - } + }; + println!("Received: {}", String::from_utf8_lossy(&message.message)); - // Always disconnect for clean shutdown — background tasks need to be + let sender_tag = message.sender_tag.expect("should have sender tag"); + client.send_reply(sender_tag, "anonymous reply!").await.unwrap(); + + let reply = loop { + if let Some(msgs) = client.wait_for_messages().await { + if let Some(msg) = msgs.into_iter().find(|m| !m.message.is_empty()) { + break msg; + } + } + }; + println!("Reply: {}", String::from_utf8_lossy(&reply.message)); + + // Always disconnect for clean shutdown; background tasks need to be // stopped and state files flushed. client.disconnect().await; } @@ -184,7 +201,7 @@ async fn main() { // split_sender() returns a clone-able MixnetClientSender. let sender = client.split_sender(); - // Spawn a receiver — the original client implements futures::Stream. + // Spawn a receiver: the original client implements futures::Stream. let rx = tokio::spawn(async move { if let Some(msg) = client.next().await { println!("Received: {}", String::from_utf8_lossy(&msg.message)); @@ -231,7 +248,7 @@ async fn main() { .send_plain_message(*our_address, "hello from the mixnet!") .await .unwrap(); - println!("Sent — waiting for arrival..."); + println!("Sent, waiting for arrival..."); let message = loop { if let Some(msgs) = client.wait_for_messages().await { @@ -270,6 +287,7 @@ async fn main() { nym_bin_common::logging::setup_tracing_logger(); let paths = StoragePaths::new_from_dir("./my-client-data").unwrap(); + let mut client = mixnet::MixnetClientBuilder::new_with_default_storage(paths) .await .unwrap() @@ -280,13 +298,13 @@ async fn main() { .unwrap(); let our_address = client.nym_address(); - println!("Connected: {our_address}"); + println!("Address: {our_address}"); client - .send_plain_message(*our_address, "hello from the mixnet!") + .send_plain_message(*our_address, "hello from persistent client!") .await .unwrap(); - println!("Sent — waiting for arrival..."); + println!("Sent, waiting for arrival..."); let message = loop { if let Some(msgs) = client.wait_for_messages().await { @@ -298,7 +316,7 @@ async fn main() { println!("Received: {}", String::from_utf8_lossy(&message.message)); let sender_tag = message.sender_tag.expect("should have sender tag"); - client.send_reply(sender_tag, "hello back, anonymously!").await.unwrap(); + client.send_reply(sender_tag, "anonymous reply!").await.unwrap(); let reply = loop { if let Some(msgs) = client.wait_for_messages().await { diff --git a/documentation/docs/pages/developers/rust/stream.mdx b/documentation/docs/pages/developers/rust/stream.mdx index 38d8d7e10a..1c3057c51e 100644 --- a/documentation/docs/pages/developers/rust/stream.mdx +++ b/documentation/docs/pages/developers/rust/stream.mdx @@ -9,13 +9,10 @@ lastUpdated: "2026-03-15" # Stream Module import { Callout } from 'nextra/components' -import { CratesPaused } from '../../../components/crates-paused' - - The Mixnet is fundamentally message-based: no persistent connections, no guaranteed ordering, no TCP. The default [message API](./mixnet) works at this level, sending individual payloads independently through Mix Nodes. This is effective for privacy but unlike how most networking code is structured. -The **Stream module** bridges the gap by providing persistent, bidirectional byte channels that behave like TCP sockets. Each `MixnetStream` implements [`AsyncRead`](https://docs.rs/tokio/latest/tokio/io/trait.AsyncRead.html) and [`AsyncWrite`](https://docs.rs/tokio/latest/tokio/io/trait.AsyncWrite.html), so `tokio::io::copy`, codecs, `BufReader`/`BufWriter`, and any other async I/O consumer work without modification. **If you're coming from socket-based networking, start here.** +The Stream module bridges the gap by providing persistent, bidirectional byte channels that behave like TCP sockets. Each `MixnetStream` implements [`AsyncRead`](https://docs.rs/tokio/latest/tokio/io/trait.AsyncRead.html) and [`AsyncWrite`](https://docs.rs/tokio/latest/tokio/io/trait.AsyncWrite.html), so `tokio::io::copy`, codecs, `BufReader`/`BufWriter`, and any other async I/O consumer work without modification. All streams are multiplexed over a single `MixnetClient`. A background router task reads a small header on each incoming message and dispatches the payload to the correct stream by ID, so multiple concurrent streams require no additional connections or gateways. @@ -23,10 +20,10 @@ All streams are multiplexed over a single `MixnetClient`. A background router ta The two sides of a stream connection follow a client/server pattern: -1. **Opener** calls `client.open_stream(recipient, surbs)`. This generates a random `StreamId`, registers the stream locally, and sends an `Open` message through the Mixnet. -2. **Listener** calls `listener.accept()`, which blocks until an `Open` arrives, registers the new stream, and returns a `MixnetStream` ready for reading and writing. +1. The opener calls `client.open_stream(recipient, surbs)`. This generates a random `StreamId`, registers the stream locally, and sends an `Open` message through the Mixnet. +2. The listener calls `listener.accept()`, which blocks until an `Open` arrives, registers the new stream, and returns a `MixnetStream` ready for reading and writing. 3. Both sides read and write using standard `AsyncRead`/`AsyncWrite`. Bytes are wrapped in a 16-byte LP frame header (stream ID, message type, sequence number), routed through the Mixnet, and demultiplexed on arrival. -4. **Cleanup** happens on `drop`. The stream deregisters from the local router. No close message is sent over the wire, since a close could race ahead of in-flight data. +4. On drop, the stream deregisters from the local router. No close message is sent over the wire, since a close could race ahead of in-flight data. ```text ┌─────────────────────────────────────────────────────────┐ @@ -94,7 +91,7 @@ async fn main() { .expect("timed out") .expect("listener closed"); - // Send data and read it back — just like a TCP socket + // Send data and read it back, just like a TCP socket outbound.write_all(b"hello from sender").await.unwrap(); outbound.flush().await.unwrap(); @@ -139,7 +136,7 @@ The receiver replies via **reply SURBs** (Single Use Reply Blocks) and never lea | **Status** | Stable | New | Deprecated | -**Streams and messages are mutually exclusive.** Once you call `open_stream()` or `listener()`, the message-based API (`send_plain_message`, `wait_for_messages`) is permanently disabled on that client. This is a one-way transition: there is no switching back without disconnecting and reconnecting. See the [`stream_mode_guard.rs` example](https://github.com/nymtech/nym/blob/develop/sdk/rust/nym-sdk/examples/stream_mode_guard.rs) for details. +Streams and messages are mutually exclusive. Once you call `open_stream()` or `listener()`, the message-based API (`send_plain_message`, `wait_for_messages`) is permanently disabled on that client. This is a one-way transition: no switching back without disconnecting and reconnecting. See the [`stream_mode_guard.rs` example](https://github.com/nymtech/nym/blob/develop/sdk/rust/nym-sdk/examples/stream_mode_guard.rs) for details. ## Next steps diff --git a/documentation/docs/pages/developers/rust/stream/architecture.mdx b/documentation/docs/pages/developers/rust/stream/architecture.mdx index 205eee370e..63541aaf15 100644 --- a/documentation/docs/pages/developers/rust/stream/architecture.mdx +++ b/documentation/docs/pages/developers/rust/stream/architecture.mdx @@ -80,7 +80,7 @@ There is no switching back without disconnecting and creating a new client. ## Known limitations -**Sequence-based reordering.** The Mixnet does not guarantee message ordering at the transport level, but each stream write includes a `sequence_num` in the LP frame header. The receiver maintains a per-stream reorder buffer (BTreeMap keyed by sequence number) that buffers out-of-order messages and drains them in sequence. This means protocols that depend on byte ordering (HTTP, TLS, protobuf) work correctly over streams. +The Mixnet does not guarantee message ordering at the transport level, but each stream write includes a `sequence_num` in the LP frame header. The receiver maintains a per-stream reorder buffer (BTreeMap keyed by sequence number) that buffers out-of-order messages and drains them in sequence, so protocols that depend on byte ordering (HTTP, TLS, protobuf) work correctly over streams. - **Buffer cap:** 256 messages per stream. If the buffer fills (e.g. a large gap in sequence numbers), the receiver skips ahead to the lowest buffered sequence. - **Duplicates:** messages with a sequence number below the next expected are dropped. @@ -89,4 +89,4 @@ There is no switching back without disconnecting and creating a new client. ## Internal details -For the full implementation details (router task, `StreamMap`, `PollSender` usage, base-client type rationale), see the `ARCHITECTURE.md` file next to the module source code. This will also be available on [docs.rs](https://docs.rs/nym-sdk/latest/nym_sdk/) once crate publication resumes with the Lewes Protocol. +For the full implementation details (router task, `StreamMap`, `PollSender` usage, base-client type rationale), see the `ARCHITECTURE.md` file next to the module source code, or the [docs.rs](https://docs.rs/nym-sdk/latest/nym_sdk/) API reference. diff --git a/documentation/docs/pages/developers/rust/stream/tutorial.mdx b/documentation/docs/pages/developers/rust/stream/tutorial.mdx index a9ec02f722..a79df667d1 100644 --- a/documentation/docs/pages/developers/rust/stream/tutorial.mdx +++ b/documentation/docs/pages/developers/rust/stream/tutorial.mdx @@ -3,15 +3,16 @@ title: "Stream Tutorial: Build a Private Echo Server" description: "Step-by-step Rust tutorial to build an echo server and client communicating through the Nym mixnet using AsyncRead and AsyncWrite streams." schemaType: "HowTo" section: "Developers" -lastUpdated: "2026-03-26" +lastUpdated: "2026-04-17" --- # Tutorial: Build a Private Echo Server import { Callout } from 'nextra/components' import { CodeVerified } from '../../../../components/code-verified' +import { RUST_MSRV } from '../../../../components/versions' -In this tutorial you'll build two programs: a server that listens for incoming streams and echoes back whatever it receives, and a client that opens a stream, sends data, and reads the echo. Both communicate through the Nym Mixnet using `AsyncRead` and `AsyncWrite`, just like TCP sockets. +Two programs: a server that listens for incoming streams and echoes back what it receives, and a client that opens a stream, writes data, and reads the echo. Both communicate through the Nym Mixnet using `AsyncRead` and `AsyncWrite`, like a TCP socket pair. ## What you'll learn @@ -25,7 +26,7 @@ In this tutorial you'll build two programs: a server that listens for incoming s ## Prerequisites -- Rust toolchain (1.70+) +- Rust toolchain ({RUST_MSRV}+) - A working internet connection (clients connect to the live Nym Mixnet) ## Step 1: Set up the project @@ -40,11 +41,10 @@ Add dependencies to `Cargo.toml`: ```toml [dependencies] -nym-sdk = { git = "https://github.com/nymtech/nym", rev = "97068b2" } -nym-bin-common = { git = "https://github.com/nymtech/nym", rev = "97068b2", features = ["basic_tracing"] } +nym-sdk = "1.21.0" +nym-bin-common = { version = "1.21.0", features = ["basic_tracing"] } tokio = { version = "1", features = ["full"] } rand = "0.8" -blake3 = "=1.7.0" # required pin — see https://nymtech.net/docs/developers/rust/importing ``` ## Step 2: Build the echo server @@ -65,7 +65,7 @@ async fn main() { let mut client = mixnet::MixnetClient::connect_new().await.unwrap(); println!("Echo server listening at: {}", client.nym_address()); - // Create a listener — this activates stream mode. + // Create a listener; this activates stream mode. // From this point, message-based methods are disabled. let mut listener = client.listener().unwrap(); @@ -88,7 +88,7 @@ async fn main() { loop { let n = match stream.read(&mut buf).await { - Ok(0) => break, // EOF — stream closed + Ok(0) => break, // EOF, stream closed Ok(n) => n, Err(e) => { eprintln!("Stream {stream_id} read error: {e}"); @@ -114,7 +114,7 @@ async fn main() { ``` -**`listener()` can only be called once per client.** It takes exclusive ownership of the inbound message channel. A second call returns `Error::ListenerAlreadyTaken`. +`listener()` can only be called once per client. It takes exclusive ownership of the inbound message channel; a second call returns `Error::ListenerAlreadyTaken`. ## Step 3: Build the client @@ -151,13 +151,13 @@ async fn main() { println!("Stream opened: {}", stream.id()); // Give the Open message time to traverse the mixnet and reach the server. - // open_stream() returns immediately after sending — it doesn't wait for + // open_stream() returns immediately after sending; it doesn't wait for // the server to accept. Writing too soon risks the data arriving before // the Open, which the server would drop. tokio::time::sleep(Duration::from_secs(5)).await; // Send three payloads of different sizes and verify the echo. - // Random bytes show that streams are binary-safe — not just text. + // Random bytes show that streams are binary-safe, not just text. let sizes = [320, 25_000, 1280]; for (i, &size) in sizes.iter().enumerate() { @@ -243,7 +243,7 @@ Stream 12345678 closed 5. On arrival, the router reads the `LpFrameKind` to identify it as stream traffic, decodes the header, finds the matching stream by ID, and delivers the raw payload to `read()`. -6. The inbound stream replies via **reply SURBs**, the same anonymous reply mechanism as the message API, applied transparently. The server never learns the client's Nym address. +6. The inbound stream replies via reply SURBs, the same anonymous reply mechanism as the message API. The server never learns the client's Nym address. 7. When a stream is dropped, it deregisters from the local router. No close message is sent over the wire, since a close could race ahead of in-flight data. @@ -279,7 +279,10 @@ async fn main() { loop { let mut stream = match listener.accept().await { Some(s) => s, - None => break, + None => { + println!("Listener closed"); + break; + } }; let stream_id = stream.id(); @@ -287,16 +290,27 @@ async fn main() { tokio::spawn(async move { let mut buf = vec![0u8; 32_000]; + loop { let n = match stream.read(&mut buf).await { - Ok(0) | Err(_) => break, + Ok(0) => break, Ok(n) => n, + Err(e) => { + eprintln!("Stream {stream_id} read error: {e}"); + break; + } }; - if let Err(_) = stream.write_all(&buf[..n]).await { + + let data = &buf[..n]; + println!("Stream {stream_id} received {n} bytes"); + + if let Err(e) = stream.write_all(data).await { + eprintln!("Stream {stream_id} write error: {e}"); break; } stream.flush().await.unwrap(); } + println!("Stream {stream_id} closed"); }); } diff --git a/documentation/docs/pages/developers/rust/tcpproxy.mdx b/documentation/docs/pages/developers/rust/tcpproxy.mdx index e25154b474..a3ec084fa7 100644 --- a/documentation/docs/pages/developers/rust/tcpproxy.mdx +++ b/documentation/docs/pages/developers/rust/tcpproxy.mdx @@ -1,26 +1,22 @@ --- -title: "Nym TcpProxy: Route TCP via the Mixnet" -description: "Route TCP traffic through the Nym mixnet using the TcpProxy Rust module. Covers architecture, single and multi-connection patterns, and troubleshooting." +title: "Nym TcpProxy: Route TCP via the Mixnet (Deprecated)" +description: "Route TCP traffic through the Nym mixnet using the TcpProxy Rust module. Deprecated in favour of the Stream module." schemaType: "TechArticle" section: "Developers" -lastUpdated: "2026-03-27" +lastUpdated: "2026-04-17" --- # TcpProxy Module + import { Callout } from 'nextra/components'; -import { CodeVerified } from '../../../components/code-verified' - **This module is unmaintained.** The TcpProxy is no longer actively developed in favour of the [Stream module](./stream), which provides `AsyncRead + AsyncWrite` streams directly over the Mixnet without the TCP socket overhead. Existing users should plan to migrate to streams when possible. The TcpProxy will continue to work but will not receive new features or bug fixes. +This module is unmaintained. The TcpProxy is no longer actively developed in favour of the [Stream module](/developers/rust/stream), which provides `AsyncRead + AsyncWrite` channels directly over the Mixnet without the localhost TCP socket layer. Existing users should plan to migrate. The module will continue to work but will not receive new features or bug fixes. -The Stream module offers the same key benefit (familiar I/O patterns on top of the Mixnet) with a simpler API. Streams multiplex connections on a single client, eliminate the localhost socket overhead, and now include sequence-based message reordering. There is no remaining reason to choose TcpProxy over Streams for new projects. +`NymProxyClient` and `NymProxyServer` proxy TCP traffic through the Mixnet. Both run in a background thread and expose a configurable `localhost` socket that callers read and write to like any other TCP connection. The Stream module replaces this pattern with multiplexed channels on a single client and no localhost socket layer. ---- - -`NymProxyClient` and `NymProxyServer` proxy TCP traffic through the Mixnet. Both run in a background thread and expose a configurable `localhost` socket that you read and write to like any other TCP connection. - -> Non-Rust/Go developers who want to experiment with this module can start with the [standalone binaries](../tools/standalone-tcpproxy). +> Non-Rust/Go developers can use the [standalone binaries](/developers/tools/standalone-tcpproxy) instead. ## Examples @@ -36,149 +32,13 @@ cargo run --example tcp_proxy_multistream ## API reference -- [API reference on docs.rs](https://docs.rs/nym-sdk/latest/nym_sdk/tcp_proxy/): architecture overview, client/server examples, and type documentation - -## Tutorial - - - -Set up the project: - -```sh -cargo init nym-tcp-proxy -cd nym-tcp-proxy -rm src/main.rs -``` - -Add dependencies to `Cargo.toml`: - -```toml -[dependencies] -nym-sdk = { git = "https://github.com/nymtech/nym", rev = "97068b2" } -nym-network-defaults = { git = "https://github.com/nymtech/nym", rev = "97068b2" } -nym-bin-common = { git = "https://github.com/nymtech/nym", rev = "97068b2", features = ["basic_tracing"] } -tokio = { version = "1", features = ["full"] } -anyhow = "1" -blake3 = "=1.7.0" # required pin — see https://nymtech.net/docs/developers/rust/importing - -[[bin]] -name = "proxy_server" -path = "src/bin/proxy_server.rs" - -[[bin]] -name = "proxy_client" -path = "src/bin/proxy_client.rs" -``` - -### Server - -The server connects to the Mixnet and forwards incoming traffic to a local TCP service (e.g. a web server on port 8000). - -```rust -use nym_sdk::tcp_proxy::NymProxyServer; - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - nym_bin_common::logging::setup_tracing_logger(); - - let mut server = NymProxyServer::new( - "127.0.0.1:8000", // upstream address (host:port) - "./proxy-server-config", // config directory for persistent keys - None, // env file (None = mainnet) - None, // gateway (None = auto-select) - ).await?; - - println!("Proxy server address: {}", server.nym_address()); - server.run_with_shutdown().await?; - Ok(()) -} -``` - -### Client - -The client opens a localhost TCP socket and tunnels all traffic through the Mixnet to the server. - -```rust -use nym_sdk::tcp_proxy::NymProxyClient; -use nym_sdk::mixnet::Recipient; -use nym_network_defaults::setup_env; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::TcpStream; - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - nym_bin_common::logging::setup_tracing_logger(); - // Load mainnet network defaults into env vars (required by NymProxyClient's internal ClientPool) - setup_env(None::); - - let server_addr: Recipient = std::env::args() - .nth(1).expect("Usage: proxy_client ") - .parse()?; - - let client = NymProxyClient::new( - server_addr, - "127.0.0.1", // listen host - "8070", // listen port - 60, // close timeout (seconds) - None, // env file (None = mainnet) - 1, // client pool size - ).await?; - - let proxy = tokio::spawn(async move { client.run().await }); - - // Wait for the pool to create a client and the proxy to be ready. - // The first startup takes ~10-15s while the client connects to the Mixnet. - println!("Waiting for proxy to be ready..."); - tokio::time::sleep(std::time::Duration::from_secs(15)).await; - - let mut stream = TcpStream::connect("127.0.0.1:8070").await?; - stream.write_all(b"GET / HTTP/1.0\r\nHost: localhost\r\n\r\n").await?; - - let mut response = Vec::new(); - stream.read_to_end(&mut response).await?; - println!("Response:\n{}", String::from_utf8_lossy(&response)); - - drop(stream); - proxy.abort(); - Ok(()) -} -``` - -### Run it - -Start an upstream TCP service (e.g. a simple HTTP server): - -```sh -python3 -m http.server 8000 -``` - -In a second terminal, start the proxy server: - -```sh -RUST_LOG=info cargo run --bin proxy_server -``` - -Copy the Nym address it prints, then in a third terminal: - -```sh -RUST_LOG=info cargo run --bin proxy_client -- -``` - -The response will take 30–60 seconds to arrive as it traverses the Mixnet in both directions. +[`docs.rs/nym-sdk/tcp_proxy`](https://docs.rs/nym-sdk/latest/nym_sdk/tcp_proxy/) covers types, methods, and the full client/server walkthrough. ## Architecture -Each sub-module handles Nym clients differently: -- **`NymProxyClient`** relies on the [Client Pool](./client-pool) to create clients and keep a reserve. If incoming TCP connections outpace the pool, it creates an ephemeral client per connection. One client maps to one TCP connection. -- **`NymProxyServer`** has a single Nym client with a persistent identity. +`NymProxyClient` uses a [Client Pool](/developers/rust/client-pool) with one client per incoming TCP connection; if the pool runs dry it falls back to creating clients on demand. `NymProxyServer` runs a single Nym client with a persistent identity. -### Sessions & message ordering - -Messages are wrapped in a session ID per connection, with individual messages given an incrementing message ID. Once all messages are sent, the client sends a `Close` message to notify the server that there are no more outbound messages for this session. - -> Session management and message IDs are necessary since *the Mixnet guarantees message delivery but not message ordering*: in the case of trying to e.g. send gRPC protobuf through the Mixnet, ordering is required so that a buffer is not split across Sphinx packet payloads, and that the 2nd half of the frame is not passed upstream to the parser before the 1st half. - -The key data structure: +Each TCP connection is wrapped in a session ID; messages within a session carry an incrementing message ID, and a final `Close` message signals that no more outbound bytes are coming. Session and message IDs are necessary because the Mixnet guarantees delivery but not ordering, and ordering matters whenever a parser cares about frame boundaries (gRPC over protobuf, HTTP, TLS). ```rust pub struct ProxiedMessage { @@ -188,68 +48,4 @@ pub struct ProxiedMessage { } ``` -### Full request/response flow - -```mermaid ---- -config: - theme: neo-dark - layout: elk ---- -sequenceDiagram - box Local Machine - participant Client Process - participant NymProxyClient - end - Client Process->>NymProxyClient: Request bytes - NymProxyClient->>NymProxyClient: New session - NymProxyClient->>Entry Gateway: Sphinx Packets: Message 1 - Entry Gateway-->>NymProxyClient: Acks - NymProxyClient->>Entry Gateway: Sphinx Packets: Message 2 - Entry Gateway-->>NymProxyClient: Acks - NymProxyClient->>Entry Gateway: Sphinx Packets: Close Message - Entry Gateway-->>NymProxyClient: Acks - - Entry Gateway-->>Mix Nodes: All Packets, Acks, etc - Note right of Mix Nodes: We are omitting the 3 hops etc for brevity here - Mix Nodes-->> Exit Gateway: All Packets, Acks, etc - - Exit Gateway->>NymProxyServer: Sphinx Packets: Message 2 - NymProxyServer-->>Exit Gateway: Acks - loop Message Buffer - NymProxyServer->>NymProxyServer: Wait for Message 1 - Exit Gateway->>NymProxyServer: Sphinx Packets: Message 1 - NymProxyServer-->>Exit Gateway: Acks - NymProxyServer->>NymProxyServer: Message Received: trigger upstream send - end - Note right of NymProxyServer: Note this happens **per session** - NymProxyServer->>Upstream Process: Reconstructed request bytes - Upstream Process->>Upstream Process: Do something with request - Exit Gateway->>NymProxyServer: Sphinx Packets: Close Message - NymProxyServer-->>Exit Gateway: Acks - NymProxyServer->>NymProxyServer: Trigger Client timeout start for session - Upstream Process->>NymProxyServer: Response bytes - NymProxyServer->>NymProxyServer: Write to provided SURB payloads - NymProxyServer->>Exit Gateway: Anonymous replies - box Remote Host - participant NymProxyServer - participant Upstream Process - end - - Entry Gateway->>NymProxyClient: Sphinx Packets: Reply Message 2 - NymProxyClient-->Entry Gateway: Ack - Loop Message Buffer: - NymProxyClient->>NymProxyClient: Wait for Message 1 - Entry Gateway->>NymProxyClient: Sphinx Packets: Message 1 - NymProxyClient-->>Entry Gateway: Acks - NymProxyClient->>NymProxyClient: Message Received: trigger send - NymProxyClient->>Client Process: Response bytes - end - Note right of NymProxyClient: Note this happens **per session** -``` - -## Troubleshooting - -### Lots of `duplicate fragment received` messages - -`WARN` level logs about duplicate fragments are caused by Mixnet-level packet retransmission, where both the original and the retransmitted copy arrive at the destination. This is expected behaviour, not a bug in the client or TcpProxy module. +For the full request/response sequence diagram, see the [module source](https://github.com/nymtech/nym/tree/develop/sdk/rust/nym-sdk/src/tcp_proxy) or the [docs.rs](https://docs.rs/nym-sdk/latest/nym_sdk/tcp_proxy/) entry. diff --git a/documentation/docs/pages/developers/rust/tour.mdx b/documentation/docs/pages/developers/rust/tour.mdx index 6f23e20fe1..9616f0a0d5 100644 --- a/documentation/docs/pages/developers/rust/tour.mdx +++ b/documentation/docs/pages/developers/rust/tour.mdx @@ -5,7 +5,7 @@ import { Callout } from 'nextra/components' A quick walkthrough of the most important things you can do with `nym-sdk`. Each section shows working code and links to the module that covers it in depth. -**The Mixnet is not like regular internet networking** — there are no persistent connections, no guaranteed message ordering, and no TCP underneath. At its core, the Mixnet is a message-based anonymity network: you send individual payloads that are Sphinx-encrypted, mixed through multiple nodes, and independently reconstructed at the destination. +The Mixnet is not like regular internet networking. There are no persistent connections, no guaranteed message ordering, and no TCP underneath. At its core, the Mixnet is a message-based anonymity network: you send individual payloads that are Sphinx-encrypted, mixed through multiple nodes, and independently reconstructed at the destination. The raw [message API](./mixnet) therefore works differently from what most developers expect. The [Stream module](./stream) bridges this gap by providing `AsyncRead + AsyncWrite` byte streams on top of the Mixnet. If you are coming from socket-based networking, start with streams. @@ -80,7 +80,7 @@ async fn main() { // Receiver accepts it let mut inc = listener.accept().await.unwrap(); - // Standard tokio I/O — write, flush, read + // Standard tokio I/O: write, flush, read out.write_all(b"hello stream").await.unwrap(); out.flush().await.unwrap(); diff --git a/documentation/docs/pages/developers/smolmix.mdx b/documentation/docs/pages/developers/smolmix.mdx new file mode 100644 index 0000000000..90f4da65c3 --- /dev/null +++ b/documentation/docs/pages/developers/smolmix.mdx @@ -0,0 +1,130 @@ +--- +title: "smolmix: TCP/UDP Over the Nym Mixnet" +description: "A userspace IP tunnel that provides standard TcpStream and UdpSocket types over the Nym mixnet. Compatible with the async tokio Rust ecosystem." +schemaType: "TechArticle" +section: "Developers" +lastUpdated: "2026-04-29" +--- + +# smolmix + +import { Callout } from 'nextra/components' +import { RUST_MSRV } from '../../components/versions' + +`smolmix` is a TCP/UDP tunnel over the Nym mixnet. It uses a userspace network stack [`smoltcp`](https://docs.rs/smoltcp/latest/smoltcp/) to provide `TcpStream` and `UdpSocket` types that work with the async [`tokio`](https://docs.rs/tokio) Rust ecosystem e.g. [`tokio-rustls`](https://docs.rs/tokio-rustls), [`hyper`](https://docs.rs/hyper), [`tokio-tungstenite`](https://docs.rs/tokio-tungstenite), etc. + +The `TcpStream` type implements tokio's `AsyncRead`/`AsyncWrite` traits and `UdpSocket` provides `send_to`/`recv_from` for datagrams. + +```text +┌──────────────────────────────────────────────────────────────┐ +│ Your application (TLS, HTTP, WebSocket, DNS, etc.) │ +│ └─ smolmix::TcpStream / UdpSocket │ +│ └─ smoltcp (userspace TCP/IP) │ +│ └─ Nym mixnet → IPR exit gateway → internet │ +└──────────────────────────────────────────────────────────────┘ +``` + +Traffic exits the mixnet at an [IPR (Internet Packet Router)](/network/infrastructure/exit-services#ip-packet-router) exit gateway. The exit IP is the gateway's, not yours. + +## Runtime and platform support +### Other runtimes +`smolmix` currently requires `tokio`. The internal pipeline is tokio-based: the bridge task that shuttles packets to the mixnet, the Nym SDK's `MixnetClient`, and the [`tokio-smoltcp`](https://docs.rs/tokio-smoltcp) reactor that drives the userspace TCP/IP stack all run on the tokio runtime. + +This means `smolmix` is not directly compatible with alternative async runtimes like [`smol`](https://docs.rs/smol) or [`async-std`](https://docs.rs/async-std). If you need to use `smolmix` from another runtime, the [`async-compat`](https://docs.rs/async-compat) crate can bridge the gap. + +### Non-native `smolmix` +A WASM version of `smolmix` is planned for a future release. + +## Installation + +Add `smolmix` to your `Cargo.toml`: + +```toml +[dependencies] +smolmix = "1.21.0" +nym-bin-common = { version = "1.21.0", features = ["basic_tracing"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros"] } +``` + +`tokio` is a transitive dependency of smolmix, but you need to enable `rt-multi-thread` (the default runtime spun up by `#[tokio::main]`) and `macros` (for the `#[tokio::main]` attribute itself). + +`nym-bin-common` is optional but recommended: it sets up [`tracing`](https://docs.rs/tracing) logging so you can see mixnet connection progress. + +**Minimum Rust version:** {RUST_MSRV}+ + +### From Git + +For unreleased changes, import directly from the repository: + +```toml +smolmix = { git = "https://github.com/nymtech/nym", branch = "develop" } +``` + +## When to use smolmix + +| | smolmix | Stream module | mixFetch | SOCKS client | +|---|---|---|---|---| +| **Layer** | Transport (TCP/UDP) | Message (multiplexed streams) | HTTP | TCP (SOCKS proxy) | +| **Controls both sides?** | No (proxy mode) | Yes (E2E) | No (proxy mode) | No (proxy mode) | +| **API** | `TcpStream`, `UdpSocket` | `AsyncRead + AsyncWrite` | `fetch()` replacement | SOCKS4/5 protocol | +| **Composability** | Full: TLS, HTTP, WebSocket, DNS, etc. stack on top | Byte streams only | HTTP(S) only | Application-dependent | +| **Best for** | Reaching external services from Rust with standard networking | Peer-to-peer / E2E protocols between Nym clients | Browser HTTP requests | Legacy apps with SOCKS support | + +## Security model + + +Traffic is Sphinx-encrypted inside the mixnet. Past the Exit Gateway, it travels over the public internet to the destination, the same as any other server-initiated connection. Protect the payload at the application layer with TLS ([`rustls`](https://docs.rs/rustls)), Noise Protocol ([`snow`](https://docs.rs/snow)), or equivalent, as you would on a direct connection. + + +### What's protected + +| Segment | Mixnet encryption | What's visible | +|---|---|---| +| Your machine → mixnet entry | Sphinx (layered) | Entry gateway sees your IP but not the destination | +| Inside the mixnet (entry + 3 mix layers + exit) | Sphinx (layered) | Each node only knows prev/next hop | +| Exit gateway (IPR) | Sphinx removed, raw IP packet exposed | IPR sees destination IP + port. Payload depends on your application layer (see below). | +| IPR → remote host | None (Sphinx is mixnet-only) | Remote host sees IPR's IP, not yours | + +The Sphinx encryption is the **mixnet transport layer**: it protects packets as they traverse the mix nodes. At the exit gateway, the Sphinx layers are stripped and the original IP packet is forwarded to the destination. This is analogous to how a Tor exit node or VPN endpoint unwraps its tunnel. + +What's inside that IP packet is up to you. If you connect with TLS (as in the [TCP example](https://github.com/nymtech/nym/blob/develop/smolmix/core/examples/tcp.rs)), the IPR sees encrypted TLS ciphertext going to a destination IP: it knows where but not what. If you send plaintext HTTP, the IPR can read the full request and response. + +### Trust boundaries + +- You trust the mixnet to provide unlinkability between sender and receiver. This is enforced cryptographically by the Sphinx packet format and mixing. +- You trust the IPR exit gateway in the same way you trust a VPN exit or Tor exit node: it can inspect your raw IP packets. The difference is that the IPR doesn't know who is sending the traffic (the mixnet hides your identity). +- **Application-layer encryption closes the gap.** TLS, Noise Protocol, or any authenticated encryption keeps the payload as ciphertext to the IPR. It can see destination IP and port, but not payload content. + +### Comparison with other privacy tools + +| | smolmix | Tor | VPN | +|---|---|---|---| +| **Exit node sees traffic?** | Yes (encrypt it) | Yes (encrypt it) | Yes (encrypt it) | +| **Exit node knows sender?** | No (mixnet hides identity) | No (onion routing) | Yes (VPN provider knows) | +| **Timing analysis resistance** | Strong (mixing, cover traffic) | Weak (low-latency) | None | +| **UDP support** | Yes | No (TCP only) | Yes | + +## Examples + +Runnable examples in [`smolmix/core/examples/`](https://github.com/nymtech/nym/tree/develop/smolmix/core/examples). Each is self-contained; read the `//!` doc comments at the top of each file for a walkthrough. + +```sh +cargo run -p smolmix --example +``` + +All examples accept `--ipr
` to target a specific exit node (pass a `Recipient` address to `Tunnel::builder().ipr_address()`). + +| Example | Source | What it demonstrates | +|---|---|---| +| UDP | [`udp.rs`](https://github.com/nymtech/nym/blob/develop/smolmix/core/examples/udp.rs) | DNS lookup via [`hickory-proto`](https://docs.rs/hickory-proto), sending a raw UDP query to `1.1.1.1:53` through the mixnet. Runs a clearnet [`hickory-resolver`](https://docs.rs/hickory-resolver) lookup alongside it to compare resolved IPs and latency | +| TCP | [`tcp.rs`](https://github.com/nymtech/nym/blob/develop/smolmix/core/examples/tcp.rs) | HTTPS request via [`hyper`](https://docs.rs/hyper) + [`tokio-rustls`](https://docs.rs/tokio-rustls). Fetches Cloudflare's `/cdn-cgi/trace` to show that the exit IP differs from clearnet | +| WebSocket | [`websocket.rs`](https://github.com/nymtech/nym/blob/develop/smolmix/core/examples/websocket.rs) | WebSocket echo against `ws.postman-echo.com` via [`tokio-tungstenite`](https://docs.rs/tokio-tungstenite) + [`tokio-rustls`](https://docs.rs/tokio-rustls). Runs the same stack over clearnet first, so the only thing that changes between runs is the underlying TCP stream | +| TCP download | [`tcp_download.rs`](https://github.com/nymtech/nym/blob/develop/smolmix/core/examples/tcp_download.rs) | DNS-over-mixnet + multi-request HTTP/1.1 download over a single keep-alive connection, the full real-world pattern | + +## Architecture + +The internal stack (smoltcp reactor, device adapter, bridge task, packet flow) is documented in [`ARCHITECTURE.md`](https://github.com/nymtech/nym/blob/develop/smolmix/core/src/ARCHITECTURE.md). This is also the crate-level documentation on docs.rs. + +## API reference + +Full API documentation is available on [docs.rs/smolmix](https://docs.rs/smolmix). diff --git a/documentation/docs/pages/developers/tools.mdx b/documentation/docs/pages/developers/tools.mdx index 07cf771203..838632e50c 100644 --- a/documentation/docs/pages/developers/tools.mdx +++ b/documentation/docs/pages/developers/tools.mdx @@ -8,10 +8,10 @@ lastUpdated: "2026-02-01" # Tools -Standalone binaries for development and testing that don't require an SDK — download or compile them and use them directly. +Standalone binaries for development and testing that don't require an SDK. Download or compile them and use them directly. | Tool | Use case | |---|---| | [nym-cli](./tools/nym-cli) | Command-line interface for interacting with the Nyx blockchain: querying state, submitting transactions, managing keys. An easier-to-use wrapper around `nyxd`. | | [Diagnostic Tool](./tools/diagnostic-tool) | Network diagnostic utility for troubleshooting connectivity issues. | -| [Standalone TcpProxy](./tools/standalone-tcpproxy) | Pre-built binaries of the TcpProxy client and server for proxying TCP traffic through the Mixnet. Note: the TcpProxy module is unmaintained; use the [Stream module](./rust/stream) for new projects. | +| [Standalone TcpProxy](./tools/standalone-tcpproxy) | Pre-built binaries of the TcpProxy client and server for proxying TCP traffic through the Mixnet. The TcpProxy module is unmaintained; use the [Stream module](./rust/stream) for new projects. | diff --git a/documentation/docs/pages/developers/tools/diagnostic-tool.mdx b/documentation/docs/pages/developers/tools/diagnostic-tool.mdx index dec7b67cf2..930c5fcab7 100644 --- a/documentation/docs/pages/developers/tools/diagnostic-tool.mdx +++ b/documentation/docs/pages/developers/tools/diagnostic-tool.mdx @@ -2,9 +2,9 @@ import { Steps } from 'nextra/components'; # Diagnostic Tool -The Diagnostic Tool is a standalone binary designed to perform various network tests, including DNS, HTTP, and gateway connectivity tests. This tool helps diagnose connectivity issues and provides insights into network performance. +The Diagnostic Tool is a standalone binary that runs network tests (DNS, HTTP, gateway connectivity) to diagnose connectivity issues and report on network performance. -It’s also possible to run it within the daemon with the same CLI interface. +It can also be run from within the daemon with the same CLI interface. ## Download Binary @@ -36,7 +36,7 @@ chmod +x ./* ## CLI Usage -The Diagnostic Tool can be executed from the command line interface (CLI). Below are the usage instructions and options available. Read in the chapter [*Tests Performed*](#tests-performed) about the purpose and outcome of these commands. +The Diagnostic Tool runs from the command line. The usage instructions and options follow. See [Tests Performed](#tests-performed) for the purpose and outcome of each command. ### Command Syntax @@ -99,36 +99,36 @@ The Diagnostic Tool runs the following tests: ### 1. DNS Test -- **Purpose**: To check the resolution DNS availability. -- **Process**: We try to resolve all the domain names present in a given nym network environment with different DNS configurations -- **Output**: Displays the resolved IP address and the time taken for the resolution. +- **Purpose**: Check DNS resolution availability. +- **Process**: Resolve all domain names present in the given Nym network environment with different DNS configurations. +- **Output**: Resolved IP address and time taken for resolution. ### 2. HTTP Test -- **Purpose**: To verify the accessibility of the NymVPN API. -- **Process**: The tool query the `health` endpoint as well as the `nodes/described` endpoint. -- **Output**: Displays the response of the `health` endpoint, the time skew and the number of nodes in the network (sanity check) +- **Purpose**: Verify accessibility of the NymVPN API. +- **Process**: Query the `health` and `nodes/described` endpoints. +- **Output**: Response from the `health` endpoint, time skew, and number of nodes in the network (sanity check). ### 3. Gateway Test -- **Purpose**: To check the connectivity to a given gateway. -- **Process**: The tool fetches information about the gateway, then establishes a TCP connection, upgrades it to WS and sends a request -- **Output**: Display the gateway reported information, the status of the connections and the WS response. +- **Purpose**: Check connectivity to a given gateway. +- **Process**: Fetch information about the gateway, establish a TCP connection, upgrade it to WS, and send a request. +- **Output**: Gateway-reported information, connection status, and WS response. ### 4. Registration Test -- **Purpose:** To check the correctness of the registration process. -- **Process:** The tool tries to build a mixnet client to the provided gateway and then tries to register to the entry authenticator -- **Output:** Display the status of the different steps -- **Caveat:** This test requires a credential to be spent, which is why it is available as a separate command only +- **Purpose:** Check the registration process. +- **Process:** Build a mixnet client to the provided gateway and register with the entry authenticator. +- **Output:** Status of each step. +- **Caveat:** This test spends a credential, which is why it lives in a separate command. ### 5. Wireguard Test -- **Purpose:** To check the soundness of a wireguard connection -- **Process:** The tool uses the registration data from the previous step to establish a wireguard connection and ping an IP. -- **Output:** Display the ping RTTs and any error that might have happened +- **Purpose:** Check the soundness of a wireguard connection. +- **Process:** Use the registration data from the previous step to establish a wireguard connection and ping an IP. +- **Output:** Ping RTTs and any errors. ## Reports -Reports are logged in a JSON format and also returned by the commands for a future use \ No newline at end of file +Reports are logged in JSON format and also returned by the commands for later use. \ No newline at end of file diff --git a/documentation/docs/pages/developers/tools/nym-cli/usage.mdx b/documentation/docs/pages/developers/tools/nym-cli/usage.mdx index 6dced9fd92..eac5574545 100644 --- a/documentation/docs/pages/developers/tools/nym-cli/usage.mdx +++ b/documentation/docs/pages/developers/tools/nym-cli/usage.mdx @@ -4,11 +4,11 @@ The `nym-cli` binary can be built by running `cargo build --release` in the `nym/tools/nym-cli` directory. ## Usage -See the [commands](commands.mdx) page for an overview of all command options. +See the [commands](/developers/tools/nym-cli/commands) page for an overview of all command options. ## Staking on someone's behalf (for custodians) -There is a limitation the staking address can only perform the following actions (and are visible via the Nym Wallet: +The staking address can only perform the following actions (visible via the Nym Wallet): - Bond on the gateway's or Mix Node's behalf. - Delegate or Un-delegate (to a Mix Node in order to begin receiving rewards) diff --git a/documentation/docs/pages/developers/tools/standalone-tcpproxy.mdx b/documentation/docs/pages/developers/tools/standalone-tcpproxy.mdx index 89372e7b34..5406a794ec 100644 --- a/documentation/docs/pages/developers/tools/standalone-tcpproxy.mdx +++ b/documentation/docs/pages/developers/tools/standalone-tcpproxy.mdx @@ -6,9 +6,9 @@ import { Callout } from 'nextra/components' **Deprecated.** The TcpProxy module is no longer actively developed. The [Stream module](/developers/rust/stream) provides the same functionality (familiar `AsyncRead`/`AsyncWrite` I/O over the Mixnet) with a simpler API, multiplexed connections, and sequence-based message reordering. Use Streams for new projects. -Standalone versions of the `TcpProxyClient` and `TcpProxyServer` [sdk module](../rust/tcpproxy) can be found [here](https://github.com/nymtech/standalone-tcp-proxies/tree/main). +Standalone versions of the `TcpProxyClient` and `TcpProxyServer` [sdk module](/developers/rust/tcpproxy) can be found [here](https://github.com/nymtech/standalone-tcp-proxies/tree/main). -These might be an easy way for developers to start proxying their traffic throught the mixnet and understanding the sort of latency they should expect, and whether their application can currently tolerate it. They might also prove useful for server setups where several components are being run via init scripts, and the addition of a separate process is acceptable. +These can be a quick way to start proxying traffic through the mixnet, see what latency to expect, and check whether your application can tolerate it. They are also useful for server setups where several components run via init scripts and an extra process is acceptable. ## Build ```shell diff --git a/documentation/docs/pages/developers/typescript.mdx b/documentation/docs/pages/developers/typescript.mdx index d2d8df68d4..5dbe63a500 100644 --- a/documentation/docs/pages/developers/typescript.mdx +++ b/documentation/docs/pages/developers/typescript.mdx @@ -1,9 +1,9 @@ --- -title: "Nym TypeScript SDK: Privacy for Web Apps" -description: "TypeScript SDK for integrating web apps with the Nym mixnet. Covers mixFetch, Mixnet Client, Smart Contracts, and Cosmos Kit with live playground examples." +title: "TypeScript SDK: Mixnet Messaging & Smart Contracts" +description: "TypeScript SDK for integrating web apps with the Nym mixnet. Covers the Mixnet Client (messaging) and Nym Smart Contracts. For HTTP requests, see mix-fetch." schemaType: "TechArticle" section: "Developers" -lastUpdated: "2026-03-13" +lastUpdated: "2026-05-12" --- import { Callout } from 'nextra/components' @@ -12,7 +12,23 @@ import { NPMLink } from '../../components/npm'; # TypeScript SDK -The TypeScript SDK lets you build browser-based applications that communicate through the Nym mixnet. Import SDK packages via NPM as you would any other TypeScript library. +The TypeScript SDK lets you build browser-based applications that communicate through the Nym mixnet. + +```text +┌──────────────────────────────────────────────────────────────┐ +│ Your browser app │ +│ └─ Nym Mixnet Client (WASM, runs in a Web Worker) │ +│ └─ WebSocket to entry gateway │ +│ └─ Nym mixnet (entry → 3 mix layers → exit) │ +│ └─ Peer MixnetClient (e.g. nym-sdk) │ +└──────────────────────────────────────────────────────────────┘ +``` + +The Mixnet Client operates in messaging mode (text or binary payloads) and runs in a Web Worker to keep the UI thread free. For HTTP requests, use [`mix-fetch`](/developers/mix-fetch) instead. + + +For an HTTP-over-mixnet `fetch()` replacement, see [**mix-fetch**](/developers/mix-fetch). This page covers the **Mixnet Client** (send / receive raw messages) and **Smart Contracts** (interact with the Nyx chain). + The Nym Mixnet routes traffic through multiple nodes with no persistent connections or guaranteed ordering. The SDK abstracts the complexity, but understanding the [underlying model](/developers/rust/tour) helps when debugging. @@ -23,21 +39,6 @@ The Nym Mixnet routes traffic through multiple nodes with no persistent connecti - - - **mixFetch** - - - A drop-in replacement for [`fetch`](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch) - that sends HTTP requests over the Nym mixnet - - -
-
-
- -
-
**Mixnet Client** @@ -82,12 +83,6 @@ All `*-full-fat` variants have large bundle sizes because they include WASM and ## Installation -### mixFetch - -```bash -npm install @nymproject/mix-fetch-full-fat -``` - ### Mixnet Client ```bash @@ -103,30 +98,11 @@ npm install @nymproject/contract-clients @cosmjs/cosmwasm-stargate @cosmjs/proto ### Install everything ```bash -npm install @nymproject/contract-clients @cosmjs/cosmwasm-stargate @cosmjs/proto-signing @nymproject/sdk-full-fat @nymproject/mix-fetch-full-fat +npm install @nymproject/contract-clients @cosmjs/cosmwasm-stargate @cosmjs/proto-signing @nymproject/sdk-full-fat ``` ## Quick start -### mixFetch - -Use [`mixFetch`](https://www.npmjs.com/package/@nymproject/mix-fetch) as a drop-in replacement for `fetch` to send HTTP requests over the mixnet: - -```ts -import { mixFetch } from '@nymproject/mix-fetch'; - -// HTTP GET -const response = await mixFetch('https://nym.com'); -const html = await response.text(); - -// HTTP POST -const apiResponse = await mixFetch('https://api.example.com', { - method: 'POST', - body: JSON.stringify({ foo: 'bar' }), - headers: { 'Content-Type': 'application/json' } -}); -``` - ### Mixnet Client Create a [`Mixnet Client`](https://www.npmjs.com/package/@nymproject/sdk) to send and receive messages through the mixnet: @@ -184,4 +160,4 @@ console.log(`Tx Hash = ${result.transactionHash}`); - **[Step-by-step examples](./typescript/examples):** Full working projects for each package - **[Live playground](./typescript/playground):** Try the SDK in your browser - **[Bundling](./typescript/bundling):** Configure Webpack or ESBuild for WASM and web workers -- **[TypeDoc reference](./typescript/api):** generated reference for all packages +- **[TypeDoc reference](./typescript/api):** Generated reference for all packages diff --git a/documentation/docs/pages/developers/typescript/bundling/esbuild.mdx b/documentation/docs/pages/developers/typescript/bundling/esbuild.mdx index 5ff64081cf..4873564737 100644 --- a/documentation/docs/pages/developers/typescript/bundling/esbuild.mdx +++ b/documentation/docs/pages/developers/typescript/bundling/esbuild.mdx @@ -2,16 +2,18 @@ import { Callout } from 'nextra/components'; # Troubleshooting bundling with ESbuild -If you've been following the steps outlined in the Examples section, your development environment should be configured as follows: +If you have followed the steps in the Examples section, your development environment should be configured as follows. #### Environment Setup -Begin by creating a directory and configuring your application environment: -Create your directory and set-up your app environment: +Create your directory and set up your app environment: + ```bash npm create vite@latest ``` -During the environment setup, choose React and subsequently opt for Typescript if you want your application to function smoothly following this tutorial. Next, navigate to your application directory and run the following commands: + +Choose React, then Typescript. Navigate to your application directory and run: + ```bash cd < YOUR_APP > npm i @@ -20,12 +22,13 @@ npm run dev ##### Installation Install the required package: + ```bash npm install @nymproject/< PACKAGE_NAME > ``` - - Remember that the CosmosKit example will require you to make use of polyfills. + + The CosmosKit example requires polyfills. -By implementing the provided code for the various components in the step-by-step examples section, you should be able to set-up and run your application without encountering any bundling challenges! +With the code from the step-by-step examples section in place, the application should run without bundling errors. diff --git a/documentation/docs/pages/developers/typescript/bundling/webpack.mdx b/documentation/docs/pages/developers/typescript/bundling/webpack.mdx index 1b5803f90c..f355f9f021 100644 --- a/documentation/docs/pages/developers/typescript/bundling/webpack.mdx +++ b/documentation/docs/pages/developers/typescript/bundling/webpack.mdx @@ -19,18 +19,19 @@ For any project using Webpack, you´ll need the following rule in your `webpack. #### General cases -If you wish to use Webpack for your app with the code provided in the step-by-step examples section, you'll need to: +To use Webpack with the code from the step-by-step examples section: ```bash npx create-react-app nymapp --template typescript cd nymapp ``` -You'll then need to install the needed dependencies, head to your app's `App.tsx` file and paste the code provided in the step-by-step section. + +Install the required dependencies, then paste the code from the step-by-step section into your app's `App.tsx`. #### Contract client - - Using webpack, the `Contract client` for querying or executing might need polyfills. As create-react-app doesn´t allow you access to the Webpack config without ejecting, you'll overwrite it as follow: + + With webpack, the `Contract client` for querying or executing may need polyfills. Since create-react-app doesn't expose the Webpack config without ejecting, override it as follows: ##### Install contract-clients dependencies @@ -38,7 +39,7 @@ You'll then need to install the needed dependencies, head to your app's `App.tsx npm install @nymproject/contract-clients @cosmjs/cosmwasm-stargate @cosmjs/proto-signing ``` -Head to you app's `App.tsx` file and replace the code by the one provided in the step-by-step examples section. +In your app's `App.tsx`, replace the existing code with the code from the step-by-step examples section. ##### Polyfilling diff --git a/documentation/docs/pages/developers/typescript/examples/cosmos-kit.mdx b/documentation/docs/pages/developers/typescript/examples/cosmos-kit.mdx index c33f16d971..666f5e2887 100644 --- a/documentation/docs/pages/developers/typescript/examples/cosmos-kit.mdx +++ b/documentation/docs/pages/developers/typescript/examples/cosmos-kit.mdx @@ -2,21 +2,22 @@ import { Callout } from 'nextra/components' # Cosmos Kit -The wonderful people of Cosmology have made some [fantastic components](https://cosmoskit.com/) that can be used with -Nym. These include: +Cosmology's [components](https://cosmoskit.com/) work with Nym. They cover: -- Using the wallets such as Keplr, Cosmostation and others from your React application; -- Using the [Ledger hardware wallet](https://docs.cosmoskit.com/integrating-wallets/ledger) from your browser; -- Any wallet that supports [Wallet Connect v2.0](https://docs.cosmoskit.com/integrating-wallets/adding-new-wallets); +- Wallets such as Keplr, Cosmostation, and others from your React application +- The [Ledger hardware wallet](https://docs.cosmoskit.com/integrating-wallets/ledger) from your browser +- Any wallet that supports [Wallet Connect v2.0](https://docs.cosmoskit.com/integrating-wallets/adding-new-wallets) ##### Environment Setup -Begin by creating a directory and configuring your application environment: + +Create a new project with Vite: ```bash npm create vite@latest ``` -During the environment setup, choose React and subsequently opt for Typescript if you want your application to function smoothly following this tutorial. Next, navigate to your application directory and run the following commands: +Choose React, then Typescript. Navigate to your application directory and run: + ```bash cd < YOUR_APP > npm i diff --git a/documentation/docs/pages/developers/typescript/examples/mix-fetch.mdx b/documentation/docs/pages/developers/typescript/examples/mix-fetch.mdx index 961df307ec..5aaf9d792a 100644 --- a/documentation/docs/pages/developers/typescript/examples/mix-fetch.mdx +++ b/documentation/docs/pages/developers/typescript/examples/mix-fetch.mdx @@ -1,6 +1,6 @@ --- title: "mixFetch Example: Private HTTP Requests" -description: "Replace browser fetch with mixFetch to route HTTP requests through the Nym mixnet. Covers setup, CA certificates, WSS gateways, and usage examples." +description: "Replace browser fetch with mixFetch to route HTTP requests through the Nym mixnet. Covers setup, CA certificates, TLS configuration, and usage examples." schemaType: "TechArticle" section: "Developers" lastUpdated: "2026-03-15" @@ -14,13 +14,9 @@ An easy way to secure parts or all of your web app is to replace calls to [`fetc Things to be aware of: -- CA certificates in `mixFetch` are periodically updated. If you get a certificate error, the root certificate you need might not be valid yet. [Send a PR](https://github.com/nymtech/nym/pulls) if you need changes to the certificates. -- If you are using `mixFetch` in a web app with HTTPS, you will need to use a gateway that has Secure Websockets (WSS) to avoid a [mixed content](https://developer.mozilla.org/en-US/docs/Web/Security/Mixed_content) error. -- `mixFetch` supports concurrent requests (up to 10) to the same or different URLs. - - -Right now Gateways are not required to run a Secure Websocket (WSS) listener, so only a subset of nodes running in Gateway mode have configured their nodes to do so. You need to select a Gateway that has WSS from [Harbourmaster](https://harbourmaster.nymtech.net/). - +- **CA certificates** are bundled into the WASM binary at build time. They're updated with each SDK release, so if you hit a certificate error, update to the latest `@nymproject/mix-fetch-full-fat` version. +- **HTTPS and WSS.** When serving your app over HTTPS, the mixnet connection must also use Secure WebSockets to avoid a [mixed content](https://developer.mozilla.org/en-US/docs/Web/Security/Mixed_content) error. Set `forceTls: true` in your `SetupMixFetchOps` config (see below) and the SDK will automatically select a WSS-capable gateway. +- `mixFetch` supports **concurrent requests** (up to 10) to the same or different URLs. ## Environment Setup @@ -41,30 +37,34 @@ npm run dev ## Installation ```bash -npm install @nymproject/mix-fetch-full-fat +npm install @nymproject/mix-fetch-full-fat @mui/material @emotion/react @emotion/styled ``` +The MUI packages are used by the example UI below. If you only need `mixFetch` itself, install only `@nymproject/mix-fetch-full-fat`. + ## Configuration ```ts import type { SetupMixFetchOps } from '@nymproject/mix-fetch-full-fat'; const mixFetchOptions: SetupMixFetchOps = { - clientId: "docs-mixfetch-demo", + clientId: "my-app", preferredGateway: "q2A2cbooyC16YJzvdYaSMH9X3cSiieZNtfBr8cE8Fi1", mixFetchOverride: { requestTimeoutMs: 60_000, }, - forceTls: true, // force WSS + forceTls: true, // use Secure WebSockets (required when serving over HTTPS) }; ``` +`preferredGateway` is optional. If omitted, the SDK auto-selects a gateway. You can pin a specific one via [Harbourmaster](https://harbourmaster.nymtech.net/). + ## Full Example -This example shows explicit initialization via `createMixFetch`, single URL fetch, and concurrent requests. Results appear both in the UI and in a visible log panel. +This example shows explicit initialisation via `createMixFetch`, single URL fetch, and concurrent requests. Results appear both in the UI and in a visible log panel. -For this example we use the `full-fat` version of the ESM SDK. If you use the unbundled ESM variant, make sure your [bundler configuration](../bundling/bundling) copies the WASM and web worker files to the output bundle. +For this example we use the `full-fat` version of the ESM SDK. If you use the unbundled ESM variant, make sure your [bundler configuration](/developers/typescript/bundling/bundling) copies the WASM and web worker files to the output bundle. ```tsx @@ -138,7 +138,7 @@ export const MixFetch = () => { setLogs((prev) => [...prev, { timestamp, message, level }]); }; - // Initialize MixFetch explicitly via createMixFetch + // Initialise MixFetch explicitly via createMixFetch const handleStart = async () => { try { setStatus("starting"); @@ -155,7 +155,7 @@ export const MixFetch = () => { } }; - // Single URL fetch — reuses the existing MixFetch singleton + // Single URL fetch (reuses the existing MixFetch singleton) const handleFetch = async () => { try { setBusy(true); @@ -229,7 +229,7 @@ export const MixFetch = () => { - {/* Fetch controls — disabled until MixFetch is ready */} + {/* Fetch controls (disabled until MixFetch is ready) */} {/* Single fetch */} diff --git a/documentation/docs/pages/developers/typescript/examples/mixnet.mdx b/documentation/docs/pages/developers/typescript/examples/mixnet.mdx index ef3e292fab..6d0810c4f6 100644 --- a/documentation/docs/pages/developers/typescript/examples/mixnet.mdx +++ b/documentation/docs/pages/developers/typescript/examples/mixnet.mdx @@ -43,7 +43,7 @@ npm install @nymproject/sdk-full-fat This example creates a Mixnet client, connects to a gateway, and provides a UI for sending and receiving messages through the mixnet. -For this example we use the `full-fat` version of the ESM SDK. If you use the unbundled ESM variant, make sure your [bundler configuration](../bundling/bundling) copies the WASM and web worker files to the output bundle. +For this example we use the `full-fat` version of the ESM SDK. If you use the unbundled ESM variant, make sure your [bundler configuration](/developers/typescript/bundling/bundling) copies the WASM and web worker files to the output bundle. ```ts copy filename="App.tsx" diff --git a/documentation/docs/pages/developers/typescript/playground/cosmos-kit.mdx b/documentation/docs/pages/developers/typescript/playground/cosmos-kit.mdx index 4498cfc72f..4a6b1d8ccd 100644 --- a/documentation/docs/pages/developers/typescript/playground/cosmos-kit.mdx +++ b/documentation/docs/pages/developers/typescript/playground/cosmos-kit.mdx @@ -12,7 +12,7 @@ Sign a transaction using [CosmosKit](https://cosmoskit.com/) wallet adapters. Th - + No transactions will be broadcast. You will only be signing a transaction. @@ -27,6 +27,6 @@ If you are using a Ledger hardware wallet: ## How this works -The component uses CosmosKit to manage wallet connections across different wallet providers (Keplr, Ledger). When you click Sign, it constructs a message, requests your wallet to sign it, and displays the signature hash. This demonstrates how to integrate Cosmos-compatible wallets into a web application that interacts with the Nyx blockchain. +The component uses CosmosKit to manage wallet connections across different wallet providers (Keplr, Ledger). When you click Sign, it constructs a message, requests your wallet to sign it, and displays the signature hash. The same pattern works for any web application that interacts with the Nyx blockchain. diff --git a/documentation/docs/pages/developers/typescript/playground/mixfetch.mdx b/documentation/docs/pages/developers/typescript/playground/mixfetch.mdx index b32c60f24d..f75896a00b 100644 --- a/documentation/docs/pages/developers/typescript/playground/mixfetch.mdx +++ b/documentation/docs/pages/developers/typescript/playground/mixfetch.mdx @@ -17,6 +17,6 @@ Fetch a URL through the Nym Mixnet. This demo creates a mixFetch client in your ## How this works -The component calls `createMixFetch()` to initialize a Mixnet client in the browser, then uses the returned `mixFetch()` function as a drop-in replacement for `window.fetch()`. The request is routed through the Mixnet to a Network Requester, which makes the HTTP request on your behalf and returns the response anonymously. +The component calls `createMixFetch()` to initialise a Mixnet client in the browser, then uses the returned `mixFetch()` function as a replacement for `window.fetch()`. The request is routed through the Mixnet to a Network Requester, which makes the HTTP request on your behalf and returns the response anonymously. diff --git a/documentation/docs/pages/developers/typescript/playground/traffic.mdx b/documentation/docs/pages/developers/typescript/playground/traffic.mdx index 56170d16a2..de0a194396 100644 --- a/documentation/docs/pages/developers/typescript/playground/traffic.mdx +++ b/documentation/docs/pages/developers/typescript/playground/traffic.mdx @@ -7,7 +7,7 @@ import FormattedTrafficExampleCode from '../../../../code-examples/sdk/typescrip Send and receive messages through the Nym Mixnet directly in your browser. This demo creates a Mixnet client, connects to the network, and lets you send a message to yourself (or any Nym address) to see the full round-trip. -**Try it:** Click Connect, wait for the client to initialize, then send a message. You'll see it arrive back through the Mixnet after traversing 5 hops. +**Try it:** Click Connect, wait for the client to initialise, then send a message. You'll see it arrive back through the Mixnet after traversing 5 hops. Open your browser's console to see the connection and send/receive logging for this example. diff --git a/documentation/docs/pages/network/cryptography.md b/documentation/docs/pages/network/cryptography.md index 0ecc00757c..ec5b7345bc 100644 --- a/documentation/docs/pages/network/cryptography.md +++ b/documentation/docs/pages/network/cryptography.md @@ -12,6 +12,6 @@ The Nym Network relies on several cryptographic systems working together. This s ## What's covered -[Sphinx Packets](/network/cryptography/sphinx) explains the packet format that enables layered encryption and anonymous routing. Each Sphinx packet contains routing information encrypted in layers, where each hop can only decrypt its own layer. +[Sphinx Packets](/network/cryptography/sphinx) explains the packet format used for layered encryption and anonymous routing. Each Sphinx packet contains routing information encrypted in layers, where each hop can only decrypt its own layer. [zk-nyms](/network/cryptography/zk-nym) covers the anonymous credential system that separates payment from usage. This is how you can pay for network access without that payment being linkable to your activity. diff --git a/documentation/docs/pages/network/cryptography/sphinx.md b/documentation/docs/pages/network/cryptography/sphinx.md index 579050e11c..8608806311 100644 --- a/documentation/docs/pages/network/cryptography/sphinx.md +++ b/documentation/docs/pages/network/cryptography/sphinx.md @@ -8,7 +8,7 @@ lastUpdated: "2026-03-15" # Sphinx -Sphinx is the cryptographic packet format used for all mixnet traffic. It provides layered encryption where each hop can only decrypt its own routing information, ensuring that no single node knows both the source and destination of a packet. +Sphinx is the cryptographic packet format used for all mixnet traffic. It provides layered encryption where each hop can only decrypt its own routing information, so no single node knows both the source and destination of a packet. ## How Sphinx works @@ -20,7 +20,7 @@ At each hop, the node uses its private key to decrypt its layer, revealing the a All Sphinx packets have a fixed payload size of 2048 bytes. This uniformity is critical: if packets varied in size, nodes could infer their position in the route or correlate packets by size. -The packet contains a header with encrypted routing information for each hop, HMACs to verify integrity at each layer, and the encrypted payload. The header uses a clever "onion" structure where processing at each hop reveals only the next hop's information while maintaining constant size through padding. +The packet contains a header with encrypted routing information for each hop, HMACs to verify integrity at each layer, and the encrypted payload. The header uses an "onion" structure where processing at each hop reveals only the next hop's information while maintaining constant size through padding. ## Integrity verification diff --git a/documentation/docs/pages/network/cryptography/zk-nym.mdx b/documentation/docs/pages/network/cryptography/zk-nym.mdx index 3febcdaebe..22ac756831 100644 --- a/documentation/docs/pages/network/cryptography/zk-nym.mdx +++ b/documentation/docs/pages/network/cryptography/zk-nym.mdx @@ -2,9 +2,9 @@ The zk-nym scheme enables the creation and use of unlinkable, rerandomisable anonymous access credentials that are 'spent' with Gateways in order to anonymously prove that someone has paid for Mixnet access. This implementation incorporates elements of both the [Coconut Credential](https://arxiv.org/pdf/1802.07344) and [Offline Ecash](https://arxiv.org/pdf/2303.08221) schemes. -As outlined in the [overview](./zk-nym/zk-nym-overview) on the next page, zk-nyms allow for users to pay for Mixnet access in a manner that is **unlinkable to their payment account**; even with pseudonymous cryptocurrencies or fiat. This solves one of the fundamental privacy problems with the majority of VPNs and dVPNs in production today: the linkability of a user's session with their payment information, which can in the majority of cases be easily used to deanonymise them, either at the behest of an authority or by the service operators themselves. +As outlined in the [overview](./zk-nym/zk-nym-overview) on the next page, zk-nyms allow users to pay for Mixnet access in a way that is **unlinkable to their payment account**, even with pseudonymous cryptocurrencies or fiat. This solves one of the fundamental privacy problems with most VPNs and dVPNs in production today: the linkability of a user's session with their payment information, which can in most cases be used to deanonymise them, either at the behest of an authority or by the service operators themselves. -> The current zk-nym scheme is non-generic in that it is only used for gating Mixnet access. A generic scheme based on zk-nyms is being actively researched in order to facilitate more generic and customisable anonymous credentials for other applications and services. +> The current zk-nym scheme is non-generic in that it is only used for gating Mixnet access. A generic scheme based on zk-nyms is being actively researched, to support more generic and customisable anonymous credentials for other applications and services. ## Motivations Most of the time, when we build system security, we think of _who_ questions: @@ -25,7 +25,7 @@ The zk-nym scheme allows for this move to take place. Credentials are generated ### Re-randomisation vs pseudonymity We stand on the shoulders of giants. Ten years ago, Bitcoin showed the way forward by allowing people to control resource access without recourse to _who_ questions. Rather, in Bitcoin and succeeding blockchains, a private key proves a _right to use_. -But as we can now see, private keys in blockchain systems act only as a minor barrier to finding out _who_ is accessing resources. A Bitcoin or Ethereum private key is effectively a long-lived pseudonym which is easily traceable through successive transactions. +But as we can now see, private keys in blockchain systems act only as a minor barrier to finding out _who_ is accessing resources. A Bitcoin or Ethereum private key is a long-lived pseudonym that is easily traceable through successive transactions. **zk-nyms allows us to build truly private systems rather than pseudonymous ones.** @@ -38,8 +38,8 @@ Let's say you have a `message` with the content `This credential controls X` in 2. _Re-randomizable signatures_ - take a signature, and generate a brand new signature that is valid for the same underlying message `This credential controls X`. The new bitstring in the re-randomized signature is equivalent to the original signature but not linkable to it. So a user can generate multiple zk-nyms from a single credential source, unlinkable to any previous "shown" zk-nym. But the underlying content of the re-randomized credential is the same (including for things like double-spend protection). This once again protects the user against the signer, because the signer can't trace the signed message that they gave back to the user when it is presented. It also protects the user against the relying party that accepts the signed credential. The user can generate multiple re-randomized credentials repeatedly, and although the underlying message is the same in all cases, there's no way of tracking them by watching the user present the same credential multiple times. -3. _Selective disclosure of attributes_ - allows someone with the public key to verify some, but not all, parts of a message. So you could for instance selectively reveal parts of a signed message to some people, but not to others. This is a very powerful property of the scheme which is to be explored more in future work, potentially leading to diverse applications: voting systems, anonymous currency, privacy-friendly KYC systems, etc. +3. _Selective disclosure of attributes_ - allows someone with the public key to verify some, but not all, parts of a message. So you could for instance selectively reveal parts of a signed message to some people, but not to others. This property of the scheme is to be explored more in future work, with potential applications including voting systems, anonymous currency, and privacy-friendly KYC systems. -4. _[Threshold issuance](https://en.wikipedia.org/wiki/Threshold_cryptosystem)_ - allows signature generation to be split up across multiple nodes and decentralized, so that either all signers need to sign (_n of n_ where _n_ is the number of signers) or only a threshold number of signers need to sign a message (_t of n_ where _t_ is the threshold value). +4. _[Threshold issuance](https://en.wikipedia.org/wiki/Threshold_cryptosystem)_ - allows signature generation to be split up across multiple nodes and decentralised, so that either all signers need to sign (_n of n_ where _n_ is the number of signers) or only a threshold number of signers need to sign a message (_t of n_ where _t_ is the threshold value). -Taken together, these properties provide privacy for applications when it comes to generating and using signatures for cryptographic claims. If you compare it to existing tech, the closest analogy in conventional systems is a decentralized, privacy-preserving [JWT](https://jwt.io/). +Taken together, these properties provide privacy for applications when generating and using signatures for cryptographic claims. The closest analogy in conventional systems is a decentralised, privacy-preserving [JWT](https://jwt.io/). diff --git a/documentation/docs/pages/network/cryptography/zk-nym/double-spend-prot.mdx b/documentation/docs/pages/network/cryptography/zk-nym/double-spend-prot.mdx index 53b76db403..f66c20652f 100644 --- a/documentation/docs/pages/network/cryptography/zk-nym/double-spend-prot.mdx +++ b/documentation/docs/pages/network/cryptography/zk-nym/double-spend-prot.mdx @@ -10,7 +10,7 @@ Double spend protection in the context of zk-nym is a balancing act between spee ## Offline Approach: Pros & Cons The advantages of the offline approach are manifold: - Immediate access to the Nym network upon zk-nym submission, eliminating any delays in service provisioning until payments are deposited and verified as would occur in the online approach. -- Alleviates performance strain on ingress Gateways and Quorum members, serving as a more efficient method compared to the online counterpart. By moving computationally intense work to the Quorum, this means that Gateway nodes are able to be run on less powerful machines, meaning more operators can more easily run them (and cover their costs) and thus increase the overall number and spread of Gateways around the globe. +- Reduces load on ingress Gateways and Quorum members compared to the online approach. Moving the compute-heavy work to the Quorum means Gateway nodes can run on less capable machines, so more operators can run them (and cover their costs), increasing the overall number and spread of Gateways around the globe. - Moreover, the offline approach can circumvent the potential issue of overwhelming the blockchain with the serial numbers of spent coins. However, the offline approach introduces certain limitations. @@ -18,10 +18,10 @@ However, the offline approach introduces certain limitations. - Any potential repercussions against double spenders can only be implemented once the user requests a new credential for their zk-nym Generator (aka they have to 'top up' and buy more bandwidth allowance), assuming they haven't altered their identifier (the Bech32 address). An exploitable scenario arises from these limitations: -- A malicious user purchases bandwidth and aggregates a valid zk-nym credential in the standard way, worth $10 of crypto/fiat. Subsequently, the malicious user proceeds to sell the credential to 100 users for $1 each, allowing each user to generate zk-nym tickets of 100MB from this **valid** credential. Under the offline approach, entry nodes forego double-spending checks; so long as the clients all used different ingress Gateways, all 100 users could access the network without obtaining a subscription. As bandwidth consumption is tracked locally between client and ingress node, and each zk-nym ticket is rerandomised, there is no way that ingress Gateways would know that the zk-credential used by the client has been shared with other parties. This loophole highlights the need for stringent measures to counter such potential abuses within the system, without creating either speed bottlenecks (in the case of the Online model) or impacting the anonymity of the system. We can, however, mitigate this problem without doing either of these things. +- A malicious user purchases bandwidth and aggregates a valid zk-nym credential in the standard way, worth $10 of crypto/fiat. The malicious user then sells the credential to 100 users for $1 each, allowing each user to generate zk-nym tickets of 100MB from this **valid** credential. Under the offline approach, entry nodes skip double-spending checks; so long as the clients all used different ingress Gateways, all 100 users could access the network without obtaining a subscription. As bandwidth consumption is tracked locally between client and ingress node, and each zk-nym ticket is rerandomised, there is no way that ingress Gateways would know that the zk-credential used by the client has been shared with other parties. This loophole calls for measures to counter such abuses without creating either speed bottlenecks (as in the Online model) or harming the anonymity of the system. We can mitigate this problem without doing either. ## Solution to Offline Double Spending -To efficiently prevent the fraudulent use of tickets within the Nym network, a two-tiered solution is in place that combines (1) the immediate detection of double-spending attempts at the level of individuals ingress Gateways and (2) subsequent identification and blacklisting of offending clients at the Quorum level. +To prevent fraudulent use of tickets within the Nym network, a two-tiered solution combines (1) immediate detection of double-spending attempts at individual ingress Gateways and (2) subsequent identification and blacklisting of offending clients at the Quorum level. ### Entry Node Implementation: Real-Time Ticket Validation Each spent zk-nym ticket contains as an attribute a unique serial number, which is revealed in plaintext to the respective ingress Gateway. Each Gateway has a copy of a [Bloom Filter](https://www.geeksforgeeks.org/bloom-filters-introduction-and-python-implementation/) - on receiving a ticket, it will check against its copy of a local database to check whether this serial number has already been seen. If so, it rejects the ticket as being double-spent and the client's connection request is rejected. If not, it will add the serial number to its local DB. @@ -30,7 +30,7 @@ Each spent zk-nym ticket contains as an attribute a unique serial number, which Each Gateway will periodically share their serial numbers with the Quorum and refresh their copy of the Bloom Filters from the Quorum, in order to refresh the global list shared by all ingress Gateways and the Quorum. See the step below for more on this. -> Crucially, ingress Gateways refrain from extensive computations to identify the original ticket owner, and avoids broadcasting information about the double-spending attempt to other ingress Gateways. The entry node is also not involved in any global blacklisting process of the clients. The sole purpose of this check is to swiftly identify any attempts at double-spending and add the seen ticket's serial number to the local DB cache. +> Crucially, ingress Gateways do not perform extensive computations to identify the original ticket owner, and do not broadcast information about the double-spending attempt to other ingress Gateways. The entry node is also not involved in any global blacklisting of clients. The sole purpose of this check is to quickly identify double-spending attempts and add the seen ticket's serial number to the local DB cache. ### Nym-API Implementation: Blacklisting and Penalties for Double-Spenders All Gateways periodically forward the collected tickets to the Quorum, enabling them to pinpoint and blacklist any clients who double spend. Upon receiving the tickets, the Quorum appends all the incoming serial numbers to the global list of spend zk-nym serial numbers and proceed with the identification process for any malicious users engaging in double-spending. diff --git a/documentation/docs/pages/network/cryptography/zk-nym/rerandomise.mdx b/documentation/docs/pages/network/cryptography/zk-nym/rerandomise.mdx index 797bc02d6b..da4a505e41 100644 --- a/documentation/docs/pages/network/cryptography/zk-nym/rerandomise.mdx +++ b/documentation/docs/pages/network/cryptography/zk-nym/rerandomise.mdx @@ -9,9 +9,9 @@ Each ticket will not be valid for the entire amount of data that the ticketbook ## Why a 'ticketbook', not individual 'tickets', and why not spend them all at once? -This is to account for the need for a client to change their ingress Gateway, either because the Gateway itself has gone down / is not offering the required bandwidth, or because a user might simply want to split their traffic across multiple Gateways for extra privacy. +This is to account for the need for a client to change their ingress Gateway, either because the Gateway itself has gone down or is not offering the required bandwidth, or because a user might want to split their traffic across multiple Gateways for extra privacy. -Clients are therefore not tied to a particular Gateway they have spent their entire subscription with. If an ingress Gateway goes down, or the client simply wants to use a different one, remaining tickets can be spent with any other Gateway. +Clients are therefore not tied to a particular Gateway they have spent their entire subscription with. If an ingress Gateway goes down, or the client wants to use a different one, remaining tickets can be spent with any other Gateway. Going back to the `nym-cli` tool to illustrate this; we can generate multiple unlinkable tickets from a single ticketbook aggregated from PSCs: diff --git a/documentation/docs/pages/network/cryptography/zk-nym/unlinkability.mdx b/documentation/docs/pages/network/cryptography/zk-nym/unlinkability.mdx index 3714a64255..2454ef4a3a 100644 --- a/documentation/docs/pages/network/cryptography/zk-nym/unlinkability.mdx +++ b/documentation/docs/pages/network/cryptography/zk-nym/unlinkability.mdx @@ -2,13 +2,13 @@ import { Callout } from 'nextra/components' # Unlinkability -Each time a credential is requested by an ingress Gateway to prove that a client has purchased data to send through the Mixnet the Requester's device will produce a ticket. This is a rerandomised value that is able to be verified as being legitimate (in that it was created by a valid root ticketbook) but **not linked to any other tickets**, either previously generated or to be generated in the future. This feature also allows for a single ticketbook to allow access to be split across multiple ingress Gateways / connections and [incrementally spent](./rerandomise) over time. +Each time a credential is requested by an ingress Gateway to prove that a client has purchased data to send through the Mixnet, the Requester's device produces a ticket. This is a rerandomised value that can be verified as legitimate (in that it was created by a valid root ticketbook) but is **not linked to any other tickets**, either previously generated or to be generated in the future. This also allows a single ticketbook to be split across multiple ingress Gateways or connections and [incrementally spent](./rerandomise) over time. The functionality included in the following code block examples were added to the [nym-cli tool](/developers/tools/nym-cli) for illustrative purposes only: this is not necessarily how credentials will be accessed in the future. -The numbers used in this high level overview are for illustration purposes only. The figures used in production will potentially vary. Note that individual zkNym sizes will be uniform across the Network. +The numbers used in this high level overview are for illustration only. The figures used in production may vary. Individual zkNym sizes are uniform across the Network. @@ -23,7 +23,7 @@ PAYMENT FOR TICKET 3: VfZAuVRRHekQYMvFevNAZmPPuwMAfEhTBY8TXatBysbrNXAg8euEGPpJvdbhNfQSznBb9nRSeBUSVoNTToSA6Uj5dXmJ7oE2rCB439DarLMWHWYfQNhw6yhWJhcg6bt7ebBYTs3vVeQgSB5kYuifzJF4QQmK6uJyTNPvpV1J6V8M32PBkGT3JpVB3GUGZiksETf7TaF9wAhMo2QAMxw5ZvaQVve5ea7Mane6cfb2Gx69SRff5zDfEQvKqKnyyZje4SGZgWUeHWVLhRjg4KMTJ3JcsHxEqj2k5qeGeyBbgzcuEtCpYvaytsz7nuZGJsT4Z87gB5Zq4NGuDmekuN977eRJvua2dASNWeHiAzVyvnS7ARN5cdUjjYKYiWgHaYrHGsv26WTDeiu4U3sdJMrLHGFY5ihX7f8sTZqD6Wx5AWjQNbEtKaVHymDogfLcwGCC42gQ2yhKfPUaWJ8H4yMB65YBDXGjATaUzcDmJcZKx8g31j2uTVNSFUesd5CRNEEcTNW7cSFFCishCD3T4eV9SuyZyEXAZ48pazPzc1BysBNHEXQNUEtEAZTKmpghC2pihhfDub6LnMJPo9DDdhCULCbcWbGAPc1vPekPaWvk7wrUTGwp5xoNUhQLW3MeJzMvrMSsqLdursCKB4h4Tk272WCStCPQwAKMYoxjWvMzxoUTTWCkhLKHruMtsehRnai4vhu13jbui6ji1F389gfazm4ctth2s4Yw3H3SaPtRETBfZNvZ7n5UV1MD6Q3qin92gT65iqXEi4zRN3woYcK6ZehiSvgUksdEFAUSxNMgNXKtHEYDS6kA37tn5JdBa2Ex2jLudFfhg6JBM226ZKyj65o6feYPgbJAR3jMCmQRHe6DSFb4aH895EowNMjfGUhwhmnbYB1djp7iFXxPP7575NAerhxEQ1WFnxTfoX7pu1Vc9YZb5priCAVbATCaDkECJsdedM45Vx96Jc6E5NWqD98RhMsPimVJkSfYJmRxH9qugica6WonFFb2YLvXYyhoBA1VHBcRqZJ5KHitS5AegYSoYprUfubMzcYo2hGVEQkGKAsFq6jZgCsbJoGLXt3No317vcowB5f3hqT9FjASHAzW2j8uJ9RRzX7XtrPhArwx4EyPgYzrvgG7xcenoSgQt8poa7aYky56eZTKHVUZgUEt6St32MjcivMvmNdWiAHHDc2ZxzTJHgeuCckX7n19vQ3XNLuXv9oGKNNCi8kHnT4tUnnGXNAWXWuyBgZKWUL8u3y41iW6dLYK3Pw5zfpKZTrq3q3bTLJRN5LnnUuFVnWsC3SNqa6VAAvhTGR9PzxLk8C6HeLP2AsYPpqeQwbaL3Ks6tvPdob3tQPWRBGL4uiKtNZ23tRYZGZLYFWZK7psRSZg5AETejKxztVzAuYovpVUiDq71o331tjqWWV1SzWT13Rd1uwz6nHtsjgao2863YaizKARcYr1j9MKtNfDs483yho6i7tbCRR9M4CPLqdiKEaRyVC1FP4F3sejA6nZTuAA35JWUzX6BBj7wgdypMLdMmmtcCZm3bRrF3GvJJs67U8JWRc6dnoGUDaD7rUu ``` -Now lets generate another ticket to spend either topping up once the previous one's data allowance has been used, or with another Gateway. Notice that the `ticket-index` is the same: this is generated from the same aggregated credential as the one above! +Generating another ticket, to spend either when topping up after the previous one's data allowance has been used or with another Gateway. The `ticket-index` is the same: this is generated from the same aggregated credential as the one above. ```sh ❯ ./nym-cli ecash generate-ticket --credential-storage storage.db --provider 6qidVK21zpHD298jdDa1RRpbRozP29ENVyqcSbm6hQrG --ticket-index=3 @@ -36,4 +36,4 @@ PAYMENT FOR TICKET 3: Vev3SmwWtH5vbnejX5Zzc1EcxXAgveqHpKNN8arxXaWLhFcEpdcZ6n7qr3NrQUNURWsK2AsUiX8aSiGSjMPEY3iDE3aDYnjYERVow8RKUmQiYSKvz7v9cEJxt97JAHBfu9WYNHXTnLFSJwWuFtBdzY5dzPdzGckFenGCysa1ZBHGADHChDVXKoPHXxpn5qyJxmi48coUQDptR64QgkCeQ8RRZ396Lxw2NKFSjqavCMMDVm3g1rW7cYyPanBhkoAUzPU9KXX1rtmhD6F9gV89mGZ8fm7ByDuKuYU28seLQ7GkVKkhNeRW9XxbjSiyscTnMUzJ24R5VbSdr141BaquUHezdUTzmA2EjAtcyyiVrCMV13cc96CRbMXENP2soUzckFnh1qPnrfKCvX4JYkztq7UgPT2mZEnSTDW4C6Z2NVCNBPNLqUSYrU4id8Jzcp1mBxqJjdYcQ7P5fWJbT5Q9NAq44PCgfXpsUkNoj35QVQvKXKLb5oNGqnua5YC1WBPcENcpS7ZPWpk2hwe8VK4gNgnwQtWH2RPmWbvBREAV97vS1vKNHJyry9sD2PiMJGSmBnb1bKsGxR9UQN3YvRsdGHzyJHzAMTzxbFJBqMPmxjSHJR4UdwzhB81Ludu1RAffTvecWFxmWH5bNymCQjw3wey7Uequcxgyy8KAWYDzvHGwCZQbHQXghsYREiqquZWaa8hX3iTNBFUtEk8PRVT78MoFNdeBWNjsLr8zyZ5EGnf4kqmw3a91g5p5vywf6e3LgMu19VHjPSNtKMNXiatkPEVjsCuCppmV4sB7FsdKKWcMUSWLsdmrDBg9PStHr7NaJRzLL5E91gvysmB36Nob9cHeHSZj3wM4NVVjFfZeRqQf4bi7ahfXjeeBetgDpqx7JcbU6tTN4JpcGUpp7fp4MhTq7MeVQMLweGUVLqewKgAGzCvEmrK6dzLd3U1P9vkAAVZ3cCAKUywnHGxoxDeEfexP1g1EqJLtKNZVKPf7hSMWqGhoQ36K7y5GnyZ5YhQ7jcDME9orm5w4StoxoDdCPcjbakKG7UaTHuhd7tU1mUffXcEvVerkXoQK9SEaKvGks21RBhW86aHUzJWVbkiDzdaqjJWbmzLV8FKvNxNyzucoH2rq8LiHRMZfV1H3SkVSa4j2Ktw7ZGoQfdj8DgekxXSR2nHPfhybzKYXTBqFo2ACisxkjR4rXr9Xo6eYywQhQ1MP6aYgYCAXFGHPoFf7kx7Jns5sWvHRBdaMF65zeFF2m5NDuMWETtLgFfsyNgR84vfSqTfzj2gsUykRei7q9N4LKmiDwBALTAEcTvZpLtXBjc8JaB9PUeBw7DoSiSK376sGrQ9F6ZGTngXACNz1TbvYhtau4bDa6KC2Qn7wmoyrphpn7TtM1jdwGBxLcaEEWZKQHvWVfTyL2itjqnrcAZkxYdCj56oQYwpWfKQk3zJEUA6SYHqyJjaLNVK6u25j7969EWjdpTsJ8qSsZgXi3T7dQqiwintZbUUUKRq7egN1SGVnA6Wup91uKrYUWEWMqVu4g8ipmRsLD9iXHHr3yA21Cka7pqk1FxR9BFTAnkk1 ``` -These are both generated by the _same_ underlying ticketbook and used in a way that they cannot be tied to each other. An ingress Gateway might (for instance) get 100 connection requests from 100 Nym clients, each validated with a ticket. It has no way of knowing whether these are all from the same single subscription, or 100 different ones. +These are both generated by the same underlying ticketbook and used in a way that they cannot be tied to each other. An ingress Gateway might (for instance) get 100 connection requests from 100 Nym clients, each validated with a ticket. It has no way of knowing whether these are all from the same single subscription, or 100 different ones. diff --git a/documentation/docs/pages/network/cryptography/zk-nym/zk-nym-overview.mdx b/documentation/docs/pages/network/cryptography/zk-nym/zk-nym-overview.mdx index 793c5a9281..da4b3cdd35 100644 --- a/documentation/docs/pages/network/cryptography/zk-nym/zk-nym-overview.mdx +++ b/documentation/docs/pages/network/cryptography/zk-nym/zk-nym-overview.mdx @@ -3,7 +3,7 @@ import { Callout } from 'nextra/components' # Generating and using zk-nym anonymous credentials - zk-nyms are already used in production by [NymVPN](https://nymvpn.com) to unlink subscription payments from network activity. The entire credential lifecycle described on this page (key generation, issuance, spending) happens transparently within the NymVPN application. SDK integrations currently connect to the Mixnet without requiring credentials. + zk-nyms are already used in production by [NymVPN](https://nymvpn.com) to unlink subscription payments from network activity. The entire credential lifecycle described on this page (key generation, issuance, spending) happens inside the NymVPN application without user involvement. SDK integrations currently connect to the Mixnet without requiring credentials. Generation of zk-nyms involves the following actors / pieces of infrastructure: @@ -16,13 +16,13 @@ Generation happens in 3 distinct stages: - Issue credential - Generate unlinkable zk-nyms for Nym Network access -From the Requester's perspective this happens transparently, producing an unlinkable, rerandomisable anonymous proof-of-payment credential (a zk-nym) that grants Mixnet access without linking usage to payment information. A single credential can be split into multiple smaller zk-nyms, so a Requester purchases bandwidth in bulk and spends it incrementally across different ingress Gateways as needed. +From the Requester's perspective this happens without user involvement, producing an unlinkable, rerandomisable anonymous proof-of-payment credential (a zk-nym) that grants Mixnet access without linking usage to payment information. A single credential can be split into multiple smaller zk-nyms, so a Requester purchases bandwidth in bulk and spends it incrementally across different ingress Gateways as needed. ## Key Generation & Payment -- First, a Cosmos [Bech32 address](https://docs.cosmos.network/sdk/latest/guides/reference/bech32#performance-address-caching) is created for the Requester. This is used to identify themselves when interacting with the OrderAPI via signed authentication tokens. **This is the only identity that the OrderAPI is able to see, and is not able to link this to the zk-nyms that will be generated.** This identity never leaves the Requester's device and there is no email or any personal details needed for signup. If a Requester is simply 'topping up' their subscription, the creation of the address is skipped as it already exists. +- First, a Cosmos [Bech32 address](https://docs.cosmos.network/sdk/latest/guides/reference/bech32#performance-address-caching) is created for the Requester. This is used to identify themselves when interacting with the OrderAPI via signed authentication tokens. **This is the only identity the OrderAPI sees, and it cannot link this to the zk-nyms that will be generated.** This identity never leaves the Requester's device and there is no email or personal details needed for signup. If a Requester is 'topping up' their subscription, the creation of the address is skipped as it already exists. - The Requester also generates an ed25519 keypair: this is used to identify and authenticate them in the case of using zk-nyms across several devices as an individual user. However, **this is never used in the clear**: these keys are used as private attribute values within generated credentials which are verified via zero-knowledge and not publicly exposed. -- The Requester can then interact with various payment backends to pay for their zk-nyms with crypto, fiat options, or natively with NYM tokens. +- The Requester can then interact with payment backends to pay for their zk-nyms with crypto, fiat options, or natively with NYM tokens. - Payment options will trigger the OrderAPI. This will: - Create a swap for `` to `NYM` tokens. - Deposit these tokens with the NymAPI Quorum via a CosmWasm smart contract deployed on the Nyx blockchain. diff --git a/documentation/docs/pages/network/dvpn-mode.md b/documentation/docs/pages/network/dvpn-mode.md index 52454d84ce..e93c8cc70b 100644 --- a/documentation/docs/pages/network/dvpn-mode.md +++ b/documentation/docs/pages/network/dvpn-mode.md @@ -1,6 +1,6 @@ --- title: "dVPN Mode" -description: "How Nym's decentralized VPN mode routes traffic through two independent gateways, splitting trust so no single operator sees both your identity and destination." +description: "How Nym's decentralised VPN mode routes traffic through two independent gateways, splitting trust so no single operator sees both your identity and destination." schemaType: "TechArticle" section: "Network" lastUpdated: "2026-03-15" @@ -8,7 +8,7 @@ lastUpdated: "2026-03-15" # dVPN Mode -dVPN mode is a 2-hop decentralized VPN available through [NymVPN](https://nymvpn.com). Traffic is routed through two independent gateways rather than a single VPN provider's server, so no single operator ever sees both who you are and what you're doing. +dVPN mode is a 2-hop decentralised VPN available through [NymVPN](https://nymvpn.com). Traffic is routed through two independent gateways rather than a single VPN provider's server, so no single operator ever sees both who you are and what you're doing. ## How it works diff --git a/documentation/docs/pages/network/dvpn-mode/protocol.mdx b/documentation/docs/pages/network/dvpn-mode/protocol.mdx index 92cd9f9bf2..047137cb75 100644 --- a/documentation/docs/pages/network/dvpn-mode/protocol.mdx +++ b/documentation/docs/pages/network/dvpn-mode/protocol.mdx @@ -1,6 +1,6 @@ --- title: "dVPN Protocol Stack and Encryption" -description: "Technical details of Nym dVPN mode's protocol layers: nested WireGuard tunnels, split-knowledge architecture, and packet format tradeoffs." +description: "Technical details of Nym dVPN mode's protocol layers: nested WireGuard tunnels, split-knowledge architecture, and packet format trade-offs." schemaType: "TechArticle" section: "Network" lastUpdated: "2026-04-07" @@ -8,11 +8,9 @@ lastUpdated: "2026-04-07" # dVPN Protocol -import { Callout } from 'nextra/components' +import { LewesPending } from '../../../components/lewes-pending' - -Cryptographic details on this page will be updated for the Lewes Protocol release. For the current algorithm overview, see the [Nym Trust Center: Cryptography](https://nym.com/trust-center/cryptography). - + This page covers the technical details of dVPN mode's protocol stack and encryption. @@ -40,7 +38,7 @@ Both tunnels use standard WireGuard cryptography: Curve25519 for key exchange, C ## Packet format -dVPN mode uses standard WireGuard packet framing: packets are not padded to a uniform size. Packet sizes may vary and could in principle leak information about content types (video streams have different size patterns than text messages). This is a deliberate tradeoff: uniform padding would add overhead and reduce throughput, which conflicts with dVPN mode's goal of low-latency, high-throughput connectivity. For uniform packet sizes, use [mixnet mode](/network/mixnet-mode), which wraps all traffic in fixed-size Sphinx packets. +dVPN mode uses standard WireGuard packet framing: packets are not padded to a uniform size. Packet sizes may vary and could in principle leak information about content types (video streams have different size patterns than text messages). This is a deliberate trade-off: uniform padding would add overhead and reduce throughput, which conflicts with dVPN mode's goal of low-latency, high-throughput connectivity. For uniform packet sizes, use [mixnet mode](/network/mixnet-mode), which wraps all traffic in fixed-size Sphinx packets. ## Connection lifecycle @@ -60,4 +58,6 @@ Replay protection comes from WireGuard's counter-based mechanism and from zk-nym dVPN mode shares infrastructure with mixnet mode. Both use the same Entry and Exit Gateways and the same credential system. The difference is in how traffic is handled: mixnet mode routes through three additional Mix Node layers with delays and cover traffic using fixed-size [Sphinx packets](/network/cryptography/sphinx), while dVPN mode routes directly between gateways using WireGuard. The two modes are distinguishable at the protocol level due to their different packet formats and traffic patterns. +In anonymous (5-hop) mode, NymVPN routes traffic through the full mixnet to the Exit Gateway's [IP Packet Router](/network/infrastructure/exit-services#ip-packet-router), which tunnels raw IP packets to the internet. See [Exit Gateway Services](/network/infrastructure/exit-services) for how the IPR and Network Requester work. + This shared infrastructure means improvements to Gateways and credentials benefit both modes. diff --git a/documentation/docs/pages/network/index.md b/documentation/docs/pages/network/index.md index 102056f7e9..ee3fb02e4d 100644 --- a/documentation/docs/pages/network/index.md +++ b/documentation/docs/pages/network/index.md @@ -8,7 +8,7 @@ lastUpdated: "2026-02-11" # The Nym Network -The Nym Network is decentralized privacy infrastructure that protects against **network-level** surveillance. Unlike tools that focus on encrypting message content, Nym protects the metadata surrounding communication: who talks to whom, when, how often, and how much. This metadata is sufficient for observers to map relationships and build behavioural profiles even without access to any message content. See [The Privacy Problem](/network/overview/privacy-problem) for a fuller treatment. +The Nym Network is decentralised privacy infrastructure that protects against **network-level** surveillance. Unlike tools that focus on encrypting message content, Nym protects the metadata surrounding communication: who talks to whom, when, how often, and how much. This metadata is sufficient for observers to map relationships and build behavioural profiles even without access to any message content. See [The Privacy Problem](/network/overview/privacy-problem) for a fuller treatment. Nym offers two operating modes with different privacy/performance trade-offs, both available through [NymVPN](https://nymvpn.com). Developers can also integrate Mixnet mode directly via the [Nym SDKs](/developers). See [Choosing a Mode](/network/overview/choosing-a-mode) for guidance on which fits a given threat model. @@ -26,7 +26,7 @@ The [Nym SDKs](/developers) allow developers to embed mixnet functionality direc ## Paying for privacy without losing it -A fundamental weakness of traditional VPNs is that payment records can deanonymize users, since most providers link sessions to account IDs. Nym addresses this with **zk-nyms**: zero-knowledge anonymous credentials that prove payment without revealing any other information. Each credential covers a small chunk of bandwidth and is unlinkable to any other. +A fundamental weakness of traditional VPNs is that payment records can deanonymise users, since most providers link sessions to account IDs. Nym addresses this with **zk-nyms**: zero-knowledge anonymous credentials that prove payment without revealing any other information. Each credential covers a small chunk of bandwidth and is unlinkable to any other. When you pay for NymVPN, your payment is converted into a credential that can be split and re-randomized. Each Gateway connection uses a fresh, unlinkable proof; the Gateway verifies that you have paid without learning who you are. Your subscription cannot be linked to your network activity, even by infrastructure operators. diff --git a/documentation/docs/pages/network/infrastructure.md b/documentation/docs/pages/network/infrastructure.md index 479cf22027..c44e7cabe7 100644 --- a/documentation/docs/pages/network/infrastructure.md +++ b/documentation/docs/pages/network/infrastructure.md @@ -1,6 +1,6 @@ --- title: "Nym Network Infrastructure" -description: "Overview of the Nym Network's decentralized infrastructure: independently operated nodes coordinated by the Nyx blockchain for routing, key management, and credential issuance." +description: "Overview of the Nym Network's decentralised infrastructure: independently operated nodes coordinated by the Nyx blockchain for routing, key management, and credential issuance." schemaType: "TechArticle" section: "Network" lastUpdated: "2026-03-15" @@ -8,7 +8,7 @@ lastUpdated: "2026-03-15" # Infrastructure -The Nym Network runs on decentralized infrastructure: a set of independently operated nodes coordinated by the Nyx blockchain, where no single party controls routing, key management, or credential issuance. +The Nym Network runs on decentralised infrastructure: a set of independently operated nodes coordinated by the Nyx blockchain, where no single party controls routing, key management, or credential issuance. ## In this section diff --git a/documentation/docs/pages/network/infrastructure/_meta.json b/documentation/docs/pages/network/infrastructure/_meta.json index d8114f2306..5a13c8a080 100644 --- a/documentation/docs/pages/network/infrastructure/_meta.json +++ b/documentation/docs/pages/network/infrastructure/_meta.json @@ -1,4 +1,5 @@ { "nyx": "Nyx Blockchain", - "nym-nodes": "Nym Nodes" + "nym-nodes": "Nym Nodes", + "exit-services": "Exit Gateway Services" } diff --git a/documentation/docs/pages/network/infrastructure/exit-services.mdx b/documentation/docs/pages/network/infrastructure/exit-services.mdx new file mode 100644 index 0000000000..73b2c62c6a --- /dev/null +++ b/documentation/docs/pages/network/infrastructure/exit-services.mdx @@ -0,0 +1,84 @@ +--- +title: "Exit Gateway Services: Network Requester & IP Packet Router" +description: "The two proxy services running on Nym Exit Gateways: the Network Requester (SOCKS proxy) and the IP Packet Router (raw IP tunneling). How they work, what they see, and who uses them." +schemaType: "TechArticle" +section: "Network" +lastUpdated: "2026-04-15" +--- + +# Exit Gateway Services + +import { Callout } from 'nextra/components' + +Exit Gateways are where traffic leaves the Nym network and reaches the wider internet. Each Exit Gateway runs two distinct proxy services that handle different kinds of outbound traffic: + +- **Network Requester (NR)**, an application-layer SOCKS proxy +- **IP Packet Router (IPR)**, a raw IP tunnel with address allocation + +Both services run on every Exit Gateway. Which one handles your traffic depends on how you connect. + +## Network Requester + +The Network Requester is a SOCKS4/4a/5 proxy. Clients send SOCKS-formatted requests through the mixnet, and the NR makes the corresponding connection on their behalf: resolving hostnames, opening TCP connections, and relaying data. + +```text +Client → Entry Gateway → Mixnodes1..3 → Exit Gateway (NR) → SOCKS connect → destination + ← relay response ← +``` + +Because it operates at the application layer, the NR: +- Resolves DNS on behalf of the client (the client sends hostnames, not IPs) +- Opens individual TCP connections per SOCKS request +- Can enforce allow/deny lists on destination hosts and ports +- Sees the destination hostname and port, but not the contents if TLS is used + +**Used by:** the [SDK's SOCKS client](/developers/rust/mixnet), [standalone SOCKS5 client](/developers/clients/socks5), and [mixFetch](/developers/mix-fetch) (which wraps SOCKS requests in a browser-friendly `fetch` API). + +## IP Packet Router + +The IP Packet Router operates at the IP layer. Instead of proxying individual connections, it allocates a virtual IP address to the client and routes raw IP packets between the client and the internet, functioning as a tunnel endpoint. + +```text +Client → Entry Gateway → Mixnodes1..3 → Exit Gateway (IPR) → raw IP packets → destination + ← raw IP packets ← +``` + +On connection, the IPR: +1. Allocates an IPv4/IPv6 address pair to the client +2. Accepts raw IP packets (TCP, UDP, or any IP protocol) from the client via the mixnet +3. Sends them to the internet from the gateway's own IP address +4. Routes response packets back through the mixnet to the client + +Because it operates at the IP layer, the IPR: +- Does not resolve DNS; the client handles its own DNS (either via clearnet or by sending DNS queries as UDP packets through the tunnel) +- Handles any IP protocol: TCP, UDP, ICMP, etc. +- Sees raw IP packets, including destination IPs and ports +- Does not see contents if the client uses TLS or another encryption layer + + +In both services, traffic between the Exit Gateway and the destination travels over the public internet, exactly as it would from any other server. The mixnet protects sender anonymity (the destination sees the gateway's IP, not yours), but does not encrypt the payload past the gateway. Use TLS or another application-layer cipher to protect payload confidentiality, just as you would on a direct connection. + + +**Used by:** [NymVPN anonymous mode](/network/dvpn-mode/protocol) (5-hop mixnet routing to the IPR), and [`smolmix`](/developers/smolmix) (programmatic `TcpStream`/`UdpSocket` access to the IPR via the Rust SDK). + +## Comparison + +| | Network Requester | IP Packet Router | +|---|---|---| +| **Layer** | Application (SOCKS) | IP (raw packets) | +| **Protocols** | TCP only | TCP, UDP, any IP protocol | +| **DNS** | Resolved by the NR | Client resolves its own | +| **Client gets** | Proxied connections | An allocated IP address | +| **Connection model** | Per-request | Persistent tunnel | +| **Used by** | SDK SOCKS client, mixFetch | NymVPN (anonymous mode), smolmix | + +## Trust model + +Both services share the same fundamental trust property: **the Exit Gateway can see destinations but not senders.** The mixnet's layered encryption ensures that the Exit Gateway cannot determine who sent a given packet; it only knows where it's going. + +Specifically, the Exit Gateway: +- **Can see:** destination IP/hostname, destination port, unencrypted payload content, traffic volume and timing at the exit hop +- **Cannot see:** the sender's IP address, the sender's Nym address, which Entry Gateway the traffic entered through +- **Cannot determine:** the linkage between different requests from the same sender (unless the payload itself contains identifying information) + +The sender's identity is protected by the mixnet's 5-hop routing, Sphinx encryption, cover traffic, and packet mixing. The Exit Gateway is the last hop: it decrypts the final Sphinx layer and sees the destination, but the chain of Mix Nodes between Entry and Exit has destroyed any timing or ordering correlation. diff --git a/documentation/docs/pages/network/infrastructure/nym-nodes.mdx b/documentation/docs/pages/network/infrastructure/nym-nodes.mdx index 59cfbe2bf5..0228b8083d 100644 --- a/documentation/docs/pages/network/infrastructure/nym-nodes.mdx +++ b/documentation/docs/pages/network/infrastructure/nym-nodes.mdx @@ -16,11 +16,11 @@ To run a node, see the [Operator Documentation](/operators/introduction). **Mix Nodes** form the three mixing layers that provide core privacy. They receive Sphinx packets, remove one encryption layer, verify integrity, apply a random delay, and forward to the next hop. Mix Nodes cannot determine their position in the route and cannot link incoming packets to outgoing packets. -**Exit Gateways** handle traffic leaving the mixnet. They communicate with external internet services on behalf of users and return responses through the network. Exit Gateways can see destination addresses but cannot identify the original sender. +**Exit Gateways** handle traffic leaving the mixnet. They run two proxy services: the [Network Requester](/network/infrastructure/exit-services#network-requester) (a SOCKS proxy for application-layer requests) and the [IP Packet Router](/network/infrastructure/exit-services#ip-packet-router) (a raw IP tunnel used by NymVPN and smolmix). Exit Gateways can see destination addresses but cannot identify the original sender. See [Exit Gateway Services](/network/infrastructure/exit-services) for details. ## Unified binary -The various components were originally separate binaries but have been consolidated into a single `nym-node` binary where the role is specified at runtime. This simplifies operation and makes configuration consistent across roles. +These components were originally separate binaries but have been consolidated into a single `nym-node` binary where the role is specified at runtime. This simplifies operation and makes configuration consistent across roles. In the future, nodes will automatically switch modes based on network conditions. Operators won't need to manually set whether a node is a Gateway or Mix Node; the network will assign modes dynamically each epoch. diff --git a/documentation/docs/pages/network/mixnet-mode.mdx b/documentation/docs/pages/network/mixnet-mode.mdx index c103ea7828..c8070f361b 100644 --- a/documentation/docs/pages/network/mixnet-mode.mdx +++ b/documentation/docs/pages/network/mixnet-mode.mdx @@ -8,7 +8,7 @@ lastUpdated: "2026-03-15" # Mixnet Mode -import { Callout } from 'nextra/components' +import { LewesPending } from '../../components/lewes-pending' Mixnet mode routes traffic through 5 hops: an Entry Gateway, three layers of Mix Nodes, and an Exit Gateway. Each mixing layer adds random delays, reorders packets, and injects cover traffic. Available through [NymVPN](https://nymvpn.com) and the [Nym SDKs](/developers). @@ -32,9 +32,7 @@ Each Mix Node strips one layer of [Sphinx](/network/cryptography/sphinx) encrypt The three mixing layers add additional latency. This is acceptable for messaging, file transfers, and most API calls, but unsuitable for real-time applications like video calling. For those, [dVPN mode](/network/dvpn-mode) is more appropriate. - -Updated latency measurements will be published after the Lewes Protocol release. - + ## Further reading diff --git a/documentation/docs/pages/network/mixnet-mode/anonymous-replies.mdx b/documentation/docs/pages/network/mixnet-mode/anonymous-replies.mdx index 2988841e92..209a23e30d 100644 --- a/documentation/docs/pages/network/mixnet-mode/anonymous-replies.mdx +++ b/documentation/docs/pages/network/mixnet-mode/anonymous-replies.mdx @@ -28,7 +28,7 @@ SURB validity is tied to key rotation. Node keys rotate on an odd/even schedule ## SURB replenishment -If Bob's reply is larger than the available SURBs can carry, he uses one SURB to request more. Alice receives the request, generates additional SURBs, and sends them to Bob. This adds round-trip latency but ensures conversations can continue regardless of reply size. +If Bob's reply is larger than the available SURBs can carry, he uses one SURB to request more. Alice receives the request, generates additional SURBs, and sends them to Bob. This adds round-trip latency but lets conversations continue regardless of reply size. ```mermaid --- @@ -53,7 +53,7 @@ sequenceDiagram ## Sender tags -For sessions with multiple messages, Alice includes a randomly generated sender tag with her SURBs. This helps Bob organize SURBs from multiple conversations without revealing anything about Alice's identity; the tag is random and unlinkable to her address. +For sessions with multiple messages, Alice includes a randomly generated sender tag with her SURBs. This helps Bob organise SURBs from multiple conversations without revealing anything about Alice's identity; the tag is random and unlinkable to her address. ## Security considerations diff --git a/documentation/docs/pages/network/mixnet-mode/cover-traffic.md b/documentation/docs/pages/network/mixnet-mode/cover-traffic.md index ecb33efc79..2ee27cfc2d 100644 --- a/documentation/docs/pages/network/mixnet-mode/cover-traffic.md +++ b/documentation/docs/pages/network/mixnet-mode/cover-traffic.md @@ -43,7 +43,7 @@ Mix nodes also generate their own cover traffic, ensuring minimum traffic levels Traffic follows a Poisson process with a configurable rate parameter. Inter-packet times are exponentially distributed: random, but with a known average rate. This distribution provides maximum entropy (uncertainty) for a given mean rate, which translates to optimal privacy properties. -## Tradeoffs +## Trade-offs More cover traffic provides better unobservability but uses more bandwidth and, when zk-nyms are enabled, more credential value. Less cover traffic reduces costs but may allow some inference about activity patterns. @@ -51,4 +51,4 @@ The default parameters balance privacy and resource usage. Applications with hei ## What cover traffic defeats -Cover traffic prevents volume analysis (how much you communicate), timing analysis (when you communicate), and behavioral profiling (your communication patterns over time). Combined with packet mixing, it ensures that even an adversary watching the entire network cannot learn about your communication behavior with currently known methods. +Cover traffic prevents volume analysis (how much you communicate), timing analysis (when you communicate), and behavioural profiling (your communication patterns over time). Combined with packet mixing, this means that even an adversary watching the entire network cannot learn about your communication behaviour with currently known methods. diff --git a/documentation/docs/pages/network/mixnet-mode/loopix.md b/documentation/docs/pages/network/mixnet-mode/loopix.md index 1aace31e4a..3369acb1d1 100644 --- a/documentation/docs/pages/network/mixnet-mode/loopix.md +++ b/documentation/docs/pages/network/mixnet-mode/loopix.md @@ -8,7 +8,7 @@ lastUpdated: "2026-03-15" # Loopix Design -The Nym mixnet is based on the [Loopix](https://arxiv.org/pdf/1703.00536) design, with modifications for decentralized operation and economic incentives. +The Nym mixnet is based on the [Loopix](https://arxiv.org/pdf/1703.00536) design, with modifications for decentralised operation and economic incentives. ## The insight @@ -38,7 +38,7 @@ Loop traffic ensures minimum anonymity even when few users are active, hides whe ## Nym's modifications -The Nym implementation extends Loopix in several ways: replacing the trusted directory server with the Nyx blockchain for decentralized topology management, incentivising node operation with NYM token rewards rather than relying on volunteers, and adding zk-nyms for privacy-preserving payment, which the original academic design did not address. +The Nym implementation extends Loopix in several ways: replacing the trusted directory server with the Nyx blockchain for decentralised topology management, incentivising node operation with NYM token rewards rather than relying on volunteers, and adding zk-nyms for privacy-preserving payment, which the original academic design did not address. ## Security guarantees diff --git a/documentation/docs/pages/network/mixnet-mode/mixing.mdx b/documentation/docs/pages/network/mixnet-mode/mixing.mdx index 1cf9890c73..f1ddd6b000 100644 --- a/documentation/docs/pages/network/mixnet-mode/mixing.mdx +++ b/documentation/docs/pages/network/mixnet-mode/mixing.mdx @@ -8,7 +8,7 @@ lastUpdated: "2026-03-15" # Packet Mixing -import { Callout } from 'nextra/components' +import { LewesPending } from '../../../components/lewes-pending' Packet mixing breaks timing correlations by adding random delays at each Mix Node. It's the core mechanism that prevents traffic analysis. @@ -50,9 +50,7 @@ With three Mix Node layers, each applying random delays, the overall effect is t These delays account for the additional latency of mixnet mode relative to dVPN mode. - -Updated latency measurements will be published after the Lewes Protocol release. - + ## Combined with cover traffic diff --git a/documentation/docs/pages/network/overview.md b/documentation/docs/pages/network/overview.md index 45f76d5005..99b43c974f 100644 --- a/documentation/docs/pages/network/overview.md +++ b/documentation/docs/pages/network/overview.md @@ -18,4 +18,4 @@ The Nym Network is a privacy infrastructure that protects metadata: not just mes ## Network Components -All traffic-routing infrastructure runs on [Nym Nodes](/network/infrastructure/nym-nodes), a single binary that operators configure to serve as an Entry Gateway, Mix Node, or Exit Gateway depending on their setup. Network coordination, token bonding, and the distributed credential system all live on the [Nyx blockchain](/network/infrastructure/nyx), a Cosmos SDK chain whose on-chain topology registry eliminates the need for a centralised directory server. +All traffic-routing infrastructure runs on [Nym Nodes](/network/infrastructure/nym-nodes), a single binary that operators configure to serve as an Entry Gateway, Mix Node, or Exit Gateway depending on their setup. Network coordination, token bonding, and the distributed credential system all live on the [Nyx blockchain](/network/infrastructure/nyx), a Cosmos SDK chain whose on-chain topology registry removes the need for a centralised directory server. diff --git a/documentation/docs/pages/network/overview/choosing-a-mode.md b/documentation/docs/pages/network/overview/choosing-a-mode.md index b29caa332a..9523b3c1aa 100644 --- a/documentation/docs/pages/network/overview/choosing-a-mode.md +++ b/documentation/docs/pages/network/overview/choosing-a-mode.md @@ -28,7 +28,7 @@ Both modes run on the same Nym infrastructure but defend against different threa - Latency matters: browsing, streaming, downloads, video calls - Your concern is ISPs, advertisers, and websites tracking you, not nation-state surveillance -- You want decentralized trust and payment privacy without the overhead of mixing +- You want decentralised trust and payment privacy without the overhead of mixing ## Use Mixnet mode when diff --git a/documentation/docs/pages/network/overview/comparisons.md b/documentation/docs/pages/network/overview/comparisons.md index 5fec66afbe..e27e503200 100644 --- a/documentation/docs/pages/network/overview/comparisons.md +++ b/documentation/docs/pages/network/overview/comparisons.md @@ -24,7 +24,7 @@ Nym's mixnet mode goes further by adding timing obfuscation and cover traffic, w Nym's mixnet addresses this by adding random delays at each Mix Node to break timing correlations, cover traffic so observers can't tell when real communication is occurring, per-packet routing rather than Tor's per-session circuits (so there's no long-lived path to observe), and a blockchain-based topology instead of Tor's centralised directory authority. -The tradeoff is latency: Tor is faster because it doesn't add mixing delays, so it may be a better fit for general browsing where timing protection isn't needed. Nym's mixnet is designed for threat models where the adversary can perform traffic analysis. +The trade-off is latency: Tor is faster because it doesn't add mixing delays, so it may be a better fit for general browsing where timing protection isn't needed. Nym's mixnet is designed for threat models where the adversary can perform traffic analysis. ## Nym vs I2P diff --git a/documentation/docs/pages/network/reference/acks.mdx b/documentation/docs/pages/network/reference/acks.mdx index b35a4be1be..1a4a490600 100644 --- a/documentation/docs/pages/network/reference/acks.mdx +++ b/documentation/docs/pages/network/reference/acks.mdx @@ -6,7 +6,7 @@ section: "Network" lastUpdated: "2026-03-15" --- -import { Callout } from 'nextra/components' +import { LewesPending } from '../../../components/lewes-pending' # Acknowledgements @@ -28,8 +28,6 @@ Acknowledgements operate hop-by-hop between adjacent nodes. They confirm that pa ## Implementation -This is handled entirely by the Nym binaries. Developers and operators don't need to implement or configure acknowledgements; the system handles packet loss transparently. +This is handled entirely by the Nym binaries. Developers and operators don't need to implement or configure acknowledgements; the system handles packet loss without any application involvement. - -**Lewes Protocol:** The upcoming Lewes release will introduce changes to how acknowledgements are handled. The current hop-by-hop ACK mechanism described above may be revised as part of broader protocol improvements. Details will be documented here once the changes are finalised. - + diff --git a/documentation/docs/pages/network/reference/epochs.md b/documentation/docs/pages/network/reference/epochs.md index 0c7e55d442..7375c6c7bd 100644 --- a/documentation/docs/pages/network/reference/epochs.md +++ b/documentation/docs/pages/network/reference/epochs.md @@ -1,6 +1,6 @@ --- title: "Epochs in the Nym Network" -description: "How epochs organize time in the Nym Network: reward distribution, topology reshuffling, SURB validity windows, and future automatic role assignment." +description: "How epochs organise time in the Nym Network: reward distribution, topology reshuffling, SURB validity windows, and future automatic role assignment." schemaType: "TechArticle" section: "Network" lastUpdated: "2026-03-15" @@ -8,11 +8,11 @@ lastUpdated: "2026-03-15" # Epochs -Time in the Nym Network is organized into epochs: discrete periods during which certain network operations occur. The current epoch length is one hour. +Time in the Nym Network is organised into epochs: discrete periods during which certain network operations occur. The current epoch length is one hour. ## What happens at epoch boundaries -**Reward distribution** calculates performance metrics for each node and distributes NYM token rewards based on routing reliability and uptime, ensuring that nodes successfully forwarding packets earn more than those with poor performance. +**Reward distribution** calculates performance metrics for each node and distributes NYM token rewards based on routing reliability and uptime, so that nodes successfully forwarding packets earn more than those with poor performance. **Topology rerandomization** shuffles the arrangement of nodes in each layer. This prevents long-term route prediction attacks and limits the damage from any compromised nodes. Nodes may also enter or leave the active set based on uptime monitoring and stake changes. diff --git a/documentation/docs/pages/operators/changelog.mdx b/documentation/docs/pages/operators/changelog.mdx index 4cfb438764..16f72c28ca 100644 --- a/documentation/docs/pages/operators/changelog.mdx +++ b/documentation/docs/pages/operators/changelog.mdx @@ -16,6 +16,8 @@ import { Steps } from 'nextra/components'; import AbuseResponse from 'components/operators/templates/dmca_response.md'; import OperatorIntroduction from 'components/operators/templates/provider_introduction.md'; import QuicDeploymentSteps from 'components/operators/snippets/quic-bridge-deployment-script-setup.mdx'; +import NTMExplanation from 'components/operators/snippets/ntm-accordion-explanation.mdx'; + export const TestingSteps = () => ( @@ -48,7 +50,6 @@ export const LoadEndpointInfo = () => ( ); - # Changelog This page displays a full list of all the changes during our release cycle from `v2024.3-eclipse` onward. Operators can find here the newest updates together with links to relevant documentation. The list is sorted so that the newest changes appear first. @@ -57,6 +58,77 @@ This page displays a full list of all the changes during our release cycle from +## Operators Tools + +As the upcoming platform release has been posponed, we prepared this indenpendent release of tools and updates important for node operators. + + +[**Security steps required**](/operators/troubleshooting/vps-isp#security-patch-copyfail--dirtyfrag): Several critical [Linux kernel vulnerabilities](https://ubuntu.com/blog/copy-fail-vulnerability-fixes-available) had been disclosed. Check out your servers and if needed apply required mitigations! + + +- [**NIP-11 - NTM updated: Telegram voice and video call works now!**](https://github.com/nymtech/nym/pull/6807) Please re-run [Nym network tunnel manager](https://raw.githubusercontent.com/nymtech/nym/refs/heads/develop/scripts/nym-node-setup/network-tunnel-manager.sh) (NTM) on your hosting servers: + +
+ Manual steps, + <>Ansible, + ]} defaultIndex="0"> + + +- Get the latest version of NTM + +```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 +``` + +- Run NTM (Standard SSH on port 22 (default)): +```sh +./network-tunnel-manager.sh complete_networking_configuration +``` + +- Run NTM on non-standard SSH port: +```sh +HOST_SSH_PORT= ./network-tunnel-manager.sh complete_networking_configuration + +# example - replace 2222 with your actual port: +# HOST_SSH_PORT=2222 ./network-tunnel-manager.sh complete_networking_configuration +``` + + + + + + +- Navigate to your Nym node Ansible directory with all playbooks (`/playbooks`) + +- For safety start only with one node: +```sh +ansible-playbook deploy.yml -t ntm -l node1 +``` + +- Check if everything worked smooth and the node works on VPN + +- Run on all machines in your inventory +```sh +ansible-playbook deploy.yml -t ntm +``` + + + + + +
+ +- [**New docs**](https://github.com/nymtech/nym/pull/6716): Max's leg work on [exit services documentation](/network/infrastructure/exit-services), [`Mixfetch`](/developers/mix-fetch) and [`smolmix`](/developers/smolmix) + +### Operators UX Improvements + +- [NTM: Split IPv4 / IPv6 uplinks](https://github.com/nymtech/nym/pull/6640): Now NTM can work with different uplink interfaces for IPv4 and IPv6 + +- [Nym Node CLI: Split IPv4 / IPv6 uplinks](https://github.com/nymtech/nym/pull/6743): `nym-node-cli.py` uplinks sync up with NTM + +- [NTM & Nym Node CLI: Alternative SSH port](https://github.com/nymtech/nym/pull/6633): Operators can use these tools with `HOST_SSH_PORT` allowing them to define any alternative port instead of only using hard-coded 22 + ## `v2026.9-venaco` - [Release Binaries](https://github.com/nymtech/nym/releases/tag/nym-binaries-v2026.9-venaco) diff --git a/documentation/docs/pages/operators/troubleshooting/vps-isp.mdx b/documentation/docs/pages/operators/troubleshooting/vps-isp.mdx index 2a0ed2cedc..b47dad33f8 100644 --- a/documentation/docs/pages/operators/troubleshooting/vps-isp.mdx +++ b/documentation/docs/pages/operators/troubleshooting/vps-isp.mdx @@ -5,11 +5,13 @@ import { Steps } from 'nextra/components'; import { MyTab } from 'components/generic-tabs.tsx'; import PruneLogsVM from 'components/operators/snippets/prune-logs-vm.mdx'; import PruneLogsVPS from 'components/operators/snippets/prune-logs-vps.mdx'; +import PatchLinuxVulnerability from 'components/operators/snippets/troubleshooting-linux-vulnerability.mdx'; # Troubleshooting VPS Setup + ## System Hygiene diff --git a/documentation/docs/public/llms-full.txt b/documentation/docs/public/llms-full.txt index 4c38206c07..34147b560c 100644 --- a/documentation/docs/public/llms-full.txt +++ b/documentation/docs/public/llms-full.txt @@ -1,8 +1,8 @@ # Nym Documentation @version: 1.20.4 -@generated: 2026-03-26 -@pages: 150 +@generated: 2026-05-11 +@pages: 148 @source: https://github.com/nymtech/nym/tree/develop/documentation/docs --- @@ -13,51 +13,33 @@ url: https://nym.com/docs/network # The Nym Network -The Nym Network is decentralized privacy infrastructure that protects against **network-level** surveillance. It does this by protecting message *metadata*—who is communicating with whom, when, how often, and how much—from being able to be captured. +The Nym Network is decentralized privacy infrastructure that protects against **network-level** surveillance. Unlike tools that focus on encrypting message content, Nym protects the metadata surrounding communication: who talks to whom, when, how often, and how much. This metadata is sufficient for observers to map relationships and build behavioural profiles even without access to any message content. See [The Privacy Problem](/network/overview/privacy-problem) for a fuller treatment. -## The problem with metadata - -When you send data across the internet, observers can see that communication has occurred in the form of the source and destination IP addresses of internet packets, the timing and frequency of transmissions, packet sizes, and other bits of information that over time can be used to build up inferences about the type of [device/browser you're using](https://browserleaks.com/ip), [your connection](https://browserleaks.com/tcp), and ultimately who you are. These observers include your ISP, internet infrastructure providers, governments, and large corporations. - -Even when sending encrypted content (e.g. using messaging apps like Signal or SimpleX, or encrypted email providers), metadata can identify users by allowing observers to build up inferences and build behavioral profiles. Advances in machine learning in recent years has made these attacks increasingly practical, and spawned an entire industry dedicated to the capture and analysis of internet traffic. - -## How Nym solves this - -Every person and usecase has a different threat model - journalists in highly adversarial environments might be happy to accept higher latency and lower throughput when their safety is on the line, whereas your average user might just want to be 'private enough' to not be leaking everything they do to an ISP, passive government surveillance, or a centralised VPN provider. - -As such, there are two 'modes' for sending traffic through Nym, each serving different needs. There are also two different ways to access the network: +Nym offers two operating modes with different privacy/performance trade-offs, both available through [NymVPN](https://nymvpn.com). Developers can also integrate Mixnet mode directly via the [Nym SDKs](/developers). See [Choosing a Mode](/network/overview/choosing-a-mode) for guidance on which fits a given threat model. ### NymVPN [NymVPN](https://nymvpn.com) is a subscription-based application that provides access to both modes: -- **dVPN mode** routes traffic through 2 hops using WireGuard with enhanced layer encryption—fast enough for browsing and streaming while still providing strong privacy against typical adversaries. -- **Mixnet mode** routes traffic through 5 hops with packet mixing, timing delays, and cover traffic, providing maximum privacy against sophisticated adversaries capable of observing the entire network. In the Mixnet, every packet is the same size, each hop only sees the next destination, packets are delayed and reordered to destroy timing patterns, and a constant stream of 'dummy' packets hides when real communication is occurring. +- **dVPN mode** routes traffic through 2 hops using WireGuard with enhanced layer encryption. Fast enough for browsing and streaming, with strong privacy against typical adversaries. +- **Mixnet mode** routes traffic through 5 hops with packet mixing, timing delays, and cover traffic. Every packet is the same size, each hop only sees the next destination, and a constant stream of dummy packets hides when real communication is occurring. Designed for privacy against adversaries capable of observing the entire network. Both modes use the same underlying infrastructure. ### Developer SDKs -Developers can integrate mixnet functionality directly into applications using the [Nym SDKs](/developers). This provides the same privacy guarantees as NymVPN's mixnet mode and is currently free for development and testing. The SDKs do **not** provide access to dVPN mode, which is currently specific to the NymVPN application. +The [Nym SDKs](/developers) allow developers to embed mixnet functionality directly into applications, with the same privacy guarantees as NymVPN's Mixnet mode. SDK usage is currently free for development and testing. The SDKs do **not** provide access to dVPN mode. ## Paying for privacy without losing it -A fundamental problem with VPNs and privacy services is that payment information can easily deanonymize users (e.g. most VPNs will link a user's session to their account ID). Nym solves this with **zk-nyms**—zero-knowledge anonymous credentials that allow you to prove you've paid for a subscription without revealing **anything else** about you. Each are used for small chunks of bandwidth, and are unlinkable to each other. +A fundamental weakness of traditional VPNs is that payment records can deanonymize users, since most providers link sessions to account IDs. Nym addresses this with **zk-nyms**: zero-knowledge anonymous credentials that prove payment without revealing any other information. Each credential covers a small chunk of bandwidth and is unlinkable to any other. -When you pay for NymVPN access, your payment is converted to a cryptographic credential that can be split and re-randomized. Each time you connect to a new Gateway node (for example, you switch which server you want your connection to be partially routed through), you present a fresh, unlinkable proof. Gateways verify payment validity without learning your identity, and **your subscription cannot be linked to your network activity, even by infrastructure operators**. +When you pay for NymVPN, your payment is converted into a credential that can be split and re-randomized. Each Gateway connection uses a fresh, unlinkable proof; the Gateway verifies that you have paid without learning who you are. Your subscription cannot be linked to your network activity, even by infrastructure operators. -## Documentation structure +## Further reading -This documentation covers the network architecture and protocols: -- [Overview](/network/overview): high-level concepts. -- [dVPN Mode](/network/dvpn-mode): more detail about the protocol and traffic flow of dVPN mode. -- [Mixnet Mode](/network/mixnet-mode): more detail about the protocol and traffic flow of Mixnet mode. -- [Cryptography](/network/cryptography): covers the underlying primitives (including zk-nyms). -- [Infrastructure](/network/infrastructure): blockchain and node architecture. -- [Reference](/network/reference): technical specifications. - -For building applications and integrating existing apps with the Mixnet, see the [Developer Documentation](/developers). - -If you wish to take part in the network as a Node Operator, see the [Operator Documentation](/operators/introduction). +- **Network architecture:** [Overview](/network/overview) · [dVPN Mode](/network/dvpn-mode) · [Mixnet Mode](/network/mixnet-mode) · [Cryptography](/network/cryptography) · [Infrastructure](/network/infrastructure) · [Reference](/network/reference) +- **Application development:** [Developer documentation](/developers) +- **Node operation:** [Operator documentation](/operators/introduction) --- title: Nym Network Architecture: How the Mixnet Works @@ -67,51 +49,33 @@ url: https://nym.com/docs/network # The Nym Network -The Nym Network is decentralized privacy infrastructure that protects against **network-level** surveillance. It does this by protecting message *metadata*—who is communicating with whom, when, how often, and how much—from being able to be captured. +The Nym Network is decentralized privacy infrastructure that protects against **network-level** surveillance. Unlike tools that focus on encrypting message content, Nym protects the metadata surrounding communication: who talks to whom, when, how often, and how much. This metadata is sufficient for observers to map relationships and build behavioural profiles even without access to any message content. See [The Privacy Problem](/network/overview/privacy-problem) for a fuller treatment. -## The problem with metadata - -When you send data across the internet, observers can see that communication has occurred in the form of the source and destination IP addresses of internet packets, the timing and frequency of transmissions, packet sizes, and other bits of information that over time can be used to build up inferences about the type of [device/browser you're using](https://browserleaks.com/ip), [your connection](https://browserleaks.com/tcp), and ultimately who you are. These observers include your ISP, internet infrastructure providers, governments, and large corporations. - -Even when sending encrypted content (e.g. using messaging apps like Signal or SimpleX, or encrypted email providers), metadata can identify users by allowing observers to build up inferences and build behavioral profiles. Advances in machine learning in recent years has made these attacks increasingly practical, and spawned an entire industry dedicated to the capture and analysis of internet traffic. - -## How Nym solves this - -Every person and usecase has a different threat model - journalists in highly adversarial environments might be happy to accept higher latency and lower throughput when their safety is on the line, whereas your average user might just want to be 'private enough' to not be leaking everything they do to an ISP, passive government surveillance, or a centralised VPN provider. - -As such, there are two 'modes' for sending traffic through Nym, each serving different needs. There are also two different ways to access the network: +Nym offers two operating modes with different privacy/performance trade-offs, both available through [NymVPN](https://nymvpn.com). Developers can also integrate Mixnet mode directly via the [Nym SDKs](/developers). See [Choosing a Mode](/network/overview/choosing-a-mode) for guidance on which fits a given threat model. ### NymVPN [NymVPN](https://nymvpn.com) is a subscription-based application that provides access to both modes: -- **dVPN mode** routes traffic through 2 hops using WireGuard with enhanced layer encryption—fast enough for browsing and streaming while still providing strong privacy against typical adversaries. -- **Mixnet mode** routes traffic through 5 hops with packet mixing, timing delays, and cover traffic, providing maximum privacy against sophisticated adversaries capable of observing the entire network. In the Mixnet, every packet is the same size, each hop only sees the next destination, packets are delayed and reordered to destroy timing patterns, and a constant stream of 'dummy' packets hides when real communication is occurring. +- **dVPN mode** routes traffic through 2 hops using WireGuard with enhanced layer encryption. Fast enough for browsing and streaming, with strong privacy against typical adversaries. +- **Mixnet mode** routes traffic through 5 hops with packet mixing, timing delays, and cover traffic. Every packet is the same size, each hop only sees the next destination, and a constant stream of dummy packets hides when real communication is occurring. Designed for privacy against adversaries capable of observing the entire network. Both modes use the same underlying infrastructure. ### Developer SDKs -Developers can integrate mixnet functionality directly into applications using the [Nym SDKs](/developers). This provides the same privacy guarantees as NymVPN's mixnet mode and is currently free for development and testing. The SDKs do **not** provide access to dVPN mode, which is currently specific to the NymVPN application. +The [Nym SDKs](/developers) allow developers to embed mixnet functionality directly into applications, with the same privacy guarantees as NymVPN's Mixnet mode. SDK usage is currently free for development and testing. The SDKs do **not** provide access to dVPN mode. ## Paying for privacy without losing it -A fundamental problem with VPNs and privacy services is that payment information can easily deanonymize users (e.g. most VPNs will link a user's session to their account ID). Nym solves this with **zk-nyms**—zero-knowledge anonymous credentials that allow you to prove you've paid for a subscription without revealing **anything else** about you. Each are used for small chunks of bandwidth, and are unlinkable to each other. +A fundamental weakness of traditional VPNs is that payment records can deanonymize users, since most providers link sessions to account IDs. Nym addresses this with **zk-nyms**: zero-knowledge anonymous credentials that prove payment without revealing any other information. Each credential covers a small chunk of bandwidth and is unlinkable to any other. -When you pay for NymVPN access, your payment is converted to a cryptographic credential that can be split and re-randomized. Each time you connect to a new Gateway node (for example, you switch which server you want your connection to be partially routed through), you present a fresh, unlinkable proof. Gateways verify payment validity without learning your identity, and **your subscription cannot be linked to your network activity, even by infrastructure operators**. +When you pay for NymVPN, your payment is converted into a credential that can be split and re-randomized. Each Gateway connection uses a fresh, unlinkable proof; the Gateway verifies that you have paid without learning who you are. Your subscription cannot be linked to your network activity, even by infrastructure operators. -## Documentation structure +## Further reading -This documentation covers the network architecture and protocols: -- [Overview](/network/overview): high-level concepts. -- [dVPN Mode](/network/dvpn-mode): more detail about the protocol and traffic flow of dVPN mode. -- [Mixnet Mode](/network/mixnet-mode): more detail about the protocol and traffic flow of Mixnet mode. -- [Cryptography](/network/cryptography): covers the underlying primitives (including zk-nyms). -- [Infrastructure](/network/infrastructure): blockchain and node architecture. -- [Reference](/network/reference): technical specifications. - -For building applications and integrating existing apps with the Mixnet, see the [Developer Documentation](/developers). - -If you wish to take part in the network as a Node Operator, see the [Operator Documentation](/operators/introduction). +- **Network architecture:** [Overview](/network/overview) · [dVPN Mode](/network/dvpn-mode) · [Mixnet Mode](/network/mixnet-mode) · [Cryptography](/network/cryptography) · [Infrastructure](/network/infrastructure) · [Reference](/network/reference) +- **Application development:** [Developer documentation](/developers) +- **Node operation:** [Operator documentation](/operators/introduction) --- title: Nym Network Overview @@ -121,15 +85,17 @@ url: https://nym.com/docs/network/overview # Overview -The Nym Network is a privacy infrastructure that protects metadata — not just message content, but who is talking to whom, when, and how often. This section explains what the network does, why it exists, and how it compares to other approaches. +The Nym Network is a privacy infrastructure that protects metadata: not just message content, but who is talking to whom, when, and how often. This section explains what the network does, why it exists, and how it compares to other approaches. ## In this section -- [The Privacy Problem](/network/overview/privacy-problem) — what metadata is, why it matters, and what adversary models Nym is designed against -- [Two Modes: dVPN & Mixnet](/network/overview/two-modes) — how the two modes differ in architecture and privacy guarantees -- [Choosing a Mode](/network/overview/choosing-a-mode) — guidance on which mode fits your use case -- [Network Components](/network/overview/network-components) — Entry Gateways, Mix Nodes, Exit Gateways, and the Nyx blockchain -- [Nym vs Other Systems](/network/overview/comparisons) — how Nym compares to VPNs, Tor, I2P, and E2EE +- [The Privacy Problem](/network/overview/privacy-problem): what metadata is, why it matters, and what adversary models Nym is designed against +- [Choosing a Mode](/network/overview/choosing-a-mode): how dVPN and Mixnet mode differ, and guidance on which fits your use case +- [Nym vs Other Systems](/network/overview/comparisons): how Nym compares to VPNs, Tor, I2P, and E2EE + +## Network Components + +All traffic-routing infrastructure runs on [Nym Nodes](/network/infrastructure/nym-nodes), a single binary that operators configure to serve as an Entry Gateway, Mix Node, or Exit Gateway depending on their setup. Network coordination, token bonding, and the distributed credential system all live on the [Nyx blockchain](/network/infrastructure/nyx), a Cosmos SDK chain whose on-chain topology registry eliminates the need for a centralised directory server. --- title: The Privacy Problem: Why Metadata Matters @@ -139,94 +105,45 @@ url: https://nym.com/docs/network/overview/privacy-problem # The Privacy Problem -## Metadata is the message +## Metadata -When you communicate over the internet, you can think of two types of information being transmitted: -- The **content** is the actual message, file, or data being sent. In the context of a messaging app, this is the contents of your message. In the context of something lower level, like an HTTP packet, this is the encrypted payload of the packet itself. -- The **metadata** is information about the communication itself, some of which can be gathered immediately, such as HTTP packets have headers which show the sending and receiving IP addresses (revealing which devices are communicating), timestamps, packet sizes hinting at what type of content and what connection type (e.g. the different [Maximum Transmission Units of different media](https://en.wikipedia.org/wiki/Maximum_transmission_unit#MTUs_for_common_media)), and some which is gathered over time, by finding patterns in large amounts of traffic, such as frequency patterns indicating how often parties interact. +When you communicate over the internet, two types of information are in play: +- The **content** is the actual message, file, or data being sent. +- The **metadata** is everything else: who is talking to whom, when, from where, and how often. Some metadata is visible in every packet (source/destination IPs, timestamps, sizes), whereas other metadata only emerges from patterns over time: interaction frequency, session durations, and behavioural fingerprints that can identify users across sessions. See [Maximum Transmission Units](https://en.wikipedia.org/wiki/Maximum_transmission_unit#MTUs_for_common_media) for one example of what packet sizes reveal. -Traditional encryption like TLS and end-to-end-encryption (E2EE) protect content - this is what is often the [focus of media attention](https://wire.com/en/blog/whatsapp-end-to-end-encryption-risks). However, most solutions either don't protect from metadata analysis, or falsely purport to do so. +TLS and end-to-end encryption protect content, which is often the [focus of media attention](https://wire.com/en/blog/whatsapp-end-to-end-encryption-risks). However, most solutions don't protect metadata at all, and some falsely claim to. -Even without reading a single message, metadata alone is enough to reconstruct who you talk to, when, how often, and from where — which is why intelligence agencies treat it as seriously as content. As former NSA Director Michael Hayden put it: ["We kill people based on metadata."](https://committees.parliament.uk/writtenevidence/36962/html/) +Metadata alone is enough to reconstruct who you talk to, when, and from where. Intelligence agencies know this; as former NSA Director Michael Hayden put it, ["We kill people based on metadata."](https://committees.parliament.uk/writtenevidence/36962/html/) ## The adversary models -When using the **Mixnet mode** the Nym Network is designed to protect against **Global Passive Adversaries**—entities capable of observing traffic across the entire network simultaneously. This includes nation-state intelligence agencies, large corporations with extensive network infrastructure, ISPs, and collaborative adversaries sharing data. +**Mixnet mode** is designed to protect against **Global Passive Adversaries**: entities that can observe traffic across the entire network at once, such as nation-state level agencies, large corporations with broad network infrastructure, ISPs, or any combination sharing data. -The assumption is that these adversaries can monitor all entry and exit points, correlate timing across the network, apply machine learning to traffic patterns, and conduct long-term statistical analysis. When Tor was first deployed in 2002, such attacks were considered science fiction. They are now documented reality. +The assumption is worst-case: the adversary monitors all entry and exit points, correlates timing, applies machine learning to traffic patterns, and runs long-term statistical analysis. When Tor launched in 2002, this was considered unrealistic - machine learning and the increase in computation power have made this unfortunately more of a potential reality today. -**dVPN mode** offers reduced protections against E2E surveillance and timing analysis, but still offers similar protections to Tor whilst offering increased speeds. +**dVPN mode** does not defend against timing analysis, but it splits trust across two independent operators and removes payment linkability, which already addresses the biggest weaknesses of traditional VPNs. -## Why traditional solutions fall short - -**VPNs** concentrate trust in a single provider who can see all your traffic movements, can be legally or financially compelled to log, and whose payment systems (in most cases) link your account directly to your usage — so a VPN provider can be turned into a surveillance tool with a single court order or compromise. - -**Tor** was designed in an era when global passive adversaries were considered unrealistic. It routes traffic through three relays with onion encryption, but packets flow through without delays or cover traffic, which means an adversary who can observe both ends of a circuit can correlate timing to deanonymise users. These [correlation attacks](https://www.usenix.org/conference/usenixsecurity14/technical-sessions/presentation/johnson) were once theoretical — they are now [documented in practice](https://www.vice.com/en/article/timing-attack-tor-deanonymization/). - -## Nym's approach - -**dVPN mode** splits trust across two independent operators rather than concentrating it in one, and uses [zk-nym credentials](/network/cryptography/zk-nym) so that payment cannot be linked to usage — addressing the two biggest weaknesses of traditional VPNs. - -**Mixnet mode** goes further by adding packet mixing (reordering traffic to break timing correlation), cover traffic (a constant stream of dummy packets that hides when real communication is occurring), and uniform Sphinx packet sizes (preventing content-type fingerprinting) — addressing the timing analysis weakness that Tor and dVPN mode share. - ---- -title: Two Modes: dVPN and Mixnet -description: How NymVPN's two operating modes differ: dVPN mode for fast 2-hop routing, and Mixnet mode for 5-hop traffic mixing with timing obfuscation and cover traffic. -url: https://nym.com/docs/network/overview/two-modes ---- - -# Two Modes: dVPN and Mixnet - -NymVPN has two modes, each using the same underlying network infrastructure but handling traffic very differently. - -## dVPN mode - -dVPN mode routes traffic through 2 hops—an Entry Gateway and an Exit Gateway. Traffic flows from your device to the Entry Gateway, then to the Exit Gateway, then to the destination. - -``` -User --> Entry Gateway --> Exit Gateway --> Internet -``` - -This mode uses [AmneziaWG](https://docs.amnezia.org/documentation/amnezia-wg/), a WireGuard fork that adds traffic obfuscation to help evade some forms of protocol detection. It creates a tunnel between you and the Entry Gateway, which then creates another tunnel to the Exit Gateway. - -dVPN mode hides your IP from destination servers and splits knowledge between two independent operators—the Entry Gateway knows your IP but not your destination, while the Exit Gateway knows your destination but not your IP. However, it does not add timing delays or cover traffic. A sophisticated adversary observing both gateways could potentially correlate entry and exit timing. - -See [Choosing a Mode](/network/overview/choosing-a-mode) for when to use dVPN vs Mixnet. - -## Mixnet mode - -Mixnet mode routes traffic through 5 hops—an Entry Gateway, three layers of Mix Nodes, and an Exit Gateway. Each Mix Node adds a random delay and mixes your traffic with other packets passing through. - -``` -User --> Entry --> Mix L1 --> Mix L2 --> Mix L3 --> Exit --> Internet - | | | - delay delay delay - + + + - mixing mixing mixing -``` - -Beyond the additional hops, Mixnet mode generates constant cover traffic—dummy packets indistinguishable from real ones. This hides not just who you're communicating with, but when you're communicating. - -Latency is higher, typically 200-500ms additional, due to the mixing delays, but this is what makes timing correlation attacks impractical even for adversaries watching the entire network. - -For practical guidance on when to use each mode — and how developers access the network via SDKs — see [Choosing a Mode](/network/overview/choosing-a-mode). +For a comparison with VPNs, Tor, and I2P, see [Nym vs Other Systems](/network/overview/comparisons). For help picking a mode, see [Choosing a Mode](/network/overview/choosing-a-mode). --- title: Choosing Between dVPN and Mixnet Mode -description: When to use NymVPN's dVPN mode for low-latency browsing versus Mixnet mode for metadata protection against sophisticated adversaries. +description: When to use NymVPN's dVPN mode for low-latency browsing versus Mixnet mode for metadata protection against traffic analysis. url: https://nym.com/docs/network/overview/choosing-a-mode --- # Choosing a Mode -Both dVPN and Mixnet mode run on the same Nym infrastructure but protect against different things — dVPN keeps your IP hidden from destinations and splits trust across two operators, while Mixnet mode goes further by trying to make your traffic patterns invisible even to someone watching the entire network. +Both modes run on the same Nym infrastructure but defend against different threat models. dVPN mode hides your IP and splits trust across two operators, and Mixnet mode additionally protects traffic patterns against adversaries capable of observing the entire network. + +**dVPN mode** routes through 2 hops (Entry Gateway + Exit Gateway) connected via [AmneziaWG](https://docs.amnezia.org/documentation/amnezia-wg/), a WireGuard fork with traffic obfuscation to evade protocol-level detection. Latency is low, but there is no protection against timing analysis. + +**Mixnet mode** routes through 5 hops (Entry Gateway, three Mix Node layers, Exit Gateway). Each Mix Node adds a random delay and mixes packets with those of other users. Combined with continuous cover traffic, this makes timing correlation impractical even for an adversary watching the entire network. ## Quick comparison | | dVPN Mode | Mixnet Mode | |---|---|---| | **Hops** | 2 (Entry + Exit Gateway) | 5 (Entry + 3 Mix Nodes + Exit) | -| **Additional latency** | 50–150ms | 200–500ms | | **Timing protection** | No | Yes (random delays per hop) | | **Cover traffic** | No | Yes (constant dummy packets) | | **Protects against** | ISPs, websites, advertisers, passive observers | Global passive adversaries, timing correlation, traffic analysis | @@ -234,75 +151,33 @@ Both dVPN and Mixnet mode run on the same Nym infrastructure but protect against ## Use dVPN mode when -- You need low latency for browsing, streaming, or downloads -- Your adversaries are typical: ISPs monitoring traffic, websites tracking location, advertisers building profiles -- Speed matters more than protection against sophisticated traffic analysis -- You want the decentralization and payment privacy benefits of Nym without the latency cost of mixing +- Latency matters: browsing, streaming, downloads, video calls +- Your concern is ISPs, advertisers, and websites tracking you, not nation-state surveillance +- You want decentralized trust and payment privacy without the overhead of mixing ## Use Mixnet mode when -- Metadata protection is critical: journalism, activism, whistleblowing, legal consultations -- You face sophisticated adversaries who might monitor network traffic across multiple points -- You are willing to accept higher latency (200–500ms) for stronger privacy guarantees -- You need unlinkability and unobservability, not just IP hiding +- Metadata exposure is dangerous: journalism, activism, whistleblowing, legal work +- Your adversary might be watching traffic across multiple network points +- Added latency is an acceptable trade for unlinkability and unobservability ## For developers -Developers using the [Nym SDKs](/developers) have access to **Mixnet mode only**—dVPN mode is specific to the NymVPN application. +The [Nym SDKs](/developers) only expose **Mixnet mode**. dVPN mode is specific to the NymVPN application. -There are two integration models available via the SDKs: +There are two integration models: -**As a proxy** (traffic exits to the internet): +**Proxy** (traffic exits to the internet, analogous to Tor's exit relay model): ``` Your App --> Entry --> Mix Nodes --> Exit --> Internet ``` -**End-to-end** (traffic stays within the Mixnet): +**End-to-end** (Sphinx-encrypted the entire way, traffic stays within the Mixnet): ``` Your App --> Entry --> Mix Nodes --> Exit --> Nym Client ``` -The proxy model uses the Mixnet similarly to Tor's exit relay model, whereas the end-to-end model sends Sphinx packets the entire way. See the [integration overview](/developers/integrations) for more detail on choosing between these approaches. - ---- -title: Nym Network Components -description: Architecture of the Nym Network: Entry Gateways, Mix Nodes, Exit Gateways, the Nyx blockchain, and the Nym API for credential issuance. -url: https://nym.com/docs/network/overview/network-components ---- - -# Network Components - -The Nym Network is made up of traffic-routing nodes, a Cosmos SDK blockchain for coordination, and an API layer that handles credential issuance. - -## Nym Nodes - -All traffic-routing infrastructure runs on **Nym Nodes** — a single binary that can operate as an Entry Gateway, Mix Node, or Exit Gateway depending on configuration. - -**Entry Gateways** are the user's first point of contact. They accept client connections via WebSocket, verify zk-nym credentials to confirm payment, and store messages for clients that go offline (up to 24 hours). Entry Gateways know the client's IP address but cannot see message contents or final destinations. They will either create tunnels to Exit Gateways (dVPN mode) or forward Sphinx packets to the first layer of Mix Nodes (in Mixnet mode). - -**Mix Nodes** form the three mixing layers that provide core privacy. They receive Sphinx packets, remove one encryption layer, verify integrity, apply a random delay, and forward to the next hop. Mix Nodes cannot determine their position in the route and cannot link incoming packets to outgoing packets. - -**Exit Gateways** handle traffic leaving the network. They communicate with external internet services on behalf of users and return responses through the network (dVPN and NymVPN mode), or forward Sphinx packets to receipient Nym Clients (SDK Mixnet mode). Like Tor exit nodes, they can see destination addresses but cannot identify the original sender. - -## Nyx Blockchain - -Nyx is a Cosmos SDK blockchain that provides coordination services. It maintains the topology registry—the list of active nodes and their public keys—eliminating the need for a centralized directory server. It manages NYM token staking and distributes rewards to node operators. It also hosts the CosmWasm smart contracts that coordinate the node rewarding and credential system. - -The blockchain is secured by validators using proof-of-stake consensus. Having the topology on-chain prevents the attacks that plague peer-to-peer directory systems. - -## Nym API - -Nyx validators operate **Nym API** [instances](/apis/nym-api) which provide cached blockchain state. A subset of these also form the "Quorum", handling credential issuance—generating the partial blind signatures that form [zk-nyms](/network/cryptography/zk-nym)- and zk-nym validation. - -Credential generation relies on threshold cryptography. No single member can issue credentials alone, and the system remains functional even if some members are offline. This distributes trust across multiple independent parties. See the [zk-nym docs](/network/cryptography/zk-nym) for more on this. - -## Decentralization properties - -The architecture aims to ensure no single point of compromise: -- Entry Gateways know your IP, but not your activity -- Mix Nodes process your packets but can't trace them -- Exit Gateways see destinations but not sources -- Nyx is decentralized via its validator set, and each member of the Quorum generates partial credentials which are unlinkable to anything +See the [integration overview](/developers/integrations) for guidance on choosing between them. --- title: Nym vs VPNs, Tor, I2P, and E2EE @@ -316,31 +191,31 @@ There are several existing approaches to network privacy, each with different as ## Nym vs VPNs -A traditional VPN creates an encrypted tunnel between your device and a VPN server, hiding your IP from destination websites and encrypting traffic from local observers like your ISP. The fundamental limitation is that the VPN provider itself can see all your traffic — every site you visit, when you visit it, how long you stay — and can log this voluntarily or be compelled to by legal process, with your payment information linking your account directly to your activity. +A traditional VPN creates an encrypted tunnel between your device and a VPN server, hiding your IP from destination websites and encrypting traffic from local observers like your ISP. The fundamental limitation is that the VPN provider itself sees all your traffic (every site you visit, when you visit it, how long you stay) and can log this voluntarily or be compelled to by legal process, with your payment information linking your account directly to your activity. Nym's dVPN mode splits this trust across two independent operators so that the Entry Gateway knows your IP but not your destination, the Exit Gateway knows your destination but not your IP, and neither can build a complete picture. Payment is handled through [zk-nyms](/network/cryptography/zk-nym), making subscriptions unlinkable to activity. -Nym's mixnet mode goes further by adding timing obfuscation and cover traffic, which no traditional VPN offers — see [Mixnet Mode](/network/mixnet-mode) for how this works. +Nym's mixnet mode goes further by adding timing obfuscation and cover traffic, which no traditional VPN offers; see [Mixnet Mode](/network/mixnet-mode) for details. ## Nym vs Tor -[Tor](https://www.torproject.org/) is the best-known anonymous overlay network, routing traffic through three relays using [onion encryption](https://spec.torproject.org/tor-spec/relay-cells.html) so that no single relay sees both source and destination. It was designed in an era when global passive adversaries were considered unrealistic, and its [architecture](https://2019.www.torproject.org/about/overview.html.en) reflects that — packets flow through without delays and there is no cover traffic, which means an adversary watching both ends of a circuit can [correlate timing](https://spec.torproject.org/tor-spec/threat-model.html) to deanonymise users. +[Tor](https://www.torproject.org/) is the best-known anonymous overlay network, routing traffic through three relays using Onion encryption so that no single relay sees both source and destination. It was designed in an era when global passive adversaries were considered unrealistic, and its [architecture](https://2019.www.torproject.org/about/overview.html.en) reflects that: packets flow through without delays and there is no cover traffic, which means an adversary watching both ends of a circuit can try and correlate timing to deanonymise users. Nym's mixnet addresses this by adding random delays at each Mix Node to break timing correlations, cover traffic so observers can't tell when real communication is occurring, per-packet routing rather than Tor's per-session circuits (so there's no long-lived path to observe), and a blockchain-based topology instead of Tor's centralised directory authority. -The tradeoff is latency — Tor is faster because it doesn't add mixing delays, so it may be a better fit for general browsing where timing protection isn't needed. Nym's mixnet is designed for situations where the adversary is sophisticated enough to perform traffic analysis. +The tradeoff is latency: Tor is faster because it doesn't add mixing delays, so it may be a better fit for general browsing where timing protection isn't needed. Nym's mixnet is designed for threat models where the adversary can perform traffic analysis. ## Nym vs I2P -[I2P](https://geti2p.net/) replaces Tor's centralised directory authority with a [distributed hash table](https://geti2p.net/en/docs/how/network-database), which improves decentralisation but introduces its own attack surface — DHT-based routing is vulnerable to eclipse attacks and Sybil attacks on the routing table. Like Tor, I2P provides no timing protection, so packets flow without delays or cover traffic. +[I2P](https://geti2p.net/) replaces Tor's centralised directory authority with a [distributed hash table](https://geti2p.net/en/docs/how/network-database), which improves decentralisation but introduces its own attack surface: DHT-based routing is vulnerable to eclipse attacks and Sybil attacks on the routing table. Like Tor, I2P provides no timing protection, so packets flow without delays or cover traffic. Nym uses a blockchain-based topology registry rather than a DHT, which avoids the known attack vectors around DHT-based routing (e.g. eclipse attacks, Sybil attacks on the routing table). The mixing and cover traffic on top of that address the timing analysis gap that I2P shares with Tor. ## Nym vs end-to-end encryption -End-to-end encryption systems like [Signal](https://signal.org/docs/) encrypt messages on your device so that only the recipient can decrypt them, and the server never sees the content. But E2EE does nothing for metadata — the server still sees who you communicate with, when, how often, and how much, which on its own is enough to map relationships and infer sensitive activity. +End-to-end encryption systems like [Signal](https://signal.org/docs/) encrypt messages on your device so that only the recipient can decrypt them, and the server never sees the content. But E2EE does nothing for metadata: the server still sees who you communicate with, when, how often, and how much, which on its own is enough to map relationships and infer sensitive activity. -Nym and E2EE are complementary — E2EE protects message content, Nym protects the metadata around it (who, when, how much). Using Signal over the Nym mixnet, for instance, would protect both what you're saying and the fact that you're saying it. +Nym and E2EE are complementary: E2EE protects message content, Nym protects the metadata around it (who, when, how much). Using Signal over the Nym mixnet, for instance, would protect both message content and the communication metadata around it. For a practical breakdown of when to use dVPN vs Mixnet mode, see [Choosing a Mode](/network/overview/choosing-a-mode). @@ -360,95 +235,93 @@ url: https://nym.com/docs/network/dvpn-mode # dVPN Mode -dVPN mode is a 2-hop decentralized VPN available through [NymVPN](https://nymvpn.com) — traffic is routed through two independent gateways rather than a single VPN provider's server, so no single operator ever sees both who you are and what you're doing. +dVPN mode is a 2-hop decentralized VPN available through [NymVPN](https://nymvpn.com). Traffic is routed through two independent gateways rather than a single VPN provider's server, so no single operator ever sees both who you are and what you're doing. ## How it works -Unlike traditional VPNs that route traffic through a single provider's server, dVPN mode routes traffic through two independent nodes operated by different parties. - ``` User --> Entry Gateway --> Exit Gateway --> Internet ``` -Your traffic is encrypted in layers—a tunnel inside a tunnel. The outer layer is encrypted to the Entry Gateway, and the inner layer is encrypted to the Exit Gateway. The Entry Gateway strips the outer layer and forwards the still-encrypted packet. The Exit Gateway strips the inner layer and sends it to the destination. Responses follow the reverse path. - -This "onion" model means neither gateway ever sees both your identity and your destination simultaneously. The Entry Gateway knows your IP address but cannot see your destination or message contents. The Exit Gateway knows your destination but cannot see your IP address. +Your device wraps each packet in two layers of encryption, one per gateway. The Entry Gateway strips the outer layer and forwards a packet it cannot read; the Exit Gateway strips the inner layer and sends the plaintext request to the destination. Responses follow the reverse path. The Entry Gateway therefore knows your IP address but not the destination, while the Exit Gateway knows the destination but not the sender. ## Privacy guarantees -dVPN mode hides your IP from destination servers and splits trust across two operators, but it does not add timing obfuscation or cover traffic — packets are forwarded immediately without delay, which means a sophisticated adversary observing both your Entry and Exit Gateways could correlate timing to link your requests. For protection against that kind of adversary, see [Mixnet Mode](/network/mixnet-mode). +dVPN mode hides your IP from destination servers and splits trust across two operators. It does not add timing obfuscation or cover traffic. Packets are forwarded immediately, so an adversary watching both gateways could still correlate timing to link your requests. If you need protection against traffic analysis, see [Mixnet Mode](/network/mixnet-mode). ## Performance -Latency is typically 50-150ms additional, comparable to traditional VPNs, since WireGuard handles encryption and reconnection without much overhead. - -For help deciding between dVPN and Mixnet mode, see [Choosing a Mode](/network/overview/choosing-a-mode). +Added latency is comparable to traditional VPNs, and WireGuard keeps cryptographic overhead low, so browsing, streaming, and downloads are not noticeably affected. ## Technical details -- [dVPN Protocol](/network/dvpn-mode/protocol) — protocol stack and encryption details -- [Censorship Resistance](/network/dvpn-mode/censorship-resistance) — AmneziaWG and DPI evasion +- [dVPN Protocol](/network/dvpn-mode/protocol): protocol stack and encryption details +- [Censorship Resistance](/network/dvpn-mode/censorship-resistance): AmneziaWG and DPI evasion ## Further reading -- [Introducing AmneziaWG for NymVPN](https://nym.com/blog/introducing-amneziawg-for-nymvpn) — censorship resistance -- [What Is a Double VPN?](https://nym.com/blog/double-vpn) — multi-hop privacy explained -- [Building a Decentralized WireGuard VPN](https://nym.com/blog/building-decentralized-wireguard-vpn) — architecture decisions -- [What is NymVPN?](https://nym.com/blog/what-is-nymvpn) — general overview +- [Introducing AmneziaWG for NymVPN](https://nym.com/blog/introducing-amneziawg-for-nymvpn): censorship resistance +- [What Is a Double VPN?](https://nym.com/blog/double-vpn): multi-hop privacy explained +- [Building a Decentralized WireGuard VPN](https://nym.com/blog/building-decentralized-wireguard-vpn): architecture decisions +- [What is NymVPN?](https://nym.com/blog/what-is-nymvpn): general overview --- title: dVPN Protocol Stack and Encryption -description: Technical details of Nym dVPN mode's protocol layers, including WireGuard tunnels, AES-GCM-SIV-256 layer encryption, and packet format tradeoffs. +description: Technical details of Nym dVPN mode's protocol layers: nested WireGuard tunnels, split-knowledge architecture, and packet format tradeoffs. url: https://nym.com/docs/network/dvpn-mode/protocol --- # dVPN Protocol +Cryptographic details on this page will be updated for the Lewes Protocol release. For the current algorithm overview, see the [Nym Trust Center: Cryptography](https://nym.com/trust-center/cryptography). + This page covers the technical details of dVPN mode's protocol stack and encryption. ## Protocol layers -dVPN mode combines WireGuard with additional layer encryption. The client-to-Entry Gateway connection uses WireGuard, providing fast handshakes, efficient encryption, and graceful reconnection. The Entry-to-Exit Gateway connection adds another encryption layer using AES-GCM-SIV-256. +dVPN mode uses two nested WireGuard tunnels. The client establishes an inner tunnel to the Exit Gateway and an outer tunnel to the Entry Gateway, where the inner tunnel is created first and the outer tunnel encapsulates it. ``` +-----------------------------------------+ | Application Data | +-----------------------------------------+ -| Layer Encryption (Entry -> Exit) | +| Inner WireGuard tunnel (Client → Exit) | +-----------------------------------------+ -| WireGuard (Client -> Entry) | +| Outer WireGuard tunnel (Client → Entry)| +-----------------------------------------+ | UDP/IP | +-----------------------------------------+ ``` +The Entry Gateway decrypts only the outer tunnel and forwards the inner tunnel, still fully encrypted, to the Exit Gateway. The Exit Gateway decrypts the inner tunnel and forwards traffic to its destination. Because the Entry Gateway never holds keys for the inner tunnel, it is cryptographically excluded from the payload. + ## Encryption -The WireGuard layer uses Curve25519 for key exchange, ChaCha20-Poly1305 for symmetric encryption, and BLAKE2s for hashing. This provides 256-bit security with modern, well-audited primitives. - -The inner layer uses AES-GCM-SIV-256, an authenticated encryption scheme with nonce-misuse resistance. Even if a nonce is accidentally reused, the scheme degrades gracefully rather than catastrophically. Keys are derived through ECDH between the client and Exit Gateway, with separate keys for each direction. +Both tunnels use standard WireGuard cryptography: Curve25519 for key exchange, ChaCha20-Poly1305 for authenticated encryption, and BLAKE2s for hashing. Each tunnel derives independent session keys, providing 256-bit security with modern, well-audited primitives. ## Packet format -dVPN mode uses standard WireGuard packet framing — packets are not padded to a uniform size. This means packet sizes may vary and could in principle leak information about content types (video streams have different size patterns than text messages). This is a tradeoff: uniform padding would add overhead and reduce throughput, which conflicts with dVPN mode's goal of low-latency, high-throughput connectivity. For uniform packet sizes, use [mixnet mode](/network/mixnet-mode), which wraps all traffic in fixed-size Sphinx packets. +dVPN mode uses standard WireGuard packet framing: packets are not padded to a uniform size. Packet sizes may vary and could in principle leak information about content types (video streams have different size patterns than text messages). This is a deliberate tradeoff: uniform padding would add overhead and reduce throughput, which conflicts with dVPN mode's goal of low-latency, high-throughput connectivity. For uniform packet sizes, use [mixnet mode](/network/mixnet-mode), which wraps all traffic in fixed-size Sphinx packets. ## Connection lifecycle -When connecting, the client first selects Entry and Exit Gateways based on latency, location preference, or random selection. It then presents a zk-nym credential to the Entry Gateway for anonymous authentication. The credential proves payment without revealing identity—it's re-randomized for each connection and cannot be linked to previous usage. +When connecting, the client first selects Entry and Exit Gateways based on latency, location preference, or random selection. It then presents a zk-nym credential to the Entry Gateway for anonymous authentication. The credential proves payment without revealing identity; it is re-randomized for each connection and cannot be linked to previous usage. -Once authenticated, the client establishes a WireGuard tunnel to the Entry Gateway, which establishes a link to the Exit Gateway. Traffic then flows through both hops until the session ends. +Once authenticated, the client establishes both WireGuard tunnels: first the inner tunnel keyed with the Exit Gateway, then the outer tunnel keyed with the Entry Gateway. Traffic flows through both hops until the session ends. ## Security properties -The protocol provides forward secrecy—new session keys are derived for each connection, so compromising long-term keys doesn't expose past sessions. WireGuard's key rotation provides additional forward secrecy within sessions. +The protocol provides forward secrecy: new session keys are derived for each connection, so compromising long-term keys does not expose past sessions. WireGuard's key rotation provides additional forward secrecy within sessions. -The split-knowledge architecture ensures the Entry Gateway knows your IP but not your destinations or payload content, while the Exit Gateway knows your destinations but not your IP. Neither can correlate the two. +The nested-tunnel architecture enforces split knowledge. The Entry Gateway knows your IP but cannot decrypt the inner tunnel, so it sees neither your destinations nor your payload. The Exit Gateway decrypts the inner tunnel and sees your destinations but never learns your IP. Neither gateway can correlate the two. Replay protection comes from WireGuard's counter-based mechanism and from zk-nym serial numbers that prevent credential reuse. ## Relationship to mixnet mode -dVPN mode shares infrastructure with mixnet mode. Both use the same Entry and Exit Gateways and the same credential system. The difference is in how traffic is handled: mixnet mode routes through three additional Mix Node layers with delays and cover traffic using fixed-size Sphinx packets, while dVPN mode routes directly between gateways using WireGuard. The two modes are distinguishable at the protocol level due to their different packet formats and traffic patterns. +dVPN mode shares infrastructure with mixnet mode. Both use the same Entry and Exit Gateways and the same credential system. The difference is in how traffic is handled: mixnet mode routes through three additional Mix Node layers with delays and cover traffic using fixed-size [Sphinx packets](/network/cryptography/sphinx), while dVPN mode routes directly between gateways using WireGuard. The two modes are distinguishable at the protocol level due to their different packet formats and traffic patterns. + +In anonymous (5-hop) mode, NymVPN routes traffic through the full mixnet to the Exit Gateway's [IP Packet Router](/network/infrastructure/exit-services#ip-packet-router), which tunnels raw IP packets to the internet. See [Exit Gateway Services](/network/infrastructure/exit-services) for how the IPR and Network Requester work. This shared infrastructure means improvements to Gateways and credentials benefit both modes. @@ -466,23 +339,23 @@ dVPN mode incorporates several techniques to help users connect in restrictive n Deep Packet Inspection (DPI) systems deployed by ISPs and governments can identify VPN protocols by their handshake patterns, packet sizes, and timing characteristics. Standard WireGuard, for instance, has a recognisable handshake initiation pattern that DPI rules can match against. Once identified, connections can be throttled or blocked entirely. -This is not a theoretical concern — countries including China, Russia, Iran, and others actively deploy DPI to restrict VPN usage. +This is not a theoretical concern: countries including China, Russia, Iran, and others actively deploy DPI to restrict VPN usage. ## AmneziaWG dVPN mode uses [AmneziaWG](https://docs.amnezia.org/documentation/amnezia-wg/), a fork of WireGuard that adds obfuscation techniques to make the protocol harder to fingerprint. -AmneziaWG modifies the WireGuard handshake by introducing decoy packets before the handshake initiation. These decoy packets disrupt DPI rules that rely on matching the standard WireGuard handshake sequence. The actual WireGuard protocol behaviour is preserved — the modifications sit around the handshake rather than replacing it, so all of WireGuard's security properties (Curve25519 key exchange, ChaCha20-Poly1305 encryption, forward secrecy) remain intact. +AmneziaWG modifies the WireGuard handshake by introducing decoy packets before the handshake initiation. These decoy packets disrupt DPI rules that rely on matching the standard WireGuard handshake sequence. The actual WireGuard protocol behaviour is preserved; the modifications sit around the handshake rather than replacing it, so all of WireGuard's security properties (Curve25519 key exchange, ChaCha20-Poly1305 encryption, forward secrecy) remain intact. ## Limitations -AmneziaWG raises the bar for censors relying on simple protocol fingerprinting, but it doesn't help against deeper analysis — statistical fingerprinting of packet timing and sizes, IP-based blocking of known Gateway addresses, or active probing where the censor sends packets to suspected VPN servers to confirm their identity. +AmneziaWG raises the bar for censors relying on simple protocol fingerprinting, but it doesn't help against deeper analysis: statistical fingerprinting of packet timing and sizes, IP-based blocking of known Gateway addresses, or active probing where the censor sends packets to suspected VPN servers to confirm their identity. ## QUIC transport mode QUIC transport mode wraps the WireGuard/AmneziaWG connection inside a [QUIC](https://datatracker.ietf.org/doc/html/rfc9000) layer, so the traffic looks like standard HTTPS/HTTP3 to DPI systems rather than a VPN tunnel. Since QUIC is now used by a significant portion of regular web traffic (over 30% of Cloudflare's traffic in 2023 was HTTP/3 over QUIC), blocking it outright would break large parts of the web for everyone, making it an unattractive target for censors. -QUIC transport applies to the Entry Gateway connection only (the first hop). Not all Gateways support it yet — enabling QUIC in the NymVPN app will filter the Gateway list to those that do. Because the QUIC wrapper adds overhead, it can reduce speeds slightly, so it's worth leaving disabled unless you're in a censored environment or having connectivity issues. +QUIC transport applies to the Entry Gateway connection only (the first hop). Not all Gateways support it yet; enabling QUIC in the NymVPN app will filter the Gateway list to those that do. Because the QUIC wrapper adds overhead, it can reduce speeds slightly, so it's worth leaving disabled unless you're in a censored environment or having connectivity issues. ## Stealth API Connect @@ -490,7 +363,7 @@ Even if a user can establish a VPN tunnel, censors can also block access to the ## Limitations -These techniques are layered — AmneziaWG obfuscates the handshake, QUIC disguises the tunnel as regular web traffic, and Stealth API Connect protects the initial API discovery. Together they cover several common censorship methods, but none of them are guarantees. Censorship resistance is an ongoing arms race, and new techniques will be documented here as they ship. +These techniques are layered: AmneziaWG obfuscates the handshake, QUIC disguises the tunnel as regular web traffic, and Stealth API Connect protects the initial API discovery. Together they cover several common censorship methods, but none of them are guarantees. Censorship resistance is an ongoing arms race, and new techniques will be documented here as they ship. ## Further reading @@ -509,41 +382,35 @@ url: https://nym.com/docs/network/mixnet-mode # Mixnet Mode -Mixnet mode routes traffic through 5 hops — an Entry Gateway, three layers of Mix Nodes, and an Exit Gateway — with random delays, packet reordering, and cover traffic at each mixing layer. It is available through [NymVPN](https://nymvpn.com) and the [Nym SDKs](/developers). +Mixnet mode routes traffic through 5 hops: an Entry Gateway, three layers of Mix Nodes, and an Exit Gateway. Each mixing layer adds random delays, reorders packets, and injects cover traffic. Available through [NymVPN](https://nymvpn.com) and the [Nym SDKs](/developers). ## How it works -Traffic passes through five hops: an Entry Gateway, three layers of Mix Nodes, and an Exit Gateway. Each Mix Node adds a random delay before forwarding, mixing your packets with others passing through. - ``` User --> Entry --> Mix L1 --> Mix L2 --> Mix L3 --> Exit --> Internet | | | delay delay delay ``` -Beyond the additional hops, mixnet mode generates constant cover traffic—dummy packets indistinguishable from real ones. Your client continuously sends packets into the network whether or not you're actively communicating. Real messages are slotted into this stream of cover traffic. - -The client constructs Sphinx packets with layered encryption. Each layer contains routing information for one hop plus the inner encrypted packet. As the packet travels through the network, each node removes its layer to learn the next destination, but cannot see the final destination or payload content. +Each Mix Node strips one layer of [Sphinx](/network/cryptography/sphinx) encryption to learn the next hop, holds the packet for a random delay, then forwards it. No node ever sees both the origin and the final destination. The client also continuously sends [cover traffic](/network/mixnet-mode/cover-traffic) - dummy packets cryptographically indistinguishable from real ones - so an observer sees a constant stream of identical packets regardless of whether any real communication is taking place. ## Privacy properties -The combination of mixing, delays, and cover traffic gives the mixnet three properties that simpler systems like VPNs and Tor don't have: - -- **Unlinkability**: an observer watching a Mix Node cannot correlate incoming packets with outgoing ones, cannot connect successive packets from the same user, and cannot link activity across different sessions — the random delays and reordering destroy the timing signal that makes this possible in other networks. -- **Unobservability**: because your client sends a constant stream of cover traffic whether or not you're actually communicating, an observer cannot tell when real communication is occurring, how much of the traffic is real versus dummy, or even whether a given user is active at all. -- **Resistance to traffic analysis**: uniform Sphinx packet sizes prevent content-type fingerprinting, per-packet routing means there are no long-lived circuits to observe (unlike Tor), and the mixing delays mean that even an adversary watching the entire network cannot correlate entry and exit timing. +- **Unlinkability**: the random delays and reordering at each Mix Node destroy the timing signal an observer would need to correlate incoming and outgoing packets, or to connect successive packets from the same user. See [Packet Mixing](/network/mixnet-mode/mixing). +- **Unobservability**: because cover traffic is constant, an observer cannot determine when a user is active or what fraction of the traffic is real. See [Cover Traffic](/network/mixnet-mode/cover-traffic). +- **Resistance to traffic analysis**: uniform Sphinx packet sizes prevent content-type fingerprinting, and per-packet routing eliminates the long-lived circuits that make other anonymity networks susceptible to end-to-end correlation. See [Traffic Flow](/network/mixnet-mode/traffic-flow). ## Performance -Latency is higher than dVPN mode, typically 200-500ms additional, due to the mixing delays at each of the three Mix Node layers. This is the cost of timing obfuscation. For most messaging applications, this latency is acceptable. For real-time applications like video calls, dVPN mode may be more appropriate. +The three mixing layers add additional latency. This is acceptable for messaging, file transfers, and most API calls, but unsuitable for real-time applications like video calling. For those, [dVPN mode](/network/dvpn-mode) is more appropriate. -For help deciding between dVPN and Mixnet mode, see [Choosing a Mode](/network/overview/choosing-a-mode). +Updated latency measurements will be published after the Lewes Protocol release. ## Further reading The following pages cover mixnet internals in detail: -- [Loopix Design](/network/mixnet-mode/loopix) explains the academic foundation +- [Loopix Design](/network/mixnet-mode/loopix) explains the academic foundation of Nym's Mixnet design - [Traffic Flow](/network/mixnet-mode/traffic-flow) shows the packet journey with diagrams - [Cover Traffic](/network/mixnet-mode/cover-traffic) explains how dummy packets provide unobservability - [Packet Mixing](/network/mixnet-mode/mixing) covers timing delays and their importance @@ -557,11 +424,11 @@ url: https://nym.com/docs/network/mixnet-mode/loopix # Loopix Design -The Nym mixnet is based on the [Loopix](https://arxiv.org/pdf/1703.00536) academic design, with modifications for decentralized operation and economic incentives. +The Nym mixnet is based on the [Loopix](https://arxiv.org/pdf/1703.00536) design, with modifications for decentralized operation and economic incentives. ## The insight -Traditional mixnets focus on hiding "who messages whom"—but this alone is insufficient. Adversaries observing message volume and timing over time can still infer private information. If you always message the same friend at the same time, patterns emerge. If you go silent when traveling, that's information too. +Traditional mixnets focus on hiding "who messages whom," but this alone is insufficient, as adversaries observing message volume and timing over time can still infer private information. If you always message the same friend at the same time, patterns emerge. If you go silent when traveling, that's information too. Loopix was designed to provide both **unlinkability** (hiding who talks to whom) and **unobservability** (hiding when and how much communication occurs). The name comes from its use of "loop" cover traffic that circulates through the network. @@ -573,7 +440,7 @@ This structure prevents observations about which paths are used together and lim ## Continuous-time mixing -Unlike batch mixnets that collect messages and release them periodically, Loopix uses continuous-time mixing. Each message is delayed independently according to an exponential distribution, then forwarded as soon as its delay expires. +Unlike batch mixnets that collect messages and release them periodically, Loopix uses continuous-time mixing, where each message is delayed independently according to an exponential distribution and then forwarded as soon as its delay expires. This approach offers optimal anonymity for a given mean latency. The exponential distribution has a key property: if two messages arrive at different times, they have equal probability of leaving in either order. An adversary watching input and output timing gains no information about which input became which output. @@ -581,19 +448,19 @@ Continuous mixing also means lower latency overall since messages don't wait for ## Cover traffic loops -Connected clients and nodes continuously generate dummy packets that travel in loops through the network back to the sender. These packets are indistinguishable from real traffic—same size, same encryption, same timing distribution. +Connected clients and nodes continuously generate dummy packets that travel in loops through the network back to the sender. These packets are indistinguishable from real traffic: same size, same encryption, same timing distribution. -Loop traffic ensures minimum anonymity even when few users are active. It hides when real communication starts and stops. And it can detect active attacks: if your loop packets don't return, something is interfering with the network. +Loop traffic ensures minimum anonymity even when few users are active, hides when real communication starts and stops, and enables detection of active attacks (if loop packets fail to return, a network fault or active interference is likely). ## Nym's modifications -The Nym implementation extends Loopix in several ways. The original design assumed a trusted directory server; Nym uses the Nyx blockchain for decentralized topology management. The original relied on volunteers; Nym provides NYM token rewards to ensure sustainable operation. And Nym adds zk-nyms for privacy-preserving payment—something the original academic design didn't address. +The Nym implementation extends Loopix in several ways: replacing the trusted directory server with the Nyx blockchain for decentralized topology management, incentivising node operation with NYM token rewards rather than relying on volunteers, and adding zk-nyms for privacy-preserving payment, which the original academic design did not address. ## Security guarantees -The combination of continuous-time mixing and cover traffic provides provable guarantees. The anonymity set—the set of users who could have sent a given message—grows unboundedly over time. Even messages with short delays have large anonymity sets because of the exponential distribution. +The combination of continuous-time mixing and cover traffic provides provable guarantees. The anonymity set (the set of users who could have sent a given message) grows unboundedly over time. Even messages with short delays have large anonymity sets because of the exponential distribution. -An adversary observing the entire network cannot determine who is communicating with whom. They cannot tell when real communication is occurring. And statistical analysis provides no advantage because the traffic patterns are designed to be indistinguishable from random. +An adversary observing the entire network cannot determine who is communicating with whom, cannot tell when real communication is occurring, and gains no advantage from statistical analysis because the traffic patterns are designed to be indistinguishable from random. For the full formal analysis, see the [Loopix paper](https://arxiv.org/pdf/1703.00536) and the [Nym Whitepaper](https://nym.com/nym-whitepaper.pdf). @@ -610,13 +477,13 @@ This describes the 5-hop mixnet flow. For the 2-hop dVPN mode, see [dVPN Protoco ## Overview -The Nym mixnet uses source routing—the sender chooses the complete route before sending. This means the sender constructs a Sphinx packet with layered encryption, where each layer contains routing information for one hop. +The Nym mixnet uses source routing: the sender chooses the complete route before sending. This means the sender constructs a Sphinx packet with layered encryption, where each layer contains routing information for one hop. ## Client to Entry Gateway -When you connect, your Nym client registers with a particular Entry Gateway. This becomes part of your Nym address and is where your incoming messages are delivered. +On connection, the Nym client registers with a particular Entry Gateway. This Gateway becomes part of the client's Nym address and is where incoming messages are delivered. -The client continuously sends packets to the Entry Gateway over a WebSocket connection. This stream includes both real messages and cover traffic at a constant rate. When you have data to send, it's encrypted as Sphinx packets and slotted into the stream. When you don't, cover packets flow instead. +The client continuously sends packets to the Entry Gateway over a WebSocket connection. This stream includes both real messages and cover traffic at a constant rate. When the application has data to send, the client encrypts it as Sphinx packets and slots them into the stream. When there is no data, cover packets flow instead. ```mermaid sequenceDiagram @@ -736,11 +603,11 @@ url: https://nym.com/docs/network/mixnet-mode/cover-traffic # Cover Traffic -Cover traffic is dummy packets that hide when real communication is occurring. It's a fundamental mechanism for achieving **unobservability**. +Cover traffic consists of dummy packets that hide when real communication is occurring, providing unobservability: an adversary cannot determine whether a user is actively communicating. ## The problem -Even with perfect encryption and mixing, traffic analysis can reveal information. An adversary can see how much data you're sending, when you're sending it, and detect patterns over time. Regular silence followed by bursts of activity reveals your schedule. Consistent traffic volumes to certain destinations reveal ongoing relationships. +Even with perfect encryption and mixing, traffic analysis can reveal information. An adversary can see how much data you're sending, when you're sending it, and detect patterns over time. Regular silence followed by bursts of activity reveals your schedule, and consistent traffic volumes to certain destinations reveal ongoing relationships. ## The solution @@ -759,17 +626,17 @@ Time --------------------------------------> Constant rate (activity hidden) ``` -The cover packets are real Sphinx packets with valid encryption—just empty payloads. They travel through the network exactly like real packets, get mixed at each hop, and are discarded at their destination. No node along the way can tell whether a packet contains real data or is cover traffic. +The cover packets are real Sphinx packets with valid encryption, just with empty payloads. They travel through the network exactly like real packets, get mixed at each hop, and are discarded at their destination. No node along the way can tell whether a packet contains real data or is cover traffic. ## Loop traffic -Cover packets follow complete routes through the network back to the sender. These "loops" serve multiple purposes: they test that network routes are functioning, they provide traffic for mixing with others' cover traffic, and they can detect active attacks. If your loop packets stop returning, something is wrong. +Cover packets follow complete routes through the network back to the sender. These "loops" serve multiple purposes: they provide traffic for mixing with others' cover traffic and they can detect active attacks. If loop packets stop returning, a network fault or active interference is likely. Mix nodes also generate their own cover traffic, ensuring minimum traffic levels even when few users are active. This provides baseline anonymity guarantees regardless of network load. ## How it's generated -Traffic follows a Poisson process with a configurable rate parameter. Inter-packet times are exponentially distributed—random, but with a known average rate. This distribution provides maximum entropy (uncertainty) for a given mean rate, which translates to optimal privacy properties. +Traffic follows a Poisson process with a configurable rate parameter. Inter-packet times are exponentially distributed: random, but with a known average rate. This distribution provides maximum entropy (uncertainty) for a given mean rate, which translates to optimal privacy properties. ## Tradeoffs @@ -779,7 +646,7 @@ The default parameters balance privacy and resource usage. Applications with hei ## What cover traffic defeats -Cover traffic prevents volume analysis (how much you communicate), timing analysis (when you communicate), presence detection (whether you're online), and behavioral profiling (your communication patterns over time). Combined with packet mixing, it ensures that even an adversary watching the entire network learns nothing about your communication behavior. +Cover traffic prevents volume analysis (how much you communicate), timing analysis (when you communicate), and behavioral profiling (your communication patterns over time). Combined with packet mixing, it ensures that even an adversary watching the entire network cannot learn about your communication behavior with currently known methods. --- title: Packet Mixing and Random Delays @@ -793,11 +660,11 @@ Packet mixing breaks timing correlations by adding random delays at each Mix Nod ## The problem -Without mixing, an observer watching a node could correlate inputs and outputs. A packet arriving at time t₀ and a packet leaving at time t₀ + δ are obviously related. Even with encryption hiding contents, the timing relationship reveals which input became which output. +Without mixing, an observer watching a node could correlate inputs and outputs. If packets leave on a FIFO (First In First Out) basis, even with encryption hiding contents, the timing relationship reveals which input became which output. ## The solution -Each Mix Node adds a random delay before forwarding. Packets don't flow through in order—they're held for variable times and released in a different sequence than they arrived. An observer sees packets going in and packets coming out, but cannot match them. +Each Mix Node adds a random delay before forwarding. Packets don't flow through in order; they're held for variable times and released in a different sequence than they arrived. An observer sees packets going in and packets coming out, but cannot match them. ``` Input sequence: A B C D E @@ -813,25 +680,27 @@ The delays follow an exponential distribution. This choice is mathematically opt ## Why exponential delays -The exponential distribution is "memoryless"—the probability of a packet leaving in the next moment doesn't depend on how long it's already waited. This means the adversary cannot narrow down possibilities by noting how long packets have been in the node. +The exponential distribution is memoryless: the probability of a packet leaving in the next moment does not depend on how long it has already waited, so an adversary cannot narrow down possibilities by noting how long packets have been in the node. -Any other delay distribution leaks information. Fixed delays would let adversaries match arrivals to departures by timing. Uniform distributions would create windows where matches become more likely. The exponential distribution maximizes uncertainty. +Any other delay distribution leaks information; fixed delays would let adversaries match arrivals to departures by timing, and uniform distributions would create windows where matches become more likely. ## Continuous vs batch mixing Older mixnet designs collected packets into batches and shuffled them before release. This has problems: latency is unpredictable since you wait for batches to fill, bandwidth is inefficient due to bursty traffic, and the anonymity set is limited to the batch size. -Continuous-time mixing processes each packet independently. Latency is predictable (the mean delay is configurable). Bandwidth is used efficiently. And the anonymity set is unbounded—it includes all packets that have ever passed through, weighted by time. +Continuous-time mixing processes each packet independently. Latency is predictable (the mean delay is configurable), bandwidth is used efficiently, and the anonymity set is unbounded: it includes all packets that have ever passed through, weighted by time. ## The aggregate effect With three Mix Node layers, each applying random delays, the overall effect is thorough reordering. Packets entering the mixnet in sequence exit in a completely different order. The timing relationship between sending and receiving is destroyed. -This is why mixnet mode has higher latency than dVPN mode. The delays are the price of timing protection. Mean delays of 50-100ms per hop add up to 150-300ms average across three layers—noticeable, but worth it for the privacy gain. +These delays account for the additional latency of mixnet mode relative to dVPN mode. + +Updated latency measurements will be published after the Lewes Protocol release. ## Combined with cover traffic -Mixing and cover traffic work together. Cover traffic ensures there's always packets to mix, even during low activity. Mixing ensures that real and cover packets become interleaved and indistinguishable. Neither mechanism alone is sufficient—together they provide both unlinkability and unobservability. +Mixing and cover traffic are complementary. Cover traffic ensures there are always packets to mix, even during low activity, while mixing ensures that real and cover packets become interleaved and indistinguishable. Together they provide both unlinkability and unobservability. --- title: Anonymous Replies with SURBs @@ -845,13 +714,13 @@ SURBs (Single Use Reply Blocks) enable anonymous bidirectional communication. A ## The problem -In a typical mixnet scenario, Alice sends a message to Bob and wants a reply. If Bob sends directly to Alice's Nym address, he learns it. This defeats the purpose of anonymous communication—Bob now knows Alice's identity for future contact. +In a typical mixnet scenario, Alice sends a message to Bob and wants a reply, but if Bob sends directly to Alice's Nym address, he learns it. This defeats the purpose of anonymous communication; Bob now knows Alice's identity for future contact, and due to how Nym's [addressing scheme](/network/reference/addressing.md) works, this means that Bob knows which Gateway node Alice's client is using. ## How SURBs work -Alice creates SURBs—encrypted routing headers—and includes them with her message to Bob. Each SURB contains a complete route back to Alice, encrypted so that Bob cannot read it. Bob attaches his reply to a SURB and sends the resulting packet into the mixnet. It travels through the encoded route and arrives at Alice, but Bob never learns where it went. +Alice creates SURBs (encrypted routing headers) and includes them with her message to Bob. Each SURB contains a complete route back to Alice, encrypted so that Bob cannot read it. Bob attaches his reply to a SURB and sends the resulting packet into the mixnet. It travels through the encoded route and arrives at Alice, but Bob never learns where it went. -A SURB contains the address of the first hop (Alice's Entry Gateway), encrypted routing headers for the path back to Alice, and a key to encrypt the reply payload. The routing headers are layered like a Sphinx packet—each hop can only see the next destination. +A SURB contains the address of the first hop (Alice's Entry Gateway), encrypted routing headers for the path back to Alice, and a key to encrypt the reply payload. The routing headers are layered like a Sphinx packet; each hop can only see the next destination. ## Single use @@ -886,15 +755,15 @@ sequenceDiagram ## Sender tags -For sessions with multiple messages, Alice includes a randomly generated sender tag with her SURBs. This helps Bob organize SURBs from multiple conversations without revealing anything about Alice's identity—the tag is random and unlinkable to her address. +For sessions with multiple messages, Alice includes a randomly generated sender tag with her SURBs. This helps Bob organize SURBs from multiple conversations without revealing anything about Alice's identity; the tag is random and unlinkable to her address. ## Security considerations -There's a known attack where a malicious receiver hoards SURBs and sends them all back simultaneously, attempting to correlate traffic patterns at the sender's Gateway. This attack requires active participation (not just passive observation), costs money once zk-nyms are enabled, and provides limited information even if successful. It's not a passive surveillance technique—the attacker must be specifically targeting you and willing to spend resources. +There's a known attack where a malicious receiver hoards SURBs and sends them all back simultaneously, attempting to correlate traffic patterns at the sender's Gateway. This attack requires active participation (not just passive observation), and provides limited information even if successful. It's not a passive surveillance technique; the attacker must be specifically targeting you and willing to spend resources. ## Comparison to Tor onion addresses -Tor's onion addresses allow indefinite replies but require the recipient to run a hidden service. SURBs are single-use but require no service—they're generated on-demand per message. SURBs also benefit from the mixnet's timing protection, which onion addresses don't have. +Tor's onion addresses allow indefinite replies but require the recipient to run a hidden service. SURBs are single-use but require no service; they're generated on-demand per message. SURBs also benefit from the mixnet's timing protection, which onion addresses don't have. --- title: Nym Network Cryptography @@ -906,125 +775,37 @@ url: https://nym.com/docs/network/cryptography The Nym Network relies on several cryptographic systems working together. This section covers the algorithms, packet formats, and credential systems that provide privacy guarantees. -## Defense in depth - -There isn't a single cryptographic scheme protecting traffic — transport encryption secures connections between nodes, Sphinx packets add per-hop encryption so each node only learns where to forward rather than the full route, the payload itself is encrypted end-to-end, and zk-nyms keep payment separate from usage. - ## What's covered -[Encryption Standards](/network/cryptography/encryption-standards) documents the specific algorithms used throughout the network—Curve25519 for key exchange, AES and ChaCha20 for symmetric encryption, Lioness for wide-block encryption in Sphinx payloads. - [Sphinx Packets](/network/cryptography/sphinx) explains the packet format that enables layered encryption and anonymous routing. Each Sphinx packet contains routing information encrypted in layers, where each hop can only decrypt its own layer. [zk-nyms](/network/cryptography/zk-nym) covers the anonymous credential system that separates payment from usage. This is how you can pay for network access without that payment being linkable to your activity. ---- -title: Encryption Standards Used in Nym -description: Cryptographic algorithms used across the Nym Network: Curve25519 key exchange, ChaCha20-Poly1305, AES-GCM-SIV, Lioness wide-block encryption, Noise protocol, and post-quantum KEM. -url: https://nym.com/docs/network/cryptography/encryption-standards ---- - -# Encryption Standards - -This page documents the cryptographic algorithms used throughout the Nym Network. - -## Key exchange - -All key exchanges use **Curve25519** via X25519. This elliptic curve provides 128-bit security with fast, constant-time implementations and compact 32-byte keys. Nym uses it for Sphinx packet key derivation (ECDH with each hop), Gateway authentication, WireGuard tunnel handshakes, and session key establishment. - -Digital signatures use **Ed25519**, the signature scheme built on Curve25519. Node identity keys, client authentication, and QUIC TLS certificate verification all use Ed25519 signatures. - -## Authenticated encryption - -**ChaCha20-Poly1305** is the primary authenticated encryption scheme. It encrypts all WireGuard data packets in dVPN mode (via the `boringtun` and `wireguard-go` implementations), and is used in the Noise protocol handshakes and the OutFox packet format. It provides 256-bit security with authentication and performs well on devices without AES hardware acceleration. - -**AES-GCM-SIV-256** is used for Gateway-client shared key encryption (protocol version 3+). The SIV (Synthetic Initialization Vector) construction degrades gracefully if a nonce is accidentally reused — important in distributed systems where nonce management is harder. - -**AES-CTR-128** is used in Sphinx header encryption, where the stream cipher combines with blinding factors to create the layered encryption that each mix node peels away. - -## Node authentication - -The **Noise protocol** framework (via the `snow` crate) provides authenticated key exchange between nodes. Two cipher suites are in use: - -- `Noise_XKpsk3_25519_AESGCM_SHA256` -- `Noise_IKpsk2_25519_ChaChaPoly_BLAKE2s` - -These provide mutual authentication, forward secrecy, and resistance to key-compromise impersonation. - -## Wide-block encryption - -**Lioness** is a wide-block cipher used for Sphinx packet payloads. It's constructed from ChaCha20 and BLAKE2, encrypting the entire payload as a single block. This property is essential for Sphinx: modifying any part of the payload invalidates the entire payload, preventing certain manipulation attacks. - -The Lioness implementation is part of the external [`sphinx-packet`](https://github.com/nymtech/sphinx) crate used by Nym. - -## Hashing - -**BLAKE2** variants are used in the WireGuard Noise handshake (BLAKE2s) and in Lioness payload encryption (BLAKE2b via the sphinx-packet crate). - -**BLAKE3** is used for modern key derivation in the KKT protocol and data observatory components. - -**SHA-256** and **SHA-512** appear where compatibility with Cosmos SDK, HKDF, and standard tooling is required. - -## Key derivation - -**HKDF** (HMAC-based Key Derivation Function, RFC 5869) derives session keys from shared secrets. Both HKDF-SHA-256 and HKDF-SHA-512 variants are used, with HKDF-SHA-512 as the primary variant for `DerivationMaterial` in the SDK. - -**Argon2** is used for password-based key derivation when protecting locally stored keys and credentials. - -## Wallet cryptography - -**Secp256k1** (via the `k256` crate) and **ECDSA** handle transaction signing and key management for the Nyx blockchain, consistent with Cosmos SDK conventions. **BIP32** hierarchical deterministic key derivation supports hardware wallet integration via Ledger. - -## zk-nym cryptography - -The credential system uses **BLS12-381**, a pairing-friendly elliptic curve that enables threshold signatures, signature aggregation, and zero-knowledge proofs. The Nym API Quorum uses BLS for distributed key generation and threshold blind signatures. - -**Pedersen commitments** hide attribute values in credentials while allowing verification. **Zero-knowledge proofs** enable selective disclosure — proving properties about credentials without revealing the credentials themselves. - -## Post-quantum cryptography (in progress) - -The classical algorithms used today (Curve25519, BLS12-381) would be vulnerable to a sufficiently powerful quantum computer. Work is underway in the **KKT** (Key KEM Transport) module to add hybrid post-quantum key encapsulation using two NIST-standardised or finalist algorithms: - -- **ML-KEM** (formerly CRYSTALS-Kyber) — a lattice-based KEM, now a NIST standard (FIPS 203) -- **Classic McEliece** — a code-based KEM with decades of cryptanalysis behind it - -Both are available via the `libcrux` cryptographic library. The hybrid construction pairs these with classical X25519, so the system remains secure even if one primitive is broken. Post-quantum support will ship as part of the Lewes Protocol, which is currently in development. - -## References - -- [Sphinx paper](https://cypherpunks.ca/~iang/pubs/Sphinx_Oakland09.pdf) — Original Sphinx specification -- [Coconut paper](https://arxiv.org/pdf/1802.07344) — Credential scheme foundation -- [Offline Ecash paper](https://arxiv.org/pdf/2303.08221) — Compact ecash construction -- [WireGuard protocol](https://www.wireguard.com/protocol/) — dVPN tunnel specification -- [Noise protocol](http://www.noiseprotocol.org/) — Authenticated key exchange framework -- [Nym Whitepaper](https://nym.com/nym-whitepaper.pdf) — Full protocol description -- [Nym Trust Center: Cryptography](https://nym.com/trust-center/cryptography) — Up-to-date cryptographic overview - --- title: Sphinx Packet Format description: How Sphinx packets provide layered encryption for anonymous mixnet routing, with fixed-size payloads, per-hop key derivation, and integrity verification via HMACs. url: https://nym.com/docs/network/cryptography/sphinx --- -# Sphinx Packets +# Sphinx Sphinx is the cryptographic packet format used for all mixnet traffic. It provides layered encryption where each hop can only decrypt its own routing information, ensuring that no single node knows both the source and destination of a packet. ## How Sphinx works -When a client sends a message through the mixnet, it constructs a Sphinx packet with multiple encryption layers—one for each hop in the route. The outermost layer is encrypted for the first hop (Entry Gateway), the next layer for the second hop (Mix Node Layer 1), and so on until the innermost layer contains the actual payload encrypted for the recipient. +When a client sends a message through the mixnet, it constructs a Sphinx packet with multiple encryption layers, one for each hop in the route. The outermost layer is encrypted for the first hop (Entry Gateway), the next layer for the second hop (Mix Node Layer 1), and so on until the innermost layer contains the actual payload encrypted for the recipient. At each hop, the node uses its private key to decrypt its layer, revealing the address of the next hop and a new Sphinx packet to forward. The node cannot see any other routing information or the payload contents. ## Packet structure -All Sphinx packets have a fixed payload size of 2048 bytes. This uniformity is critical—if packets varied in size, nodes could infer their position in the route or correlate packets by size. +All Sphinx packets have a fixed payload size of 2048 bytes. This uniformity is critical: if packets varied in size, nodes could infer their position in the route or correlate packets by size. The packet contains a header with encrypted routing information for each hop, HMACs to verify integrity at each layer, and the encrypted payload. The header uses a clever "onion" structure where processing at each hop reveals only the next hop's information while maintaining constant size through padding. ## Integrity verification -Each layer includes an HMAC (Hash-based Message Authentication Code) that the receiving node verifies before processing. This prevents malicious nodes from modifying packet contents en route. If the HMAC doesn't match, the packet is dropped. +Each layer includes an HMAC (Hash-based Message Authentication Code) that the receiving node verifies before processing, which prevents malicious nodes from modifying packet contents en route. If the HMAC doesn't match, the packet is dropped. The payload uses Lioness wide-block encryption, which means any modification to any part of the payload invalidates the entire payload. This prevents bit-flipping attacks where an adversary might try to modify specific bytes. @@ -1044,9 +825,9 @@ Nym uses the [`sphinx-packet`](https://github.com/nymtech/sphinx) crate for core ## References -- [Sphinx paper](https://cypherpunks.ca/~iang/pubs/Sphinx_Oakland09.pdf) — Original specification and security proofs -- [Elle Mouton's Sphinx explainer](https://ellemouton.com/posts/sphinx/) — Detailed walkthrough of packet construction -- [Nym Whitepaper §4](https://nym.com/nym-whitepaper.pdf) — Sphinx in the context of Nym +- [Sphinx paper](https://cypherpunks.ca/~iang/pubs/Sphinx_Oakland09.pdf): Original specification and security proofs +- [Elle Mouton's Sphinx explainer](https://ellemouton.com/posts/sphinx/): Detailed walkthrough of packet construction +- [Nym Whitepaper §4](https://nym.com/nym-whitepaper.pdf): Sphinx in the context of Nym --- title: What are zk-nyms? @@ -1071,7 +852,7 @@ However, _who_ is not necessarily a question we want to be asking when designing - Does the entity taking this action have a right to do _X_? -This allows a different kind of security. Many of the computer systems we talk to every day don't need to know _who we are_, they only need to know if the entity kicking off a request has the _right to use_ the system. +This allows a different kind of security. Most networked services do not need to know _who_ is making a request, only whether the requester has the _right to use_ the system. The zk-nym scheme allows for this move to take place. Credentials are generated cooperatively by decentralised, trustless systems, and once the credentials are generated, they can be _re-randomized_; entirely new credentials, which no one has ever seen before, can be presented to the ingress point of the Nym Network, and validated without being linkable back to the signatures produced by the Quorum of credential signers used to generate them, or any credentials previously used by an entity wanting access. These properties allow zk-nyms to act as something like cryptographic bearer tokens generated by decentralised systems. The tokens can be mutated so that they are not traceable, but still verified with the original permissions intact. @@ -1097,7 +878,7 @@ Let's say you have a `message` with the content `This credential controls X` in 4. _[Threshold issuance](https://en.wikipedia.org/wiki/Threshold_cryptosystem)_ - allows signature generation to be split up across multiple nodes and decentralized, so that either all signers need to sign (_n of n_ where _n_ is the number of signers) or only a threshold number of signers need to sign a message (_t of n_ where _t_ is the threshold value). -Taken together, these properties provide privacy for applications when it comes to generating and using signatures for cryptographic claims. If you compare it to existing tech, you might think of it as a sort of supercharged decentralized privacy-friendly [JWT](https://jwt.io/). +Taken together, these properties provide privacy for applications when it comes to generating and using signatures for cryptographic claims. If you compare it to existing tech, the closest analogy in conventional systems is a decentralized, privacy-preserving [JWT](https://jwt.io/). --- title: Generating and using zk-nym anonymous credentials @@ -1106,14 +887,7 @@ url: https://nym.com/docs/network/cryptography/zk-nym/zk-nym-overview # Generating and using zk-nym anonymous credentials - The first use-case of zk-nyms is for anonymously proving the right to use the Nym mixnet for privacy. - - The Nym mixnet is - at the time of publication - free for everyone. However, soon™ it will be required for each connecting client to present a valid credential - a zk-nym - to their ingress Gateway to access the Mixnet. - - Accessing zk-nym credentials will vary depending on use: - - Individual developers building on the mixnet will be able to get zk-nym credentials via something like a faucet. - - Larger application integrations will have their own 'under the hood' credential generation and distribution scheme to generate access credentials on behalf of their users automatically. - - NymVPN users will have a variety of payment methods available to them. The vast majority, if not all of the steps outlined on this page, will happen under the hood from their perspective. _More on this soon_. + zk-nyms are already used in production by [NymVPN](https://nymvpn.com) to unlink subscription payments from network activity. The entire credential lifecycle described on this page (key generation, issuance, spending) happens transparently within the NymVPN application. SDK integrations currently connect to the Mixnet without requiring credentials. Generation of zk-nyms involves the following actors / pieces of infrastructure: - **Requester needing a zk-nym** for example a single user using the NymVPN app, or a company purchasing zk-nyms to distribute to their app users, in the instance of an app integrating a Mixnet client via one of the SDKs. The Requester is represented by a Bech32 address on the Nyx blockchain. @@ -1125,10 +899,10 @@ Generation happens in 3 distinct stages: - Issue credential - Generate unlinkable zk-nyms for Nym Network access -From the perspective of the Requester most of this happens under the hood, but results in the creation and usage of an **unlinkable, rerandomisable anonymous proof-of-payment credential** - a zk-nym - with which to access the Mixnet without fear of doxxing themselves via linking app usage and payment information. The user experience is further enhanced by the fact that a single credential can be split into multiple small zk-nyms, meaning that a Requester may buy a large chunk of bandwidth but 'spend' this in the form of multiple zk-nyms with different ingress Gateways. Whilst this happens under the hood, what it affords the Requester is an ease of experience in that they have to 'top up' their bandwidth less and are able to chop and change ingress points to the Nym Network as they see fit, akin to the UX of most modern day VPNs and dVPNs. +From the Requester's perspective this happens transparently, producing an unlinkable, rerandomisable anonymous proof-of-payment credential (a zk-nym) that grants Mixnet access without linking usage to payment information. A single credential can be split into multiple smaller zk-nyms, so a Requester purchases bandwidth in bulk and spends it incrementally across different ingress Gateways as needed. ## Key Generation & Payment -- First, a Cosmos [Bech32 address](https://docs.cosmos.network/main/build/spec/addresses/bech32) is created for the Requester. This is used to identify themselves when interacting with the OrderAPI via signed authentication tokens. **This is the only identity that the OrderAPI is able to see, and is not able to link this to the zk-nyms that will be generated.** This identity never leaves the Requester's device and there is no email or any personal details needed for signup. If a Requester is simply 'topping up' their subscription, the creation of the address is skipped as it already exists. +- First, a Cosmos [Bech32 address](https://docs.cosmos.network/sdk/latest/guides/reference/bech32#performance-address-caching) is created for the Requester. This is used to identify themselves when interacting with the OrderAPI via signed authentication tokens. **This is the only identity that the OrderAPI is able to see, and is not able to link this to the zk-nyms that will be generated.** This identity never leaves the Requester's device and there is no email or any personal details needed for signup. If a Requester is simply 'topping up' their subscription, the creation of the address is skipped as it already exists. - The Requester also generates an ed25519 keypair: this is used to identify and authenticate them in the case of using zk-nyms across several devices as an individual user. However, **this is never used in the clear**: these keys are used as private attribute values within generated credentials which are verified via zero-knowledge and not publicly exposed. - The Requester can then interact with various payment backends to pay for their zk-nyms with crypto, fiat options, or natively with NYM tokens. @@ -1166,14 +940,12 @@ url: https://nym.com/docs/network/cryptography/zk-nym/rerandomise Each ticket will not be valid for the entire amount of data that the ticketbook aggregated from the PSCs is; if the aggregated ticketbook is worth (e.g.) 10GB of Mixnet data, each ticket will be worth far less (e.g. 100MB). This amount will be globally uniform in order to avoid situations where differently sized tickets allow for patterns to emerge. - The functionality included in the following code block examples were added to the [nym-cli tool](/developers/tools/nym-cli) for illustrative purposes only: this is not necessarily how credentials will be accessed in the future. - - The numbers used in this high level overview are for illustration purposes only. The figures used in production will potentially vary. Note that individual ticket sizes will be uniform across the Network. + The `nym-cli` examples below are for illustration only and do not reflect how credentials are accessed in production. The specific figures (ticket counts, bandwidth amounts) are illustrative; production values may differ, though individual ticket sizes are uniform across the network. ## Why a 'ticketbook', not individual 'tickets', and why not spend them all at once? This is to account for the need for a client to change their ingress Gateway, either because the Gateway itself has gone down / is not offering the required bandwidth, or because a user might simply want to split their traffic across multiple Gateways for extra privacy. -This means that clients are not tied to particular Gateways they have 'spent' their entire subscription amount with; if the ingress Gateway goes down, or the client simply wishes to use another ingress Gateway, the user has multiple other tickets they can use that account for their remaining purchased bandwidth. +Clients are therefore not tied to a particular Gateway they have spent their entire subscription with. If an ingress Gateway goes down, or the client simply wants to use a different one, remaining tickets can be spent with any other Gateway. Going back to the `nym-cli` tool to illustrate this; we can generate multiple unlinkable tickets from a single ticketbook aggregated from PSCs: @@ -1298,12 +1070,12 @@ url: https://nym.com/docs/network/infrastructure # Infrastructure -The Nym Network runs on decentralized infrastructure — a set of independently operated nodes coordinated by the Nyx blockchain. No single party controls routing, key management, or credential issuance. +The Nym Network runs on decentralized infrastructure: a set of independently operated nodes coordinated by the Nyx blockchain, where no single party controls routing, key management, or credential issuance. ## In this section -- [Nyx Blockchain](/network/infrastructure/nyx) — the Cosmos SDK chain that maintains the node registry, manages token economics, and hosts the smart contracts for credentials and rewards -- [Nym Nodes](/network/infrastructure/nym-nodes) — the unified `nym-node` binary that operates as Entry Gateways, Mix Nodes, or Exit Gateways depending on network demand +- [Nyx Blockchain](/network/infrastructure/nyx): the Cosmos SDK chain that maintains the node registry, manages token economics, and hosts the smart contracts for credentials and rewards +- [Nym Nodes](/network/infrastructure/nym-nodes): the unified `nym-node` binary that operates as Entry Gateways, Mix Nodes, or Exit Gateways depending on network demand --- title: Nyx Blockchain @@ -1319,9 +1091,9 @@ To interact with the chain, see [Interacting with Nyx](/developers/chain). To ru ## Role in the network -The blockchain serves several functions. It maintains the **topology registry**—the list of active nodes and their public keys. This eliminates the need for a centralized directory server and prevents attacks that plague peer-to-peer directory systems. +The blockchain serves several functions, including maintaining the **topology registry**: the list of active nodes and their public keys. This eliminates the need for a centralized directory server and prevents attacks that plague peer-to-peer directory systems. -It manages **token economics**. The NYM token is a native token of the chain, used for staking, rewards, and credential payments. Validators secure the chain via proof-of-stake consensus. +It manages **token economics**, where the NYM token is a native token of the chain, used for staking, rewards, and credential payments. Validators secure the chain via proof-of-stake consensus. And it hosts **smart contracts** for mixnet coordination and the zk-nym credential system. @@ -1333,7 +1105,7 @@ Nyx Validators run the `nyxd` binary to maintain the blockchain. They process tr For setup instructions, see the [Nym API Operator Guide](/operators/nodes/validator-setup/nym-api). -The Nym API is operated by a subset of validators forming the "Quorum." This group performs network monitoring—sending test packets through the mixnet and calculating reliability scores for nodes. More critically, it handles credential issuance, generating the partial blind signatures that form zk-nyms. +The Nym API is operated by a subset of validators forming the "Quorum." This group performs network monitoring: sending test packets through the mixnet and calculating reliability scores for nodes. More critically, it handles credential issuance, generating the partial blind signatures that form zk-nyms. The Quorum uses threshold cryptography. No single member can issue credentials alone. The system remains functional even if some members are offline. This distributes trust across multiple independent parties. @@ -1354,23 +1126,23 @@ url: https://nym.com/docs/network/infrastructure/nym-nodes # Nym Nodes -All traffic-routing infrastructure runs on the `nym-node` binary. This unified binary operates in different modes—Entry Gateway, Mix Node, or Exit Gateway—simplifying deployment and enabling future dynamic role assignment. +All traffic-routing infrastructure runs on the `nym-node` binary. This unified binary operates in different modes (Entry Gateway, Mix Node, or Exit Gateway), simplifying deployment and enabling future dynamic role assignment. To run a node, see the [Operator Documentation](/operators/introduction). ## Node modes -**Entry Gateways** are the user's first point of contact with the network. They accept WebSocket connections from clients, verify zk-nym credentials to confirm payment, and store messages for clients that go offline (up to 24 hours). Entry Gateways know the client's IP address but cannot see message contents or final destinations. +**Entry Gateways** are the user's first point of contact with the network. They accept WebSocket connections from clients, verify zk-nym credentials to confirm payment, and store messages for clients that go offline. Entry Gateways know the client's IP address but cannot see message contents or final destinations. **Mix Nodes** form the three mixing layers that provide core privacy. They receive Sphinx packets, remove one encryption layer, verify integrity, apply a random delay, and forward to the next hop. Mix Nodes cannot determine their position in the route and cannot link incoming packets to outgoing packets. -**Exit Gateways** handle traffic leaving the mixnet. They communicate with external internet services on behalf of users and return responses through the network. Exit Gateways can see destination addresses but cannot identify the original sender. +**Exit Gateways** handle traffic leaving the mixnet. They run two proxy services: the [Network Requester](/network/infrastructure/exit-services#network-requester) (a SOCKS proxy for application-layer requests) and the [IP Packet Router](/network/infrastructure/exit-services#ip-packet-router) (a raw IP tunnel used by NymVPN and smolmix). Exit Gateways can see destination addresses but cannot identify the original sender. See [Exit Gateway Services](/network/infrastructure/exit-services) for details. ## Unified binary -The various components were originally separate binaries. They've been consolidated into a single `nym-node` binary where the role is specified at runtime. This simplifies operation and makes configuration consistent across roles. +The various components were originally separate binaries but have been consolidated into a single `nym-node` binary where the role is specified at runtime. This simplifies operation and makes configuration consistent across roles. -In the future, nodes will automatically switch modes based on network conditions. Operators won't need to manually set whether a node is a Gateway or Mix Node—the network will assign modes dynamically each epoch. +In the future, nodes will automatically switch modes based on network conditions. Operators won't need to manually set whether a node is a Gateway or Mix Node; the network will assign modes dynamically each epoch. ## Nym clients @@ -1384,6 +1156,85 @@ Client types include native Rust clients, WASM clients for browsers, the SOCKS5 The current deployment includes {stats.nodes} active nodes across {stats.locations} countries, operated by independent parties worldwide. This includes {stats.mixnodes} Mix Nodes and {stats.exit_gateways} Exit Gateways. Running a node requires meeting minimum hardware specifications, bonding NYM tokens as collateral, and maintaining high uptime for rewards. +--- +title: Exit Gateway Services: Network Requester & IP Packet Router +description: The two proxy services running on Nym Exit Gateways, the Network Requester (SOCKS proxy) and the IP Packet Router (raw IP tunneling), how they work, what they see, and who uses them. +url: https://nym.com/docs/network/infrastructure/exit-services +--- + +# Exit Gateway Services + +Exit Gateways are where traffic leaves the Nym network and reaches the wider internet. Each Exit Gateway runs two distinct proxy services that handle different kinds of outbound traffic: + +- **Network Requester (NR)**, an application-layer SOCKS proxy +- **IP Packet Router (IPR)**, a raw IP tunnel with address allocation + +Both services run on every Exit Gateway. Which one handles your traffic depends on how you connect. + +## Network Requester + +The Network Requester is a SOCKS4/4a/5 proxy. Clients send SOCKS-formatted requests through the mixnet, and the NR makes the corresponding connection on their behalf: resolving hostnames, opening TCP connections, and relaying data. + +```text +Client → mixnet → Exit Gateway (NR) → SOCKS connect → destination + ← relay response ← +``` + +Because it operates at the application layer, the NR: +- Resolves DNS on behalf of the client (the client sends hostnames, not IPs) +- Opens individual TCP connections per SOCKS request +- Can enforce allow/deny lists on destination hosts and ports +- Sees the destination hostname and port, but not the contents if TLS is used + +**Used by:** the [SDK's SOCKS client](/developers/rust/mixnet), [standalone SOCKS5 client](/developers/clients/socks5), and [mixFetch](/developers/typescript#mixfetch) (which wraps SOCKS requests in a browser-friendly `fetch` API). + +## IP Packet Router + +The IP Packet Router operates at the IP layer. Instead of proxying individual connections, it allocates a virtual IP address to the client and routes raw IP packets between the client and the internet, functioning as a tunnel endpoint. + +```text +Client → mixnet → Exit Gateway (IPR) → raw IP packets → destination + ← raw IP packets ← +``` + +On connection, the IPR: +1. Allocates an IPv4/IPv6 address pair to the client +2. Accepts raw IP packets (TCP, UDP, or any IP protocol) from the client via the mixnet +3. Sends them to the internet from the gateway's own IP address +4. Routes response packets back through the mixnet to the client + +Because it operates at the IP layer, the IPR: +- Does not resolve DNS; the client handles its own DNS (either via clearnet or by sending DNS queries as UDP packets through the tunnel) +- Handles any IP protocol, not just TCP: UDP, ICMP, etc. +- Sees raw IP packets, including destination IPs and ports +- Does not see contents if the client uses TLS or another encryption layer + +In both services, traffic between the Exit Gateway and the destination travels as **normal internet traffic**. The mixnet protects sender anonymity (the destination sees the gateway's IP, not yours), but does not encrypt the final hop. Use TLS or another end-to-end encryption layer to protect payload confidentiality. + +**Used by:** [NymVPN anonymous mode](/network/dvpn-mode/protocol) (5-hop mixnet routing to the IPR), and [`smolmix`](/developers/smolmix) (programmatic `TcpStream`/`UdpSocket` access to the IPR via the Rust SDK). + +## Comparison + +| | Network Requester | IP Packet Router | +|---|---|---| +| **Layer** | Application (SOCKS) | IP (raw packets) | +| **Protocols** | TCP only | TCP, UDP, any IP protocol | +| **DNS** | Resolved by the NR | Client resolves its own | +| **Client gets** | Proxied connections | An allocated IP address | +| **Connection model** | Per-request | Persistent tunnel | +| **Used by** | SDK SOCKS client, mixFetch | NymVPN (anonymous mode), smolmix | + +## Trust model + +Both services share the same fundamental trust property: **the Exit Gateway can see destinations but not senders.** The mixnet's layered encryption ensures that the Exit Gateway cannot determine who sent a given packet; it only knows where it's going. + +Specifically, the Exit Gateway: +- **Can see:** destination IP/hostname, destination port, unencrypted payload content, traffic volume and timing at the exit hop +- **Cannot see:** the sender's IP address, the sender's Nym address, which Entry Gateway the traffic entered through +- **Cannot determine:** the linkage between different requests from the same sender (unless the payload itself contains identifying information) + +The sender's identity is protected by the mixnet's 5-hop routing, Sphinx encryption, cover traffic, and packet mixing. The Exit Gateway is the last hop: it decrypts the final Sphinx layer and sees the destination, but the chain of Mix Nodes between Entry and Exit has destroyed any timing or ordering correlation. + --- title: Nym Network Reference description: Technical specifications and protocol details for the Nym Network: addressing format, epoch timing, and the hop-by-hop acknowledgement system. @@ -1396,9 +1247,9 @@ Technical specifications and protocol details that apply across the Nym Network ## In this section -- [Addressing](/network/reference/addressing) — the `identity.encryption@gateway` address format and how routing works -- [Epochs](/network/reference/epochs) — time divisions in the network, reward distribution, and topology reshuffling -- [Acknowledgements](/network/reference/acks) — the hop-by-hop packet delivery confirmation system +- [Addressing](/network/reference/addressing): the `identity.encryption@gateway` address format and how routing works +- [Epochs](/network/reference/epochs): time divisions in the network, reward distribution, and topology reshuffling +- [Acknowledgements](/network/reference/acks): the hop-by-hop packet delivery confirmation system --- title: Nym Network Addressing @@ -1418,7 +1269,7 @@ A Nym address has three parts separated by dots and an @ symbol: .@ ``` -The **identity key** identifies the client for routing purposes. It's derived from the client's Ed25519 keypair and base58-encoded for readability. +The **identity key** identifies the client for routing purposes and is derived from the client's Ed25519 keypair and base58-encoded for readability. The **encryption key** is the public key used to encrypt the final layer of Sphinx packets destined for this client. Only the client holding the corresponding private key can decrypt messages addressed to them. @@ -1448,11 +1299,11 @@ url: https://nym.com/docs/network/reference/epochs # Epochs -Time in the Nym Network is organized into epochs—discrete periods during which certain network operations occur. The current epoch length is one hour. +Time in the Nym Network is organized into epochs: discrete periods during which certain network operations occur. The current epoch length is one hour. ## What happens at epoch boundaries -**Reward distribution** calculates performance metrics for each node and distributes NYM token rewards based on routing reliability and uptime. Nodes that successfully forward packets earn more than those with poor performance. +**Reward distribution** calculates performance metrics for each node and distributes NYM token rewards based on routing reliability and uptime, ensuring that nodes successfully forwarding packets earn more than those with poor performance. **Topology rerandomization** shuffles the arrangement of nodes in each layer. This prevents long-term route prediction attacks and limits the damage from any compromised nodes. Nodes may also enter or leave the active set based on uptime monitoring and stake changes. @@ -1462,7 +1313,7 @@ In upcoming releases, epochs will trigger automatic role assignment. Nodes will ## SURB validity -SURBs are tied to key rotation cycles. Node keys rotate on an odd/even schedule with a default validity of 24 epochs. A SURB remains usable for `(validity_epochs + 1) * epoch_duration` — roughly 25 hours at the current 1-hour epoch. After that, the routing keys it was built with are no longer accepted by the network. Clients automatically purge stale SURBs and request fresh ones. +SURBs are tied to key rotation cycles. Node keys rotate on an odd/even schedule with a default validity of 24 epochs. A SURB remains usable for `(validity_epochs + 1) * epoch_duration`, roughly 25 hours at the current 1-hour epoch. After that, the routing keys it was built with are no longer accepted by the network. Clients automatically purge stale SURBs and request fresh ones. ## Querying epoch information @@ -1486,7 +1337,7 @@ This happens automatically at each hop. If a client sends 100 packets to a Gatew ## Why it matters -Network conditions can cause packet loss—congestion, temporary failures, connectivity issues. Without acks and retransmission, lost packets would mean lost messages. The acknowledgement system ensures reliable delivery despite imperfect network conditions. +Network conditions can cause packet loss: congestion, temporary failures, connectivity issues. Without acks and retransmission, lost packets would mean lost messages. The acknowledgement system ensures reliable delivery despite imperfect network conditions. ## Scope @@ -1494,7 +1345,9 @@ Acknowledgements operate hop-by-hop between adjacent nodes. They confirm that pa ## Implementation -This is handled entirely by the Nym binaries. Developers and operators don't need to implement or configure acknowledgements—the system handles packet loss transparently. +This is handled entirely by the Nym binaries. Developers and operators don't need to implement or configure acknowledgements; the system handles packet loss transparently. + +**Lewes Protocol:** The upcoming Lewes release will introduce changes to how acknowledgements are handled. The current hop-by-hop ACK mechanism described above may be revised as part of broader protocol improvements. Details will be documented here once the changes are finalised. --- title: Licensing @@ -1548,15 +1401,16 @@ Build applications that protect user metadata using the Nym Mixnet. This section ## Where to start -**Choosing an integration approach** — read [Integrations](./integrations) to understand the architectural trade-offs (native SDK vs proxy vs mixFetch), then pick your path: +**Choosing an integration approach:** read [Integrations](/developers/integrations) to understand the architectural trade-offs (native SDK vs proxy vs mixFetch), then pick your path: -- **[Rust SDK](./rust)** — full-featured SDK with message passing, `AsyncRead`/`AsyncWrite` streams, and client pooling. Start with the [Tour](./rust/tour). -- **[TypeScript SDK](./typescript)** — browser and Node.js SDK for mixFetch, Mixnet client, and smart contract interaction. -- **[Standalone Clients](./clients)** — language-agnostic SOCKS5 and WebSocket proxies for piping traffic through the Mixnet without an SDK. +- **[Rust SDK](/developers/rust):** full-featured SDK with message passing, `AsyncRead`/`AsyncWrite` streams, and client pooling. Start with the [Tour](/developers/rust/tour). +- **[`smolmix`](/developers/smolmix):** standalone crate providing `TcpStream` and `UdpSocket` over the Mixnet via a userspace IP stack. Drop-in compatible with `tokio-rustls`, `hyper`, `tungstenite`, and the rest of the async Rust ecosystem. Also serves as the core for companion crates that plug into specific frameworks (e.g. hyper connectors, DNS resolvers). +- **[TypeScript SDK](/developers/typescript):** browser and Node.js SDK for mixFetch, Mixnet client, and smart contract interaction. +- **[Standalone Clients](/developers/clients):** language-agnostic SOCKS5 and WebSocket proxies for piping traffic through the Mixnet without an SDK. ## Blockchain interaction -The Nym Network is coordinated by the [Nyx blockchain](/network/infrastructure/nyx). To query chain state, submit transactions, or interact with smart contracts, see [Chain Interaction](./chain). +The Nym Network is coordinated by the [Nyx blockchain](/network/infrastructure/nyx). To query chain state, submit transactions, or interact with smart contracts, see [Chain Interaction](/developers/chain). ## API reference @@ -1574,15 +1428,16 @@ Build applications that protect user metadata using the Nym Mixnet. This section ## Where to start -**Choosing an integration approach** — read [Integrations](./integrations) to understand the architectural trade-offs (native SDK vs proxy vs mixFetch), then pick your path: +**Choosing an integration approach:** read [Integrations](/developers/integrations) to understand the architectural trade-offs (native SDK vs proxy vs mixFetch), then pick your path: -- **[Rust SDK](./rust)** — full-featured SDK with message passing, `AsyncRead`/`AsyncWrite` streams, and client pooling. Start with the [Tour](./rust/tour). -- **[TypeScript SDK](./typescript)** — browser and Node.js SDK for mixFetch, Mixnet client, and smart contract interaction. -- **[Standalone Clients](./clients)** — language-agnostic SOCKS5 and WebSocket proxies for piping traffic through the Mixnet without an SDK. +- **[Rust SDK](/developers/rust):** full-featured SDK with message passing, `AsyncRead`/`AsyncWrite` streams, and client pooling. Start with the [Tour](/developers/rust/tour). +- **[`smolmix`](/developers/smolmix):** standalone crate providing `TcpStream` and `UdpSocket` over the Mixnet via a userspace IP stack. Drop-in compatible with `tokio-rustls`, `hyper`, `tungstenite`, and the rest of the async Rust ecosystem. Also serves as the core for companion crates that plug into specific frameworks (e.g. hyper connectors, DNS resolvers). +- **[TypeScript SDK](/developers/typescript):** browser and Node.js SDK for mixFetch, Mixnet client, and smart contract interaction. +- **[Standalone Clients](/developers/clients):** language-agnostic SOCKS5 and WebSocket proxies for piping traffic through the Mixnet without an SDK. ## Blockchain interaction -The Nym Network is coordinated by the [Nyx blockchain](/network/infrastructure/nyx). To query chain state, submit transactions, or interact with smart contracts, see [Chain Interaction](./chain). +The Nym Network is coordinated by the [Nyx blockchain](/network/infrastructure/nyx). To query chain state, submit transactions, or interact with smart contracts, see [Chain Interaction](/developers/chain). ## API reference @@ -1590,19 +1445,32 @@ Auto-generated API specs for Nym infrastructure endpoints are in the [APIs secti --- title: Integrating With Nym +description: Choose an integration path for sending application traffic through the Nym mixnet, depending on your runtime environment and architecture. url: https://nym.com/docs/developers/integrations --- # Integrating With Nym -Any application that wants to integrate with Nym involves sending its application traffic through the Mixnet using one of the available Nym Clients. There is no single solution for this, as different environments offer different access and transport options (e.g. if operating in a web browser, you do not have access to syscalls or sockets, have to deal with content security policies, etc). -As such, we have several solutions available for developers to choose from depending on the **environment** their application is expected to run in: native apps which are running on a desktop, or webapps running in a browser. +Any application that integrates with Nym sends its traffic through the Mixnet via a Nym client. The right integration path depends on two factors: **environment** and **architecture**. - The list of current options available to developers to do not cover all environments and setups - we are working on expanding this list and approaching more general solutions, but there is no one-size-fits-all approach when dealing with rerouting network traffic. +## Environment -Integration options are then further subdivided by app **architecture**; whether the application interacts with remote hosts on the public internet running independently of the app (e.g. public blockchain RPC endpoints, third-party APIs) or whether app developers have some control over the versions of the software being run on both sides of an interaction (e.g. peer to peer apps running the same software version, or client-server architectures which are running software written by the same team). +Different runtimes have different transport constraints: a browser cannot open raw sockets or access the filesystem, while a desktop app can. - This is because of the different security considerations each option offers. These are detailed in the following pages. +- **Native / Desktop**: full access to system networking and persistent storage. Use the [Rust SDK](/developers/rust) or [`smolmix`](/developers/smolmix). +- **Browser**: restricted to WebSockets, Web Transport, and `fetch`, with HTTPS-only mixed content rules and no filesystem access. Use the [TypeScript SDK](/developers/typescript). + +## Architecture + +The second factor is whether you control both sides of the communication. + +**End-to-end (E2E)**: both sides run Nym clients. All traffic stays Sphinx-encrypted the entire way. Appropriate for peer-to-peer setups or any case where you control both endpoints. + +**Proxy**: only the client side runs Nym. Traffic exits the Mixnet at an Exit Gateway and continues to the destination as normal internet traffic. Appropriate when connecting to third-party services (blockchain RPCs, external APIs) that you do not control. For Rust, [`smolmix`](/developers/smolmix) provides `TcpStream` and `UdpSocket` types that work as drop-in replacements for their tokio equivalents. + +In proxy mode, the last hop from Exit Gateway to the remote host travels as standard internet traffic. This is weaker than E2E against a global passive adversary, but still provides timing obfuscation and sender-receiver unlinkability. + +See the [Native / Desktop](/developers/native) and [Browser](/developers/browsers) pages for the specific modules available in each environment. --- title: Native and Desktop App Integration @@ -1612,52 +1480,36 @@ url: https://nym.com/docs/developers/native # Native / Desktop Apps -Developers wanting to integrate into desktop apps and CLIs can use our [Rust SDK](./rust). There are two broad approaches to using the Mixnet (E2E or as a proxy), with different modules suited for each. +Desktop apps and CLIs integrate with two broad approaches: embedding Nym clients on both sides of the communication (E2E), or using the Mixnet as a proxy to reach external services. -## Option 1: Mixnet End-To-End -Embed Nym Clients in both sides of your app and have them send all network traffic through the Mixnet: a peer-to-peer setup, or a client and server where you control both sides. +## Mixnet End-To-End +Both sides of your app run Nym clients. All traffic stays Sphinx-encrypted the entire way. Works for peer-to-peer setups or any case where you control both ends. ![](/images/developers/nym-arch-client-to-client.png) -### Stream Module -The [Stream module](./rust/stream) provides `AsyncRead + AsyncWrite` byte streams multiplexed over the mixnet. If you're used to working with TCP sockets, this is the closest analog — open a stream, read and write bytes. +| SDK Module | What it does | Status | Links | +|---|---|---|---| +| **Stream** | `AsyncRead + AsyncWrite` byte streams multiplexed over the mixnet, the closest analogue to TCP sockets | Recommended | [docs](/developers/rust/stream) · [tutorial](/developers/rust/stream/tutorial) | +| **Mixnet** | Raw message API and `MixnetClient` for full control over the communication model | Stable | [docs](/developers/rust/mixnet) · [tutorial](/developers/rust/mixnet/tutorial) | +| **Client Pool** | Pre-connected client pool for bursty workloads | Stable | [docs](/developers/rust/client-pool) | +| **TcpProxy** | Localhost TCP sockets that proxy traffic through the mixnet | Unmaintained | [docs](/developers/rust/tcpproxy) | -- [docs](./rust/stream) -- [tutorial](./rust/stream/tutorial) +**TcpProxy is unmaintained.** Use the [Stream module](/developers/rust/stream) for new projects. -### Mixnet & Client Pool Modules -The [Mixnet module](./rust/mixnet) exposes the raw message-based API and `MixnetClient`. The [Client Pool](./rust/client-pool) pre-creates clients in the background for bursty traffic patterns. - -Use these when you need full control over the communication model, or when you're building custom connection logic on top of the raw message API. - -- [docs](./rust/mixnet) -- [tutorial](./rust/mixnet/tutorial) - -### TcpProxy Module (Unmaintained) - -**This module is unmaintained.** Use the [Stream module](./rust/stream) for new projects. Existing users should plan to migrate when possible. - -A pair of abstractions that expose localhost TCP sockets for proxying traffic through the mixnet. - -- [docs](./rust/tcpproxy) - -## Option 2: Mixnet-As-Proxy -For developers who can only control the client side, and need to communicate with a 3rd party service such as a public blockchain RPC or a remote host they do not control. +## Mixnet-As-Proxy +For cases where you only control the client side and need to reach a third-party service such as a blockchain RPC or remote API. ![](/images/developers/nym-arch-ip-routing.png) -### Security Considerations +Traffic is Sphinx-encrypted until the Exit Gateway, where it's unwrapped into HTTPS ([Network Requester](/network/infrastructure/exit-services#network-requester)) or raw IP ([IP Packet Router](/network/infrastructure/exit-services#ip-packet-router)). The last hop to the remote host **travels as normal internet traffic**. Use TLS or another encryption layer to protect the final hop. -Since traffic is only packaged as Sphinx until it gets to the Exit Gateway, where it is unwrapped into either HTTPS packets (by a Network Requester) or IP packets (by an IP Packet Router), the last hop between the Gateway and the remote host **travels as normal internet traffic**. +| Standalone Crate | What it does | Links | +|---|---|---| +| **`smolmix`** | Userspace IP tunnel providing `TcpStream` and `UdpSocket` over the mixnet, compatible with the entire async Rust ecosystem. Also serves as the core for companion crates that plug into specific frameworks (e.g. hyper connectors, DNS resolvers) | [docs](/developers/smolmix) | -This option has fewer protections than the E2E option against a global passive adversary, but still grants you timing obfuscation and sender-receiver unlinkability between your client software and whatever service it is interacting with. - -### SOCKS Client -Developers with apps that support SOCKS4, 4a, or 5 can use the Socks Client exposed by the Mixnet module. This uses the Network Requester service of the chosen Exit Gateway to interact with the remote host via the chosen SOCKS proxy protocol. The Network Requester uses SURBs to anonymously reply to the original sender. - -- [docs](./rust/mixnet) - -Development is in progress to allow for this proxy method from native Rust, C, and Go without requiring a separate SOCKS client. Stay tuned. +| SDK Module | What it does | Links | +|---|---|---| +| **SOCKS Client** | SOCKS4/4a/5 proxy via the Exit Gateway's Network Requester. Works with any SOCKS-capable application without code changes, just point it at the local proxy | [docs](/developers/rust/mixnet) | --- title: Browser-Based App Integration @@ -1666,43 +1518,21 @@ url: https://nym.com/docs/developers/browsers --- # Browser-Based Apps -Browsers are a very restricted environment to work in, with limited options for external communications (websockets, Web Transport API, WebRTC), mixed content restrictions (HTTPS-only), and no access to the file system or any syscalls. These aside, the main issue when trying to capture traffic and send it via a different transport - such as the Mixnet - is the lack of access to browser TLS negotiation from JS or the CA certificate store. -This means that the functionality offered by our current browser-based solutions are quite restricted / specific. There are currently two options for interacting with the Mixnet from the browser: `mixFetch`, and the WASM SDK. +Browsers are a restricted environment: communication is limited to WebSockets, Web Transport, and WebRTC; mixed content policies enforce HTTPS-only; and there is no access to the filesystem or system calls. The main obstacle for routing traffic through the Mixnet is the lack of access to browser TLS negotiation or the CA certificate store from JavaScript. + +Two integration options are available, both delivered as packages bundled into your web application. ![](/images/developers/nym-browser-arch.png) -Both `mixFetch` and the WASM client are delivered to the client bundled into a web application. +| Module | What it does | Links | +|---|---|---| +| **mixFetch** | Drop-in `fetch` replacement. Routes HTTP(S) requests via Exit Gateways with an embedded CA store for browser-to-destination TLS over the Mixnet | [docs](/developers/typescript#mixfetch) · [example](/developers/typescript/playground/mixfetch) | +| **WASM Client** | Sphinx packets and cover traffic in WASM, sent over WebSocket to the Entry Gateway. Messaging mode only (text/binary payloads), runs in a web worker | [docs](/developers/typescript#mixnet-client) · [example](/developers/typescript/playground/traffic) | -## mixFetch -Drop-in replacement for browser's `fetch` API that makes HTTP(S) requests via Exit Gateways using the SOCKS Network Requester. +`mixFetch` currently supports a maximum of 10 concurrent in-flight requests. `mixFetchv2`, which will function as a general-purpose userspace IP stack, is in development. -Uses an embedded CA certificate store to establish TLS session between `mixFetch` and the remote host, creating a client-host secure channel from the browser to the host over the Mixnet. - -Internally it uses the WASM client. - -- [docs](./typescript#mixfetch) -- [example](./typescript/playground/mixfetch) - - ### Current Limitations of `mixFetch` - - `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. - -## WASM Client -Makes Sphinx packets and cover traffic using WASM and sent over a Websocket to the Entry Gateway and receive responses. - -This only works in messaging mode (i.e. messages sent either as text or binary data), and currently doesn’t support making IP packets that are routed to the Internet by an Exit Gateway IPR, nor does it currently expose any stream-like API. If you want to send HTTP(S) requests, use `mixFetch`. - -Note that the limitations of CSPs and Mixed Content restrictions (i.e HTTPS only) apply to the Websocket connection as normal in browsers or embedded WebViews. - -Runs in a web worker to leave UI thread free for the user. - -- [docs](./typescript#mixnet-client) -- [example](./typescript/playground/traffic) +The WASM Client does not support IP packet routing (IPR) or stream-like APIs. For HTTP(S) requests from the browser, use `mixFetch`. Standard browser CSP and mixed content restrictions (HTTPS only) apply to the WebSocket connection. --- title: Nym Client Message Queue and Cover Traffic @@ -1712,7 +1542,7 @@ url: https://nym.com/docs/developers/concepts/message-queue # Message Queue - Although good to understand how the Nym Client works under the hood, this information is only of practical use if you're using the [`Mixnet`](../rust/mixnet) module of the Rust SDK and interacting with the client at a low level. Most of this is abstracted away by the [`Stream`](../rust/stream) module (`AsyncRead + AsyncWrite` channels) and the [`TcpProxy`](../rust/tcpproxy) module (TCP tunnelling with message ordering). + Although useful for understanding how the Nym Client works internally, this information is only of practical use if you are using the [`Mixnet`](/developers/rust/mixnet) module of the Rust SDK and interacting with the client at a low level. Most of this is abstracted away by the [`Stream`](/developers/rust/stream) module (`AsyncRead + AsyncWrite` channels) and the [`TcpProxy`](/developers/rust/tcpproxy) module (TCP tunnelling with message ordering). ## Sphinx Packet Streams Clients, once connected to the Mixnet, **are always sending traffic into the Mixnet**; as well as the packets that you as a developer are sending from your application logic, they send [cover traffic](/network/mixnet-mode/cover-traffic) at a constant rate defined by a Poisson process. This is part of the network's mitigation of timing attacks. @@ -1783,10 +1613,136 @@ sequenceDiagram When passing a message to a client (however you do it, either piping messages from an app to a standalone client or via one of the `send` functions exposed by the SDKs), you are **putting that message into the queue** to be source encrypted and sent in the future, in order to ensure that traffic leaving the client does so in a manner that to an external observer is uniform / does not create any 'burst' or change in traffic timings that could aid traffic analysis. ## Note on Client Shutdown -Accidentally dropping a client before your message has been sent is something that is possible and should be avoided (see the [troubleshooting guide](../rust/mixnet/troubleshooting) for more on this) but is easy to avoid simply by remembering to: +Accidentally dropping a client before your message has been sent is something that is possible and should be avoided (see the [troubleshooting guide](/developers/rust/mixnet/troubleshooting) for more on this) but is easy to avoid simply by remembering to: - keep your client process alive, even if you are not expecting a reply to your message - (in the case of the SDKs) properly disconnecting your client in order to make sure that the message queue is flushed of Sphinx packets with actual payloads. +--- +title: smolmix: TCP/UDP Over the Nym Mixnet +description: A userspace IP tunnel that provides standard TcpStream and UdpSocket types over the Nym mixnet. Drop-in compatible with async tokio Rust ecosystem. +url: https://nym.com/docs/developers/smolmix +--- + +# smolmix + +`smolmix` is a TCP/UDP tunnel over the Nym mixnet. It uses a userspace network stack [`smoltcp`](https://docs.rs/smoltcp/latest/smoltcp/) to provide `TcpStream` and `UdpSocket` types that work with the async [`tokio`](https://docs.rs/tokio) Rust ecosystem e.g. [`tokio-rustls`](https://docs.rs/tokio-rustls), [`hyper`](https://docs.rs/hyper), [`tokio-tungstenite`](https://docs.rs/tokio-tungstenite), etc. + +The `TcpStream` type implements tokio's `AsyncRead`/`AsyncWrite` traits and `UdpSocket` provides `send_to`/`recv_from` for datagrams. + +```text +┌──────────────────────────────────────────────────────────────┐ +│ Your application (TLS, HTTP, WebSocket, DNS, etc.) │ +│ └─ smolmix::TcpStream / UdpSocket │ +│ └─ smoltcp (userspace TCP/IP) │ +│ └─ Nym mixnet → IPR exit gateway → internet │ +└──────────────────────────────────────────────────────────────┘ +``` + +Traffic exits the mixnet at an [IPR (Internet Packet Router)](/network/infrastructure/exit-services#ip-packet-router) exit gateway. The exit IP is the gateway's, not yours. + +## Runtime and platform support +### Other runtimes +`smolmix` currently requires `tokio`. The internal pipeline is tokio-based: the bridge task that shuttles packets to the mixnet, the Nym SDK's `MixnetClient`, and the [`tokio-smoltcp`](https://docs.rs/tokio-smoltcp) reactor that drives the userspace TCP/IP stack all run on the tokio runtime. + +This means `smolmix` is not compatible with alternative async runtimes like [`smol`](https://docs.rs/smol) or [`async-std`](https://docs.rs/async-std) out of the box. If you need to use `smolmix` from another runtime, the [`async-compat`](https://docs.rs/async-compat) crate can bridge the gap. + +### Non-native `smolmix` +A WASM version of `smolmix` is planned for a future release. + +## Installation + +Add `smolmix` to your `Cargo.toml`: + +```toml +[dependencies] +smolmix = "1.21.0" +nym-bin-common = { version = "1.21.0", features = ["basic_tracing"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros"] } +``` + +`tokio` is a transitive dependency of smolmix, but you need to enable `rt-multi-thread` (smolmix spawns multiple tasks internally) and `macros` (for `#[tokio::main]`). + +`nym-bin-common` is optional but recommended: it sets up [`tracing`](https://docs.rs/tracing) logging so you can see mixnet connection progress. + +**Minimum Rust version:** {RUST_MSRV}+ + +### From Git + +For unreleased changes, import directly from the repository: + +```toml +smolmix = { git = "https://github.com/nymtech/nym", branch = "develop" } +``` + +## When to use smolmix + +| | smolmix | Stream module | mixFetch | SOCKS client | +|---|---|---|---|---| +| **Layer** | Transport (TCP/UDP) | Message (multiplexed streams) | HTTP | TCP (SOCKS proxy) | +| **Controls both sides?** | No (proxy mode) | Yes (E2E) | No (proxy mode) | No (proxy mode) | +| **API** | `TcpStream`, `UdpSocket` | `AsyncRead + AsyncWrite` | `fetch()` drop-in | SOCKS4/5 protocol | +| **Composability** | Full: TLS, HTTP, WebSocket, DNS, etc. stack on top | Byte streams only | HTTP(S) only | Application-dependent | +| **Best for** | Reaching external services from Rust with standard networking | Peer-to-peer / E2E protocols between Nym clients | Browser HTTP requests | Legacy apps with SOCKS support | + +## Security model + +Traffic is Sphinx-encrypted inside the mixnet, but between the exit gateway and the remote host it travels as **normal internet traffic**. Always encrypt the final hop with TLS ([`rustls`](https://docs.rs/rustls)), Noise Protocol ([`snow`](https://docs.rs/snow)), etc. + +### What's protected + +| Segment | Mixnet encryption | What's visible | +|---|---|---| +| Your machine → mixnet entry | Sphinx (layered) | Entry gateway sees your IP but not the destination | +| Inside the mixnet (entry + 3 mix layers + exit) | Sphinx (layered) | Each node only knows prev/next hop | +| Exit gateway (IPR) | Sphinx removed, raw IP packet exposed | IPR sees destination IP + port. Payload depends on your application layer (see below). | +| IPR → remote host | None (Sphinx is mixnet-only) | Remote host sees IPR's IP, not yours | + +The Sphinx encryption is the **mixnet transport layer**: it protects packets as they traverse the mix nodes. At the exit gateway, the Sphinx layers are stripped and the original IP packet is forwarded to the destination. This is analogous to how a Tor exit node or VPN endpoint unwraps its tunnel. + +**What's inside that IP packet is entirely up to you.** If you connect with TLS (as in the [TCP example](https://github.com/nymtech/nym/blob/develop/smolmix/core/examples/tcp.rs)), the IPR sees encrypted TLS ciphertext going to a destination IP: it knows *where* but not *what*. If you send plaintext HTTP, the IPR can read the full request and response. + +### Trust boundaries + +- **You trust the mixnet** to provide unlinkability between sender and receiver. This is enforced cryptographically by the Sphinx packet format and mixing. +- **You trust the IPR exit gateway** in the same way you trust a VPN exit or Tor exit node: it can inspect your raw IP packets. The difference is that the IPR doesn't know *who* is sending the traffic (the mixnet hides your identity). +- **Application-layer encryption closes the gap.** TLS, Noise Protocol, or any authenticated encryption ensures the IPR only sees ciphertext. It can see destination IP and port, but not payload content. + +### Comparison with other privacy tools + +| | smolmix | Tor | VPN | +|---|---|---|---| +| **Exit node sees traffic?** | Yes (encrypt it) | Yes (encrypt it) | Yes (encrypt it) | +| **Exit node knows sender?** | No (mixnet hides identity) | No (onion routing) | Yes (VPN provider knows) | +| **Timing analysis resistance** | Strong (mixing, cover traffic) | Weak (low-latency) | None | +| **UDP support** | Yes | No (TCP only) | Yes | + +## Examples + +Runnable examples in [`smolmix/core/examples/`](https://github.com/nymtech/nym/tree/develop/smolmix/core/examples). Each is self-contained; read the `//!` doc comments at the top of each file for a walkthrough. + +```sh +cargo run -p smolmix --example +``` + +All examples accept `--ipr
` to target a specific exit node (pass a `Recipient` address to `Tunnel::builder().ipr_address()`). + +| Example | Source | What it demonstrates | +|---|---|---| +| UDP | [`udp.rs`](https://github.com/nymtech/nym/blob/develop/smolmix/core/examples/udp.rs) | DNS lookup via [`hickory-proto`](https://docs.rs/hickory-proto), sending a raw UDP query to `1.1.1.1:53` through the mixnet | +| TCP | [`tcp.rs`](https://github.com/nymtech/nym/blob/develop/smolmix/core/examples/tcp.rs) | HTTPS request via [`hyper`](https://docs.rs/hyper) + [`tokio-rustls`](https://docs.rs/tokio-rustls). Fetches Cloudflare's `/cdn-cgi/trace` to show that the exit IP differs from clearnet | +| WebSocket | [`websocket.rs`](https://github.com/nymtech/nym/blob/develop/smolmix/core/examples/websocket.rs) | WebSocket echo via [`tokio-tungstenite`](https://docs.rs/tokio-tungstenite) + [`tokio-rustls`](https://docs.rs/tokio-rustls). Full TCP → TLS → WebSocket stack composing over smolmix | +| UDP multi | [`udp_multi.rs`](https://github.com/nymtech/nym/blob/develop/smolmix/core/examples/udp_multi.rs) | Multiple DNS lookups with timeout handling + NTP time sync, all over mixnet UDP | +| TCP download | [`tcp_download.rs`](https://github.com/nymtech/nym/blob/develop/smolmix/core/examples/tcp_download.rs) | DNS-over-mixnet + multi-request HTTP/1.1 download over a single keep-alive connection, the full real-world pattern | +| WebSocket multi | [`websocket_multi.rs`](https://github.com/nymtech/nym/blob/develop/smolmix/core/examples/websocket_multi.rs) | Burst echo (varying message sizes) + idle survival probes with clearnet baseline. Sustained multi-message WebSocket usage over the mixnet | + +## Architecture + +The internal stack (smoltcp reactor, device adapter, bridge task, packet flow) is documented in [`ARCHITECTURE.md`](https://github.com/nymtech/nym/blob/develop/smolmix/core/src/ARCHITECTURE.md). This is also the crate-level documentation on docs.rs. + +## API reference + +Full API documentation is available on [docs.rs/smolmix](https://docs.rs/smolmix). + --- title: Nym Rust SDK: Privacy Apps for the Mixnet description: Rust SDK reference for building privacy applications on the Nym mixnet. Covers the Mixnet client, Stream multiplexing, Client Pool, and code examples. @@ -1795,25 +1751,23 @@ url: https://nym.com/docs/developers/rust # Rust SDK -The Rust SDK provides high-level abstractions for building privacy-preserving applications on the Nym Mixnet. All modules share a common `MixnetClient` that handles gateway connections, Sphinx packet encryption, routing, and cover traffic under the hood. +All modules share a common `MixnetClient` that manages gateway connections, Sphinx packet encryption, routing, and cover traffic. -Full API reference, architecture documentation, and type details are available on [**docs.rs/nym-sdk**](https://docs.rs/nym-sdk/latest/nym_sdk/). +Full API reference: [**docs.rs/nym-sdk**](https://docs.rs/nym-sdk/latest/nym_sdk/) + +For an overview of what the SDK can do, see the **[Tour](./rust/tour)**. For setup instructions, see [Installation](./rust/importing). ## Modules -- **[Mixnet](./rust/mixnet)** — Core client for sending and receiving individual message payloads through the Mixnet. This is the Mixnet's native communication model — no connections, no ordering, just individually routed payloads. +- **[Stream](./rust/stream)**: multiplexed `AsyncRead + AsyncWrite` byte streams over the Mixnet. **If you're used to TCP sockets, start here.** -- **[Stream](./rust/stream)** — Multiplexed `AsyncRead + AsyncWrite` byte streams over the Mixnet. This is the abstraction layer that bridges the gap between the Mixnet's message-based model and familiar socket-based networking. **If you're used to TCP sockets, start here.** +- **[Mixnet](./rust/mixnet)**: raw message payloads, independently routed, no connections or ordering. Use this when you want full control over the communication model. -- **[TcpProxy](./rust/tcpproxy)** *(deprecated)* — TCP socket proxying through the Mixnet with session management and message ordering. For new projects, use the Stream module instead. +- **[Client Pool](./rust/client-pool)**: keeps ready-to-use `MixnetClient` instances warm for bursty workloads. -- **[Client Pool](./rust/client-pool)** — A connection pool that maintains ready-to-use `MixnetClient` instances for high-throughput applications. +- **[TcpProxy](./rust/tcpproxy)** *(deprecated)*: TCP socket proxying with session management and message ordering. Use Stream for new projects. -- **[FFI](./rust/ffi)** — Foreign function interface bindings for using the SDK from Go and C/C++. - -## Getting started - -New to the SDK? Start with the **[Tour](./rust/tour)** for a quick overview of what you can do, then see [Installation](./rust/importing) for how to add `nym-sdk` to your project. +- **[FFI](./rust/ffi)**: Go and C/C++ bindings. --- title: Tour of the Rust SDK @@ -1824,9 +1778,9 @@ url: https://nym.com/docs/developers/rust/tour A quick walkthrough of the most important things you can do with `nym-sdk`. Each section shows working code and links to the module that covers it in depth. -**The Mixnet is not like regular internet networking.** There are no persistent connections, no guaranteed message ordering, and no TCP underneath. At its core, the Mixnet is a message-based anonymity network — you send individual payloads that are Sphinx-encrypted, mixed through multiple nodes, and independently reconstructed at the destination. +**The Mixnet is not like regular internet networking**: there are no persistent connections, no guaranteed message ordering, and no TCP underneath. At its core, the Mixnet is a message-based anonymity network: you send individual payloads that are Sphinx-encrypted, mixed through multiple nodes, and independently reconstructed at the destination. -This means the raw [message API](./mixnet) works differently from what most developers expect. To bridge that gap, we've built the [Stream module](./stream) — an abstraction layer that gives you familiar `AsyncRead + AsyncWrite` byte streams on top of the Mixnet. **If you're coming from socket-based networking, start with streams.** +The raw [message API](./mixnet) therefore works differently from what most developers expect. The [Stream module](./stream) bridges this gap by providing `AsyncRead + AsyncWrite` byte streams on top of the Mixnet. If you are coming from socket-based networking, start with streams. ## Send a raw message payload @@ -1861,11 +1815,11 @@ async fn main() { The message is Sphinx-encrypted, mixed across 5 nodes, and reconstructed on arrival. The whole round trip takes a few seconds. -**Next:** [Mixnet module](./mixnet) | [Tutorial: Send Your First Private Message](./mixnet/tutorial) +Next: [Mixnet module](./mixnet) | [Tutorial: Send Your First Private Message](./mixnet/tutorial) ## Reply anonymously with SURBs -Every received message carries a `sender_tag` — an opaque token that lets you reply **without knowing the sender's Nym address**. Replies travel back through pre-built Single Use Reply Blocks (SURBs): +Every received message carries a `sender_tag`, an opaque token that lets you reply **without knowing the sender's Nym address**. Replies travel back through pre-built Single Use Reply Blocks (SURBs): ```rust // After receiving a message... @@ -1873,11 +1827,11 @@ let tag = received_msg.sender_tag.expect("message includes sender tag"); client.send_reply(tag, "anonymous reply!").await.unwrap(); ``` -The replying side never learns where the reply is going. This is the foundation of anonymous communication on the Mixnet. +The replying side never learns where the reply is going, enabling anonymous communication without mutual identity disclosure. ## Open a bidirectional stream -If you're used to working with TCP sockets, this is where you'll feel at home. The [Stream module](./stream) provides persistent, bidirectional byte channels that implement tokio's `AsyncRead + AsyncWrite` — so any code that works with sockets works with `MixnetStream`: +If you're used to working with TCP sockets, this is where you'll feel at home. The [Stream module](./stream) provides persistent, bidirectional byte channels that implement tokio's `AsyncRead + AsyncWrite`, so any code that works with sockets works with `MixnetStream`: ```rust use nym_sdk::mixnet; @@ -1898,7 +1852,7 @@ async fn main() { // Receiver accepts it let mut inc = listener.accept().await.unwrap(); - // Standard tokio I/O — write, flush, read + // Standard tokio I/O: write, flush, read out.write_all(b"hello stream").await.unwrap(); out.flush().await.unwrap(); @@ -1915,7 +1869,7 @@ async fn main() { Activating stream mode (by calling `listener()` or `open_stream()`) disables message-based methods like `send_plain_message()` and `wait_for_messages()`. A single client operates in one mode at a time. -**Next:** [Stream module](./stream) | [Tutorial: Build a Private Echo Server](./stream/tutorial) +Next: [Stream module](./stream) | [Tutorial: Build a Private Echo Server](./stream/tutorial) ## Use a client pool for bursty traffic @@ -1942,9 +1896,9 @@ async fn main() { } ``` -Clients are **consumed, not returned** — the pool creates replacements automatically. +Clients are consumed, not returned; the pool creates replacements automatically. -**Next:** [Client Pool module](./client-pool) | [Tutorial: Handle Bursty Traffic](./client-pool/tutorial) +Next: [Client Pool module](./client-pool) | [Tutorial: Handle Bursty Traffic](./client-pool/tutorial) ## Persist your identity @@ -1973,28 +1927,30 @@ println!("Persistent address: {}", client.nym_address()); ## Where to go next -- **[Installation](./importing)** — Add `nym-sdk` to your project -- **[Mixnet Tutorial](./mixnet/tutorial)** — Full walkthrough: send, receive, reply with SURBs -- **[Stream Tutorial](./stream/tutorial)** — Build a private echo server -- **[Client Pool Tutorial](./client-pool/tutorial)** — Handle bursty traffic -- **[API Reference on docs.rs](https://docs.rs/nym-sdk/latest/nym_sdk/)** — Type details, method signatures, architecture docs +- [Installation](./importing): add `nym-sdk` to your project +- [Mixnet Tutorial](./mixnet/tutorial): send, receive, and reply with SURBs +- [Stream Tutorial](./stream/tutorial): build a private echo server +- [Client Pool Tutorial](./client-pool/tutorial): handle bursty traffic +- [API Reference on docs.rs](https://docs.rs/nym-sdk/latest/nym_sdk/): type details, method signatures, architecture docs --- title: Install the Nym Rust SDK -description: Add nym-sdk to your Rust project from crates.io or Git. Covers version requirements, minimum Rust version, and current feature gate status. +description: Add nym-sdk to your Rust project from Git or crates.io. Covers version requirements, minimum Rust version, and current feature gate status. url: https://nym.com/docs/developers/rust/importing --- # Installation -The `nym-sdk` crate is available on [crates.io](https://crates.io/crates/nym-sdk): - ```toml [dependencies] -nym-sdk = "1.20.4" +nym-sdk = "1.21.0" ``` -You can also import directly from the Git repository if you need unreleased changes: +**Minimum Rust version:** {RUST_MSRV}+ + +### From Git + +You can also import directly from Git if you want unreleased changes: ```toml # development branch (latest changes, may be unstable) @@ -2004,9 +1960,7 @@ nym-sdk = { git = "https://github.com/nymtech/nym", branch = "develop" } nym-sdk = { git = "https://github.com/nymtech/nym", branch = "master" } ``` -**Minimum Rust version:** 1.70+ - -**Feature gates are not yet implemented.** Importing `nym-sdk` currently pulls in all modules (mixnet, tcp_proxy, client_pool, etc.) and their full dependency trees. Work is planned to gate modules behind Cargo feature flags so you can import only what you need. +**No feature gates yet.** Importing `nym-sdk` pulls in everything (mixnet, tcp_proxy, client_pool, etc.) and their full dependency trees. Cargo feature flags are planned. --- title: Nym Rust SDK: Mixnet Messaging Module @@ -2018,13 +1972,13 @@ url: https://nym.com/docs/developers/rust/mixnet The `mixnet` module is the core of the Nym SDK. It provides [`MixnetClient`](https://docs.rs/nym-sdk/latest/nym_sdk/mixnet/struct.MixnetClient.html) for connecting to the Nym Mixnet, sending messages through Sphinx packet encryption and 5-hop routing, and receiving reconstructed messages on the other side. -**The Mixnet is not like regular internet networking.** There are no persistent connections, no guaranteed message ordering, and no TCP. Each message is independently Sphinx-encrypted, routed through multiple mix nodes, and reconstructed at the destination. This is the Mixnet's native communication model — powerful for privacy, but different from what most developers expect. If you want familiar socket-like I/O (`read`/`write`), use the [Stream module](./stream) instead — it's an abstraction layer we've built to bridge the gap. +Messages are individually routed through the Mixnet with no guaranteed ordering or persistent connections. If you want familiar socket-like I/O (`read`/`write`), use the [Stream module](./stream) instead. See the [Tour](./tour) for how the two approaches compare. ## Two operating modes The client operates in one of two mutually exclusive modes: -**Message mode** (default) — send and receive raw message payloads: +**Message mode** (default): send and receive raw message payloads: ```rust use nym_sdk::mixnet::{self, MixnetMessageSender}; @@ -2043,14 +1997,14 @@ if let Some(msgs) = client.wait_for_messages().await { client.disconnect().await; ``` -**Stream mode** — persistent `AsyncRead + AsyncWrite` channels. See the [Stream module](./stream) for details. +**Stream mode:** persistent `AsyncRead + AsyncWrite` channels. See the [Stream module](./stream) for details. Stream mode is activated by calling `open_stream()` or `listener()`. Once active, message-mode methods return `Error::StreamModeActive`. This is a one-way transition. ## API reference -- [API reference on docs.rs](https://docs.rs/nym-sdk/latest/nym_sdk/mixnet/) — full architecture documentation, all types, builder methods, traits, and configuration options -- [Examples on GitHub](https://github.com/nymtech/nym/tree/develop/sdk/rust/nym-sdk/examples) — runnable examples covering simple send/receive, builder patterns, custom topologies, SOCKS proxy, anonymous replies, and more +- [API reference on docs.rs](https://docs.rs/nym-sdk/latest/nym_sdk/mixnet/): full architecture documentation, all types, builder methods, traits, and configuration options +- [Examples on GitHub](https://github.com/nymtech/nym/tree/develop/sdk/rust/nym-sdk/examples): runnable examples covering simple send/receive, builder patterns, custom topologies, SOCKS proxy, anonymous replies, and more Run any example with: ```sh @@ -2059,9 +2013,9 @@ cargo run --example ## Next steps -- [Tutorial: Send your first private message](./mixnet/tutorial) — step-by-step guide covering sending, receiving, SURBs, and persistent identity -- [Troubleshooting](./mixnet/troubleshooting) — common issues with logging, empty messages, and client lifecycle -- [Stream module](./stream) — if you need persistent bidirectional byte channels +- [Tutorial: Send your first private message](./mixnet/tutorial): step-by-step guide covering sending, receiving, SURBs, and persistent identity +- [Troubleshooting](./mixnet/troubleshooting): common issues with logging, empty messages, and client lifecycle +- [Stream module](./stream): if you need persistent bidirectional byte channels --- title: Mixnet Tutorial: Send Your First Private Message @@ -2071,22 +2025,9 @@ url: https://nym.com/docs/developers/rust/mixnet/tutorial # Tutorial: Send Your First Private Message -Build a program that connects to the Nym Mixnet, sends a message to yourself, receives it, and replies anonymously using SURBs. Then extend it with persistent identity and concurrent send/receive. +By the end of this tutorial you'll have a working program that sends a Sphinx-encrypted message to itself through the Nym Mixnet, receives it, and replies anonymously using SURBs. The later sections cover persistent identity and concurrent send/receive. -## What you'll learn - -- Connecting an ephemeral client to the Nym Mixnet -- Sending and receiving Sphinx-encrypted messages -- Replying anonymously using SURBs (Single Use Reply Blocks) -- Persisting client identity to disk with `MixnetClientBuilder` -- Using `split_sender()` for concurrent send and receive tasks - -## Prerequisites - -- Rust toolchain (1.70+) -- A working internet connection (clients connect to the live Nym Mixnet) - -Code verified against `nym-sdk` v1.20.4 ([`4077717`](https://github.com/nymtech/nym/commit/4077717d3)). If the API has changed since then, check the [examples in the repo](https://github.com/nymtech/nym/tree/develop/sdk/rust/nym-sdk/examples) for the latest usage. +**You'll need:** Rust {RUST_MSRV}+ and an internet connection (clients connect to the live Mixnet). ## Step 1: Set up the project @@ -2099,7 +2040,8 @@ Add dependencies to `Cargo.toml`: ```toml [dependencies] -nym-sdk = "1.20.4" +nym-sdk = "1.21.0" +nym-bin-common = { version = "1.21.0", features = ["basic_tracing"] } tokio = { version = "1", features = ["full"] } ``` @@ -2112,28 +2054,32 @@ use nym_sdk::mixnet::{self, MixnetMessageSender}; #[tokio::main] async fn main() { - // connect_new() creates an ephemeral client — keys are generated in + nym_bin_common::logging::setup_tracing_logger(); + + // connect_new() creates an ephemeral client: keys are generated in // memory and discarded on disconnect. let mut client = mixnet::MixnetClient::connect_new().await.unwrap(); let our_address = client.nym_address(); println!("Connected: {our_address}"); // The message is Sphinx-encrypted and mixed across 5 nodes. - // send_plain_message only blocks until the message is queued — + // send_plain_message only blocks until the message is queued; // encryption and mixing happen in background tasks. client .send_plain_message(*our_address, "hello from the mixnet!") .await .unwrap(); - println!("Sent — waiting for arrival..."); + println!("Sent, waiting for arrival..."); ``` +`setup_tracing_logger()` shows what the SDK is doing under the hood: gateway connections, topology fetches, Sphinx packet encryption. If the output is too verbose, comment out the line or filter with `RUST_LOG=warn cargo run`. + ## Step 3: Receive ```rust // wait_for_messages() returns the next batch of incoming messages. - // Filter empty messages — these are SURB replenishment requests. + // Filter empty messages: these are SURB replenishment requests. let message = loop { if let Some(msgs) = client.wait_for_messages().await { if let Some(msg) = msgs.into_iter().find(|m| !m.message.is_empty()) { @@ -2147,12 +2093,12 @@ async fn main() { ## Step 4: Reply anonymously -Every message includes a `sender_tag` — an opaque `AnonymousSenderTag` that lets you reply **without knowing the sender's address**. The SDK includes SURBs (Single Use Reply Blocks) with every message by default: +Every message includes a `sender_tag`, an opaque `AnonymousSenderTag` that lets you reply **without knowing the sender's address**. The SDK bundles SURBs (Single Use Reply Blocks) with every outgoing message by default: ```rust let sender_tag = message.sender_tag.expect("should have sender tag"); - // send_reply uses the SURB — the sender's address is never revealed. + // send_reply uses the SURB: the sender's address is never revealed. client.send_reply(sender_tag, "hello back, anonymously!").await.unwrap(); let reply = loop { @@ -2172,12 +2118,12 @@ Every message includes a `sender_tag` — an opaque `AnonymousSenderTag` that le ## Step 5: Run it ```sh -cargo run +RUST_LOG=info cargo run ``` ``` Connected: 8gk4Y...@2xU4d... -Sent — waiting for arrival... +Sent, waiting for arrival... Received: hello from the mixnet! Reply: hello back, anonymously! ``` @@ -2203,27 +2149,44 @@ async fn main() { .await .unwrap(); - println!("Address: {}", client.nym_address()); + let our_address = client.nym_address(); + println!("Address: {our_address}"); - // Same API as before — send, receive, reply. + // Same API as before: send, receive, SURB reply. client - .send_plain_message(*client.nym_address(), "persistent identity!") + .send_plain_message(*our_address, "hello from persistent client!") .await .unwrap(); + println!("Sent, waiting for arrival..."); - if let Some(msgs) = client.wait_for_messages().await { - for m in msgs.into_iter().filter(|m| !m.message.is_empty()) { - println!("Received: {}", String::from_utf8_lossy(&m.message)); + let message = loop { + if let Some(msgs) = client.wait_for_messages().await { + if let Some(msg) = msgs.into_iter().find(|m| !m.message.is_empty()) { + break msg; + } } - } + }; + println!("Received: {}", String::from_utf8_lossy(&message.message)); - // Always disconnect for clean shutdown — background tasks need to be + let sender_tag = message.sender_tag.expect("should have sender tag"); + client.send_reply(sender_tag, "anonymous reply!").await.unwrap(); + + let reply = loop { + if let Some(msgs) = client.wait_for_messages().await { + if let Some(msg) = msgs.into_iter().find(|m| !m.message.is_empty()) { + break msg; + } + } + }; + println!("Reply: {}", String::from_utf8_lossy(&reply.message)); + + // Always disconnect for clean shutdown: background tasks need to be // stopped and state files flushed. client.disconnect().await; } ``` -Run it twice — the address stays the same. +Run it twice; the address stays the same. ## Going further: send and receive from different tasks @@ -2247,7 +2210,7 @@ async fn main() { // split_sender() returns a clone-able MixnetClientSender. let sender = client.split_sender(); - // Spawn a receiver — the original client implements futures::Stream. + // Spawn a receiver: the original client implements futures::Stream. let rx = tokio::spawn(async move { if let Some(msg) = client.next().await { println!("Received: {}", String::from_utf8_lossy(&msg.message)); @@ -2265,39 +2228,27 @@ async fn main() { } ``` -## What's happening under the hood +## What's happening underneath -1. **`connect_new()`** generates an ephemeral identity (ed25519 + x25519 keys), fetches the current network topology, selects a gateway, and opens a persistent WebSocket connection. +`connect_new()` generates an ephemeral identity (ed25519 + x25519 keypair), fetches the current network topology, selects a gateway, and opens a persistent WebSocket connection. `send_plain_message()` wraps the payload in Sphinx packets, layered encryption where each of the 5 Mix Nodes can only decrypt one layer and learn the next hop, never the full route. `wait_for_messages()` drains a local queue fed by the gateway; messages arrive out of order by design, to defeat timing analysis. -2. **`send_plain_message()`** wraps your data in **Sphinx packets** — layered encryption where each of the 5 mix nodes can only decrypt one layer and learn the next hop, never the full route. +SURBs (Single Use Reply Blocks) are pre-computed return routes bundled with each outgoing message. The recipient uses them to reply without learning the sender's address. Each is single-use; the SDK replenishes them automatically. -3. **`wait_for_messages()`** pulls from a local queue that is fed by the gateway. Messages arrive out of order (by design — this prevents timing analysis). - -4. **SURBs** (Single Use Reply Blocks) are pre-computed return routes bundled with each outgoing message. The recipient can reply without learning the sender's address. Each SURB is single-use; the SDK replenishes them automatically. - -5. **`split_sender()`** clones the send channel while the original client retains the receive side. Both can run on separate tokio tasks without locks. - -## What you've learned - -- **`MixnetClient::connect_new()`** creates an ephemeral client with in-memory keys -- **`send_plain_message(recipient, data)`** queues a Sphinx-encrypted message -- **`wait_for_messages()`** returns the next batch of received messages -- **`send_reply(sender_tag, data)`** replies anonymously via SURBs -- **`MixnetClientBuilder` + `StoragePaths`** persists identity to disk -- **`split_sender()`** enables concurrent send/receive on separate tasks -- **Always call `disconnect()`** for clean shutdown +`split_sender()` clones the send channel while the original client retains the receive side. Both halves can run on separate tokio tasks without synchronization. ## Complete code ### Ephemeral client -New address on every run — good for quick experiments: +New address on every run, good for quick experiments: ```rust use nym_sdk::mixnet::{self, MixnetMessageSender}; #[tokio::main] async fn main() { + nym_bin_common::logging::setup_tracing_logger(); + let mut client = mixnet::MixnetClient::connect_new().await.unwrap(); let our_address = client.nym_address(); println!("Connected: {our_address}"); @@ -2306,7 +2257,7 @@ async fn main() { .send_plain_message(*our_address, "hello from the mixnet!") .await .unwrap(); - println!("Sent — waiting for arrival..."); + println!("Sent, waiting for arrival..."); let message = loop { if let Some(msgs) = client.wait_for_messages().await { @@ -2335,14 +2286,17 @@ async fn main() { ### Persistent identity -Same address across restarts — use this for real applications: +Same address across restarts. Use this for real applications: ```rust use nym_sdk::mixnet::{self, MixnetMessageSender, StoragePaths}; #[tokio::main] async fn main() { + nym_bin_common::logging::setup_tracing_logger(); + let paths = StoragePaths::new_from_dir("./my-client-data").unwrap(); + let mut client = mixnet::MixnetClientBuilder::new_with_default_storage(paths) .await .unwrap() @@ -2353,13 +2307,13 @@ async fn main() { .unwrap(); let our_address = client.nym_address(); - println!("Connected: {our_address}"); + println!("Address: {our_address}"); client - .send_plain_message(*our_address, "hello from the mixnet!") + .send_plain_message(*our_address, "hello from persistent client!") .await .unwrap(); - println!("Sent — waiting for arrival..."); + println!("Sent, waiting for arrival..."); let message = loop { if let Some(msgs) = client.wait_for_messages().await { @@ -2371,7 +2325,7 @@ async fn main() { println!("Received: {}", String::from_utf8_lossy(&message.message)); let sender_tag = message.sender_tag.expect("should have sender tag"); - client.send_reply(sender_tag, "hello back, anonymously!").await.unwrap(); + client.send_reply(sender_tag, "anonymous reply!").await.unwrap(); let reply = loop { if let Some(msgs) = client.wait_for_messages().await { @@ -2397,7 +2351,7 @@ url: https://nym.com/docs/developers/rust/mixnet/examples Runnable examples in [`sdk/rust/nym-sdk/examples/`](https://github.com/nymtech/nym/tree/develop/sdk/rust/nym-sdk/examples). Each file is self-contained with step-by-step comments. ```bash -cargo run --example +cargo run --example ``` | Example | Source | What it demonstrates | @@ -2407,6 +2361,12 @@ cargo run --example | Builder | [`builder.rs`](https://github.com/nymtech/nym/blob/develop/sdk/rust/nym-sdk/examples/builder.rs) | Using `MixnetClientBuilder` with ephemeral keys | | Builder with Storage | [`builder_with_storage.rs`](https://github.com/nymtech/nym/blob/develop/sdk/rust/nym-sdk/examples/builder_with_storage.rs) | Persisting keys to disk with `StoragePaths` | | Parallel Send/Receive | [`parallel_sending_and_receiving.rs`](https://github.com/nymtech/nym/blob/develop/sdk/rust/nym-sdk/examples/parallel_sending_and_receiving.rs) | Using `split_sender()` for concurrent tasks | +| Sandbox Testnet | [`sandbox.rs`](https://github.com/nymtech/nym/blob/develop/sdk/rust/nym-sdk/examples/sandbox.rs) | Connecting to the Sandbox testnet instead of mainnet | +| Bandwidth Credential | [`bandwidth.rs`](https://github.com/nymtech/nym/blob/develop/sdk/rust/nym-sdk/examples/bandwidth.rs) | Acquiring a bandwidth credential for paid mixnet access | +| Custom Topology | [`custom_topology_provider.rs`](https://github.com/nymtech/nym/blob/develop/sdk/rust/nym-sdk/examples/custom_topology_provider.rs) | Implementing the `TopologyProvider` trait to filter or customize node selection | +| Overwrite Topology | [`manually_overwrite_topology.rs`](https://github.com/nymtech/nym/blob/develop/sdk/rust/nym-sdk/examples/manually_overwrite_topology.rs) | Manually constructing a topology with hardcoded nodes | +| Control Requests | [`control_requests.rs`](https://github.com/nymtech/nym/blob/develop/sdk/rust/nym-sdk/examples/control_requests.rs) | Sending service provider control requests (health, version, binary info) | +| Custom Storage | [`manually_handle_storage.rs`](https://github.com/nymtech/nym/blob/develop/sdk/rust/nym-sdk/examples/manually_handle_storage.rs) | Implementing custom storage backends for keys, gateways, and credentials | --- title: Mixnet Module Troubleshooting @@ -2424,7 +2384,7 @@ You should always **manually disconnect your client** with `client.disconnect(). ## Waiting for non-empty messages -When listening for a response, you may receive empty messages. These are SURB replenishment requests — the remote side asking for more reply SURBs. Filter them out: +When listening for a response, you may receive empty messages. These are SURB replenishment requests: the remote side asking for more reply SURBs. Filter them out: ```rust let mut message = None; @@ -2436,7 +2396,7 @@ while let Some(new_message) = client.wait_for_messages().await { } ``` -Prefer `client.next().await` (from the `futures::StreamExt` trait — not the Nym Stream module) over `client.wait_for_messages().await` — it returns one message at a time which is easier to work with. You'll need `use futures::StreamExt;` in scope. +Prefer `client.next().await` (from the `futures::StreamExt` trait, not the Nym Stream module) over `client.wait_for_messages().await`; it returns one message at a time which is easier to work with. You'll need `use futures::StreamExt;` in scope. ## Verbose `task client is being dropped` logging @@ -2458,7 +2418,7 @@ If you see these messages unexpectedly, you may be killing the client process to If you see errors like `Polling shutdown failed: channel closed` or panics about `action control task has died`, your client is being dropped before it finishes sending. -`send_plain_message()` is async, but **it only blocks until the message is placed in the client's internal queue** — not until it's actually sent into the Mixnet. After queuing, the client still needs to route-encrypt the message and interleave it with cover traffic. +`send_plain_message()` is async, but **it only blocks until the message is placed in the client's internal queue**, not until it's actually sent into the Mixnet. After queuing, the client still needs to route-encrypt the message and interleave it with cover traffic. Make sure the program stays alive long enough. In practice this means awaiting a response or calling `sleep` before disconnecting: @@ -2479,7 +2439,7 @@ client.disconnect().await; ## Lots of `duplicate fragment received` messages -`WARN` level logs about duplicate fragments are caused by Mixnet-level packet retransmission — the original and the retransmitted copy both arrive. This is not a bug in your client logic. +`WARN` level logs about duplicate fragments are caused by Mixnet-level packet retransmission: the original and the retransmitted copy both arrive. This is not a bug in your client logic. --- title: Stream Module: AsyncRead/AsyncWrite Over the Mixnet @@ -2489,50 +2449,60 @@ url: https://nym.com/docs/developers/rust/stream # Stream Module -The Mixnet is fundamentally a message-based anonymity network — no persistent connections, no guaranteed ordering, no TCP. The default [message API](./mixnet) works at this native level: individual payloads sent independently through mix nodes. This is powerful for privacy, but it's not how most networking code works. +The Mixnet is fundamentally message-based: no persistent connections, no guaranteed ordering, no TCP. The default [message API](./mixnet) works at this level, sending individual payloads independently through Mix Nodes. This is effective for privacy but unlike how most networking code is structured. -The **Stream module** bridges that gap. It gives you persistent, bidirectional byte channels that look and feel like TCP sockets. Each `MixnetStream` implements Rust's standard [`AsyncRead`](https://docs.rs/tokio/latest/tokio/io/trait.AsyncRead.html) and [`AsyncWrite`](https://docs.rs/tokio/latest/tokio/io/trait.AsyncWrite.html) traits — use `tokio::io::copy`, codecs, `BufReader`/`BufWriter`, or any library that works with async I/O. Under the hood, the module handles framing, multiplexing, and routing so you don't have to. +The **Stream module** bridges the gap by providing persistent, bidirectional byte channels that behave like TCP sockets. Each `MixnetStream` implements [`AsyncRead`](https://docs.rs/tokio/latest/tokio/io/trait.AsyncRead.html) and [`AsyncWrite`](https://docs.rs/tokio/latest/tokio/io/trait.AsyncWrite.html), so `tokio::io::copy`, codecs, `BufReader`/`BufWriter`, and any other async I/O consumer work without modification. **If you're coming from socket-based networking, start here.** -**If you're coming from socket-based networking, start here.** - -Under the hood, every stream is multiplexed over a single `MixnetClient`. A background router task decodes a small header on each incoming Mixnet message and dispatches payloads to the correct stream by ID — no extra connections or gateways needed. +All streams are multiplexed over a single `MixnetClient`. A background router task reads a small header on each incoming message and dispatches the payload to the correct stream by ID, so multiple concurrent streams require no additional connections or gateways. ## How it works The two sides of a stream connection follow a client/server pattern: -1. **Opener** calls `client.open_stream(recipient, surbs)` — this generates a random `StreamId`, registers the stream locally, and sends an `Open` message through the Mixnet. -2. **Listener** calls `listener.accept()` — this blocks until an `Open` arrives, registers the new stream, and returns a `MixnetStream` ready for reading and writing. -3. Both sides read and write using standard `AsyncRead`/`AsyncWrite` — bytes are wrapped with a 10-byte stream header, routed through the Mixnet, and demultiplexed on arrival. -4. **Cleanup** happens on `drop` — the stream deregisters from the local router. No close message is sent over the wire (the Mixnet doesn't guarantee message ordering, so a close could arrive before the final data). +1. **Opener** calls `client.open_stream(recipient, surbs)`. This generates a random `StreamId`, registers the stream locally, and sends an `Open` message through the Mixnet. +2. **Listener** calls `listener.accept()`, which blocks until an `Open` arrives, registers the new stream, and returns a `MixnetStream` ready for reading and writing. +3. Both sides read and write using standard `AsyncRead`/`AsyncWrite`. Bytes are wrapped in a 16-byte LP frame header (stream ID, message type, sequence number), routed through the Mixnet, and demultiplexed on arrival. +4. **Cleanup** happens on `drop`. The stream deregisters from the local router. No close message is sent over the wire, since a close could race ahead of in-flight data. -```mermaid ---- -config: - theme: neo-dark ---- -sequenceDiagram - participant A as Client A (opener) - participant M as Mixnet - participant B as Client B (listener) - - Note over B: listener = client.listener() - A->>M: Open message (StreamId + initial data) - M->>B: Open message delivered - Note over B: stream = listener.accept() - - A->>M: Data (StreamId + payload) - M->>B: Data delivered to stream - B->>M: Data (reply via SURBs) - M->>A: Reply delivered to stream - - Note over A: drop(stream) - Note over B: drop(stream) +```text +┌─────────────────────────────────────────────────────────┐ +│ MixnetClient │ +│ │ +│ ┌──────────────┐ ┌──────────────┐ │ +│ │ MixnetStream │ │ MixnetStream │ ... │ +│ │ (peer A) │ │ (peer B) │ │ +│ └──────┬───────┘ └──────┬───────┘ │ +│ │writes │writes │ +│ ▼ ▼ │ +│ ┌─────────────────────────────────┐ │ +│ │ ClientInput.input_sender │ │ +│ └──────────────┬──────────────────┘ │ +│ │ │ +│ ▼ │ +│ ── mixnet ── │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────┐ │ +│ │ reconstructed_receiver │ │ +│ └──────────────┬──────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────┐ │ +│ │ Router task │ │ +│ │ decode header → dispatch by ID │ │ +│ └──┬──────────────────────────┬───┘ │ +│ │ Open messages │ Data messages │ +│ ▼ ▼ │ +│ ┌──────────────┐ ┌──────────────────┐ │ +│ │MixnetListener│ │ StreamMap lookup │ │ +│ │ .accept() │ │ → per-stream tx │ │ +│ └──────────────┘ └──────────────────┘ │ +└─────────────────────────────────────────────────────────┘ ``` ## Complete example -This is a minimal but complete example: two clients on the same machine, one opens a stream to the other, sends a message, and reads a reply. +A minimal example with two clients on the same machine: one opens a stream to the other, sends a message, and reads a reply. ```rust use nym_sdk::mixnet; @@ -2543,24 +2513,24 @@ const TIMEOUT: Duration = Duration::from_secs(60); #[tokio::main] async fn main() { - // 1. Connect two ephemeral clients + // Connect two ephemeral clients let mut sender = mixnet::MixnetClient::connect_new().await.unwrap(); let mut receiver = mixnet::MixnetClient::connect_new().await.unwrap(); let receiver_addr = *receiver.nym_address(); - // 2. The receiver creates a listener (activates stream mode) + // The receiver creates a listener (activates stream mode) let mut listener = receiver.listener().unwrap(); - // 3. The sender opens a stream to the receiver's Nym address + // The sender opens a stream to the receiver's Nym address let mut outbound = sender.open_stream(receiver_addr, None).await.unwrap(); - // 4. The receiver accepts the incoming stream + // The receiver accepts the incoming stream let mut inbound = tokio::time::timeout(TIMEOUT, listener.accept()) .await .expect("timed out") .expect("listener closed"); - // 5. Send data and read it back — just like a TCP socket + // Send data and read it back, just like a TCP socket outbound.write_all(b"hello from sender").await.unwrap(); outbound.flush().await.unwrap(); @@ -2571,7 +2541,7 @@ async fn main() { .expect("read failed"); println!("Receiver got: {}", String::from_utf8_lossy(&buf[..n])); - // 6. Reply back through the same stream + // Reply back through the same stream inbound.write_all(b"hello from receiver").await.unwrap(); inbound.flush().await.unwrap(); @@ -2581,7 +2551,7 @@ async fn main() { .expect("read failed"); println!("Sender got: {}", String::from_utf8_lossy(&buf[..n])); - // 7. Clean up — streams deregister on drop, then disconnect clients + // Streams deregister on drop, then disconnect clients drop(outbound); drop(inbound); sender.disconnect().await; @@ -2589,7 +2559,7 @@ async fn main() { } ``` -The receiver replies via **reply SURBs** (Single Use Reply Blocks) — it never learns the sender's Nym address. This is the same anonymous reply mechanism used by the message API, applied transparently to streams. +The receiver replies via **reply SURBs** (Single Use Reply Blocks) and never learns the sender's Nym address. ## When to use streams vs messages @@ -2598,24 +2568,17 @@ The receiver replies via **reply SURBs** (Single Use Reply Blocks) — it never | **Pattern** | Raw message payloads | Persistent bidirectional channels | TCP socket proxying | | **API** | `send_plain_message()` / `wait_for_messages()` | `AsyncRead` + `AsyncWrite` | Localhost TCP socket | | **Multiplexing** | N/A | Multiple streams per client | One client per TCP connection | -| **Ordering** | No guarantees | No guarantees (yet) | Session-based ordering | +| **Ordering** | No guarantees | Sequence-based reordering | Session-based ordering | | **Best for** | Simple notifications, one-shot requests | Interactive protocols, streaming data, any code expecting async I/O | Wrapping existing TCP applications | | **Status** | Stable | New | Deprecated | -**Streams and messages are mutually exclusive.** Once you call `open_stream()` or `listener()`, the message-based API (`send_plain_message`, `wait_for_messages`) is permanently disabled on that client. This is a one-way transition — there is no switching back without disconnecting and reconnecting. See the [mode guard example](./stream/examples/mode-guard) for details. - -## Key types - -- [**`MixnetStream`**](https://docs.rs/nym-sdk/latest/nym_sdk/mixnet/struct.MixnetStream.html) — a single stream implementing `AsyncRead + AsyncWrite`. Obtained from `open_stream()` (outbound) or `listener.accept()` (inbound). -- [**`MixnetListener`**](https://docs.rs/nym-sdk/latest/nym_sdk/mixnet/struct.MixnetListener.html) — accepts inbound streams from remote peers. Created once per client via `client.listener()`. -- [**`StreamId`**](https://docs.rs/nym-sdk/latest/nym_sdk/mixnet/struct.StreamId.html) — 8-byte random identifier (`u64`) generated by the stream opener, used to multiplex streams over a single client. +**Streams and messages are mutually exclusive.** Once you call `open_stream()` or `listener()`, the message-based API (`send_plain_message`, `wait_for_messages`) is permanently disabled on that client. This is a one-way transition: there is no switching back without disconnecting and reconnecting. See the [`stream_mode_guard.rs` example](https://github.com/nymtech/nym/blob/develop/sdk/rust/nym-sdk/examples/stream_mode_guard.rs) for details. ## Next steps -- [Tutorial: Build a private echo server](./stream/tutorial) — step-by-step guide with a server and client communicating over streams -- [Architecture](./stream/architecture) — wire protocol, router task, data flow, stream cleanup, and known limitations -- [Examples](./stream/examples) — annotated walkthroughs of the SDK examples (multi-stream, idle timeout, throughput testing) -- [API reference on docs.rs](https://docs.rs/nym-sdk/latest/nym_sdk/mixnet/stream/) — type details and method signatures +- [Tutorial: Build a private echo server](./stream/tutorial): server and client communicating over streams +- [Architecture](./stream/architecture): wire protocol, router task, data flow, stream cleanup, and known limitations +- [Examples](./stream/examples): annotated walkthroughs of the SDK examples (multi-stream, idle timeout, throughput testing) --- title: Stream Tutorial: Build a Private Echo Server @@ -2625,7 +2588,7 @@ url: https://nym.com/docs/developers/rust/stream/tutorial # Tutorial: Build a Private Echo Server -In this tutorial you'll build two programs — a server that listens for incoming streams and echoes back whatever it receives, and a client that opens a stream, sends data, and reads the echo. Both communicate through the Nym Mixnet using `AsyncRead` and `AsyncWrite`, just like TCP sockets. +In this tutorial you'll build two programs: a server that listens for incoming streams and echoes back whatever it receives, and a client that opens a stream, sends data, and reads the echo. Both communicate through the Nym Mixnet using `AsyncRead` and `AsyncWrite`, just like TCP sockets. ## What you'll learn @@ -2635,11 +2598,9 @@ In this tutorial you'll build two programs — a server that listens for incomin - How streams are multiplexed over a single `MixnetClient` - Clean shutdown and stream lifecycle -Code verified against `nym-sdk` v1.20.4 ([`4077717`](https://github.com/nymtech/nym/commit/4077717d3)). If the API has changed since then, check the [examples in the repo](https://github.com/nymtech/nym/tree/develop/sdk/rust/nym-sdk/examples) for the latest usage. - ## Prerequisites -- Rust toolchain (1.70+) +- Rust toolchain ({RUST_MSRV}+) - A working internet connection (clients connect to the live Nym Mixnet) ## Step 1: Set up the project @@ -2647,14 +2608,17 @@ Code verified against `nym-sdk` v1.20.4 ([`4077717`](https://github.com/nymtech/ ```sh cargo init nym-echo cd nym-echo +rm src/main.rs ``` Add dependencies to `Cargo.toml`: ```toml [dependencies] -nym-sdk = "1.20.4" +nym-sdk = "1.21.0" +nym-bin-common = { version = "1.21.0", features = ["basic_tracing"] } tokio = { version = "1", features = ["full"] } +rand = "0.8" ``` ## Step 2: Build the echo server @@ -2669,11 +2633,13 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt}; #[tokio::main] async fn main() { + nym_bin_common::logging::setup_tracing_logger(); + // Connect to the Mixnet let mut client = mixnet::MixnetClient::connect_new().await.unwrap(); println!("Echo server listening at: {}", client.nym_address()); - // Create a listener — this activates stream mode. + // Create a listener: this activates stream mode. // From this point, message-based methods are disabled. let mut listener = client.listener().unwrap(); @@ -2692,11 +2658,11 @@ async fn main() { // Spawn a task to handle each stream concurrently tokio::spawn(async move { - let mut buf = vec![0u8; 4096]; + let mut buf = vec![0u8; 32_000]; loop { let n = match stream.read(&mut buf).await { - Ok(0) => break, // EOF — stream closed + Ok(0) => break, // EOF: stream closed Ok(n) => n, Err(e) => { eprintln!("Stream {stream_id} read error: {e}"); @@ -2705,11 +2671,7 @@ async fn main() { }; let data = &buf[..n]; - println!( - "Stream {stream_id} received {} bytes: {:?}", - n, - String::from_utf8_lossy(data) - ); + println!("Stream {stream_id} received {n} bytes"); // Echo it back if let Err(e) = stream.write_all(data).await { @@ -2742,6 +2704,8 @@ const TIMEOUT: Duration = Duration::from_secs(60); #[tokio::main] async fn main() { + nym_bin_common::logging::setup_tracing_logger(); + // Read the server's Nym address from the command line let server_addr: Recipient = std::env::args() .nth(1) @@ -2758,22 +2722,32 @@ async fn main() { let mut stream = client.open_stream(server_addr, None).await.unwrap(); println!("Stream opened: {}", stream.id()); - // Send three messages and read back the echo for each - for i in 1..=3 { - let msg = format!("message {i}"); - println!("Sending: {msg}"); + // Give the Open message time to traverse the mixnet and reach the server. + // open_stream() returns immediately after sending. It doesn't wait for + // the server to accept. Writing too soon risks the data arriving before + // the Open, which the server would drop. + tokio::time::sleep(Duration::from_secs(5)).await; - stream.write_all(msg.as_bytes()).await.unwrap(); + // Send three payloads of different sizes and verify the echo. + // Random bytes show that streams are binary-safe, not just text. + let sizes = [320, 25_000, 1280]; + + for (i, &size) in sizes.iter().enumerate() { + let payload: Vec = (0..size).map(|_| rand::random::()).collect(); + println!("Sending message {} ({size} bytes)", i + 1); + + stream.write_all(&payload).await.unwrap(); stream.flush().await.unwrap(); // Read the echo - let mut buf = vec![0u8; 1024]; + let mut buf = vec![0u8; 32_000]; let n = tokio::time::timeout(TIMEOUT, stream.read(&mut buf)) .await .expect("timed out waiting for echo") .expect("read failed"); - println!("Echo: {}", String::from_utf8_lossy(&buf[..n])); + assert_eq!(&buf[..n], &payload[..], "echo mismatch on message {}", i + 1); + println!("Received echo: {n} bytes ok"); } // Drop the stream to deregister it from the router @@ -2790,7 +2764,7 @@ async fn main() { In one terminal, start the server: ```sh -cargo run --bin server +RUST_LOG=info cargo run --bin server ``` It prints its Nym address: @@ -2802,7 +2776,7 @@ Echo server listening at: 8gk4Y...@2xU4d... In a second terminal, start the client with the server's address: ```sh -cargo run --bin client 8gk4Y...@2xU4d... +RUST_LOG=info cargo run --bin client -- 8gk4Y...@2xU4d... ``` You'll see the messages traverse the Mixnet and echo back: @@ -2810,12 +2784,12 @@ You'll see the messages traverse the Mixnet and echo back: ``` Client address: F3qR7...@9nK2m... Stream opened: 12345678 -Sending: message 1 -Echo: message 1 -Sending: message 2 -Echo: message 2 -Sending: message 3 -Echo: message 3 +Sending message 1 (320 bytes) +Received echo: 320 bytes ok +Sending message 2 (25000 bytes) +Received echo: 25000 bytes ok +Sending message 3 (1280 bytes) +Received echo: 1280 bytes ok Done! ``` @@ -2823,13 +2797,13 @@ On the server side: ``` Accepted stream 12345678 -Stream 12345678 received 9 bytes: "message 1" -Stream 12345678 received 9 bytes: "message 2" -Stream 12345678 received 9 bytes: "message 3" +Stream 12345678 received 320 bytes +Stream 12345678 received 25000 bytes +Stream 12345678 received 1280 bytes Stream 12345678 closed ``` -## What's happening under the hood +## How it works internally 1. The server's `listener()` activates **stream mode**, which spawns a **router task** that decodes incoming Mixnet messages and dispatches them by stream ID. @@ -2837,25 +2811,25 @@ Stream 12345678 closed 3. When the server's router receives the `Open` message, it delivers it to `listener.accept()`, which creates the inbound `MixnetStream`. -4. Each `write_all()` prepends a 10-byte header (`[version][stream_id][message_type]`) and sends the data through the Mixnet as a Sphinx packet. +4. Each `write_all()` prepends a 16-byte LP frame header (`[LpFrameKind: 2B][StreamId: 8B][MsgType: 1B][SequenceNum: 4B][Reserved: 1B]`) and sends the data through the Mixnet as a Sphinx packet. -5. On arrival, the router decodes the header, finds the matching stream, and delivers the raw payload to `read()`. +5. On arrival, the router reads the `LpFrameKind` to identify it as stream traffic, decodes the header, finds the matching stream by ID, and delivers the raw payload to `read()`. -6. The inbound stream replies via **reply SURBs** — it never learns the client's Nym address. This is the same anonymous reply mechanism used by the message API, applied transparently. +6. The inbound stream replies via **reply SURBs**, the same anonymous reply mechanism as the message API, applied transparently. The server never learns the client's Nym address. -7. When a stream is dropped, it deregisters from the local router. No close message is sent over the wire (because the Mixnet doesn't guarantee message ordering — a close could arrive before the final data). +7. When a stream is dropped, it deregisters from the local router. No close message is sent over the wire, since a close could race ahead of in-flight data. See the [Architecture](./architecture) page for the full technical details. ## What you've learned -- **`client.listener()`** activates stream mode and returns a `MixnetListener` -- **`listener.accept()`** blocks until a remote peer opens a stream -- **`client.open_stream(recipient, surbs)`** opens an outbound stream -- **`MixnetStream`** implements `AsyncRead + AsyncWrite` — standard tokio I/O -- Streams are **multiplexed** over a single client — you can open many to different peers -- **Cleanup is automatic on `drop`** — no close handshake needed -- **Reply SURBs** enable the server to respond without knowing the client's address +- `client.listener()` activates stream mode and returns a `MixnetListener` +- `listener.accept()` blocks until a remote peer opens a stream +- `client.open_stream(recipient, surbs)` opens an outbound stream to a Nym address +- `MixnetStream` implements `AsyncRead + AsyncWrite`, so standard tokio I/O works unchanged +- Multiple streams are multiplexed over a single client +- Streams deregister on `drop`; no close handshake is needed +- The server replies via SURBs and never learns the client's address ## Complete code @@ -2867,6 +2841,8 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt}; #[tokio::main] async fn main() { + nym_bin_common::logging::setup_tracing_logger(); + let mut client = mixnet::MixnetClient::connect_new().await.unwrap(); println!("Echo server listening at: {}", client.nym_address()); @@ -2875,24 +2851,38 @@ async fn main() { loop { let mut stream = match listener.accept().await { Some(s) => s, - None => break, + None => { + println!("Listener closed"); + break; + } }; let stream_id = stream.id(); println!("Accepted stream {stream_id}"); tokio::spawn(async move { - let mut buf = vec![0u8; 4096]; + let mut buf = vec![0u8; 32_000]; + loop { let n = match stream.read(&mut buf).await { - Ok(0) | Err(_) => break, + Ok(0) => break, Ok(n) => n, + Err(e) => { + eprintln!("Stream {stream_id} read error: {e}"); + break; + } }; - if let Err(_) = stream.write_all(&buf[..n]).await { + + let data = &buf[..n]; + println!("Stream {stream_id} received {n} bytes"); + + if let Err(e) = stream.write_all(data).await { + eprintln!("Stream {stream_id} write error: {e}"); break; } stream.flush().await.unwrap(); } + println!("Stream {stream_id} closed"); }); } @@ -2910,6 +2900,8 @@ const TIMEOUT: Duration = Duration::from_secs(60); #[tokio::main] async fn main() { + nym_bin_common::logging::setup_tracing_logger(); + let server_addr: Recipient = std::env::args() .nth(1) .expect("Usage: client ") @@ -2922,20 +2914,26 @@ async fn main() { let mut stream = client.open_stream(server_addr, None).await.unwrap(); println!("Stream opened: {}", stream.id()); - for i in 1..=3 { - let msg = format!("message {i}"); - println!("Sending: {msg}"); + // Wait for the Open message to reach the server through the mixnet + tokio::time::sleep(Duration::from_secs(5)).await; - stream.write_all(msg.as_bytes()).await.unwrap(); + let sizes = [320, 25_000, 1280]; + + for (i, &size) in sizes.iter().enumerate() { + let payload: Vec = (0..size).map(|_| rand::random::()).collect(); + println!("Sending message {} ({size} bytes)", i + 1); + + stream.write_all(&payload).await.unwrap(); stream.flush().await.unwrap(); - let mut buf = vec![0u8; 1024]; + let mut buf = vec![0u8; 32_000]; let n = tokio::time::timeout(TIMEOUT, stream.read(&mut buf)) .await .expect("timed out waiting for echo") .expect("read failed"); - println!("Echo: {}", String::from_utf8_lossy(&buf[..n])); + assert_eq!(&buf[..n], &payload[..], "echo mismatch on message {}", i + 1); + println!("Received echo: {n} bytes ok"); } drop(stream); @@ -2978,17 +2976,19 @@ flowchart TD ## Wire protocol -Every stream message has a fixed 10-byte header prepended to the payload: +Every stream message has a fixed 16-byte LP frame header prepended to the payload: ``` -[Version: 1 byte][StreamId: 8 bytes][MessageType: 1 byte][payload ...] +[LpFrameKind: 2 bytes LE][StreamId: 8 bytes BE][MsgType: 1 byte][SequenceNum: 4 bytes BE][Reserved: 1 byte][payload ...] ``` -- **Version** — protocol version (`1`). Unknown versions are rejected. -- **StreamId** — random `u64` generated by the opener, used to multiplex streams. -- **MessageType** — `Open` (0) or `Data` (1). +- **LpFrameKind:** `3` (SphinxStream). Distinguishes stream traffic from other LP frame types (Opaque, Registration, Forward). +- **StreamId:** random `u64` generated by the opener, used to multiplex streams. +- **MsgType:** `Open` (0) or `Data` (1). +- **SequenceNum:** `u32` counter, incremented per write. Used by the receiver's per-stream reorder buffer to deliver data in the correct order. +- **Reserved:** must be `0x00`. -There is no `Close` message type — see [Known Limitations](#known-limitations) for why. +There is no `Close` message type; see [Known Limitations](#known-limitations) for why. ## Stream mode @@ -3014,23 +3014,20 @@ There is no switching back without disconnecting and creating a new client. ## Cleanup -- **On `drop`** — the stream deregisters from the routing table. No close message is sent over the wire. -- **Idle timeout** — streams idle for longer than the configured timeout (default: 30 minutes) are automatically cleaned up. Configure with [`MixnetClientBuilder::with_stream_idle_timeout()`](https://docs.rs/nym-sdk/latest/nym_sdk/mixnet/struct.MixnetClientBuilder.html). +- **On `drop`:** the stream deregisters from the routing table. No close message is sent over the wire. +- **Idle timeout:** streams idle for longer than the configured timeout (default: 30 minutes) are automatically cleaned up. Configure with [`MixnetClientBuilder::with_stream_idle_timeout()`](https://docs.rs/nym-sdk/latest/nym_sdk/mixnet/struct.MixnetClientBuilder.html). ## Known limitations -**No message ordering.** The Mixnet does not guarantee message ordering. Messages on a stream can arrive out of order. This means: -- Large writes that span multiple Sphinx packets may arrive shuffled -- There is no `Close` message — a close could race ahead of in-flight data -- Protocols that depend on byte ordering (HTTP, TLS, protobuf) may not work correctly over streams yet +**Sequence-based reordering.** The Mixnet does not guarantee message ordering at the transport level, but each stream write includes a `sequence_num` in the LP frame header. The receiver maintains a per-stream reorder buffer (BTreeMap keyed by sequence number) that buffers out-of-order messages and drains them in sequence. This means protocols that depend on byte ordering (HTTP, TLS, protobuf) work correctly over streams. -Sequencing (similar to the `MessageBuffer` in the TcpProxy module) is planned for a future release. - -**No protocol discriminator.** There is currently no way to distinguish stream messages from regular Mixnet messages. Sending to a non-stream client will deliver bytes with the stream header prepended. A protocol discriminator is planned for a future release. +- **Buffer cap:** 256 messages per stream. If the buffer fills (e.g. a large gap in sequence numbers), the receiver skips ahead to the lowest buffered sequence. +- **Duplicates:** messages with a sequence number below the next expected are dropped. +- There is no `Close` message type, since a close could race ahead of in-flight data. ## Internal details -For the full implementation details (router task, `StreamMap`, `PollSender` usage, base-client type rationale), see the [architecture documentation in the source tree](https://docs.rs/nym-sdk/latest/nym_sdk/mixnet/stream/) or the `ARCHITECTURE.md` file next to the module code. +For the full implementation details (router task, `StreamMap`, `PollSender` usage, base-client type rationale), see the `ARCHITECTURE.md` file next to the module source code, or the [docs.rs](https://docs.rs/nym-sdk/latest/nym_sdk/) API reference. --- title: Stream Module Examples @@ -3061,15 +3058,15 @@ url: https://nym.com/docs/developers/rust/tcpproxy # TcpProxy Module - **This module is unmaintained.** The TcpProxy is no longer actively developed in favour of the [Stream module](./stream), which provides `AsyncRead + AsyncWrite` streams directly over the Mixnet without the TCP socket overhead. Existing users should plan to migrate to streams when possible. The TcpProxy will continue to work but will not receive new features or bug fixes. + **This module is unmaintained.** The TcpProxy is no longer actively developed in favour of the [Stream module](/developers/rust/stream), which provides `AsyncRead + AsyncWrite` streams directly over the Mixnet without the TCP socket overhead. Existing users should plan to migrate to streams when possible. The TcpProxy will continue to work but will not receive new features or bug fixes. -The Stream module offers the same key benefit (familiar I/O patterns on top of the Mixnet) with a simpler API, multiplexed connections on a single client, and no localhost socket overhead. The one feature TcpProxy has that streams don't yet have is **message ordering** — see the [stream architecture](./stream/architecture#known-limitations) for details. If your application requires guaranteed byte ordering today, TcpProxy still works. +The Stream module offers the same key benefit (familiar I/O patterns on top of the Mixnet) with a simpler API. Streams multiplex connections on a single client, eliminate the localhost socket overhead, and now include sequence-based message reordering. There is no remaining reason to choose TcpProxy over Streams for new projects. --- -This module exposes `NymProxyClient` and `NymProxyServer` for proxying TCP traffic through the Mixnet. Both are initialised and run in a background thread, exposing a configurable `localhost` socket which you can read/write to without worrying about the Mixnet's message-based internals. +`NymProxyClient` and `NymProxyServer` proxy TCP traffic through the Mixnet. Both run in a background thread and expose a configurable `localhost` socket that you read and write to like any other TCP connection. -> Non-Rust/Go developers who want to experiment with this module can start with the [standalone binaries](../tools/standalone-tcpproxy). +> Non-Rust/Go developers who want to experiment with this module can start with the [standalone binaries](/developers/tools/standalone-tcpproxy). ## Examples @@ -3078,60 +3075,34 @@ This module exposes `NymProxyClient` and `NymProxyServer` for proxying TCP traff | Single connection | [`tcp_proxy_single_connection.rs`](https://github.com/nymtech/nym/blob/develop/sdk/rust/nym-sdk/examples/tcp_proxy_single_connection.rs) | | Multiple connections | [`tcp_proxy_multistream.rs`](https://github.com/nymtech/nym/blob/develop/sdk/rust/nym-sdk/examples/tcp_proxy_multistream.rs) | +```bash +cargo run --example tcp_proxy_single_connection +cargo run --example tcp_proxy_multistream +``` + ## API reference -- [API reference on docs.rs](https://docs.rs/nym-sdk/latest/nym_sdk/tcp_proxy/) — architecture overview, client/server examples, and type documentation +- [API reference on docs.rs](https://docs.rs/nym-sdk/latest/nym_sdk/tcp_proxy/): architecture overview, client/server examples, and type documentation -## Troubleshooting +## Tutorial -### Lots of `duplicate fragment received` messages - -`WARN` level logs about duplicate fragments are caused by Mixnet-level packet retransmission — the original and the retransmitted copy both arrive at the destination. This is not a bug in your client logic or the TcpProxy module. - ---- -title: TcpProxy Tutorial: Tunnel TCP Through the Mixnet -description: Build a proxy server and client that tunnel TCP traffic through the Nym mixnet using the TcpProxy module. Includes NymProxyServer and NymProxyClient setup. -url: https://nym.com/docs/developers/rust/tcpproxy/tutorial ---- - -# Tutorial: Tunnel TCP Through the Mixnet - -The TcpProxy module is **unmaintained**. For new projects, use the [Stream module](../stream) instead. This tutorial exists for users working with existing TcpProxy-based code. - -Build two programs — a proxy server that forwards TCP traffic to a local service, and a proxy client that tunnels connections through the Mixnet. - -## What you'll learn - -- Setting up a `NymProxyServer` that forwards Mixnet traffic to a local TCP service -- Setting up a `NymProxyClient` that tunnels localhost TCP connections through the Mixnet -- How TcpProxy differs from the Stream module - -## Prerequisites - -- Rust toolchain (1.70+) -- A working internet connection - -Code verified against `nym-sdk` v1.20.4 ([`4077717`](https://github.com/nymtech/nym/commit/4077717d3)). If the API has changed since then, check the [examples in the repo](https://github.com/nymtech/nym/tree/develop/sdk/rust/nym-sdk/examples) for the latest usage. - -## Step 1: Set up the project +Set up the project: ```sh cargo init nym-tcp-proxy cd nym-tcp-proxy +rm src/main.rs ``` Add dependencies to `Cargo.toml`: ```toml [dependencies] -nym-sdk = "1.20.4" +nym-sdk = "1.21.0" +nym-network-defaults = "1.21.0" +nym-bin-common = { version = "1.21.0", features = ["basic_tracing"] } tokio = { version = "1", features = ["full"] } -anyhow = "1" -``` -This tutorial creates two binaries (server and client). Add them to `Cargo.toml`: - -```toml [[bin]] name = "proxy_server" path = "src/bin/proxy_server.rs" @@ -3141,79 +3112,68 @@ name = "proxy_client" path = "src/bin/proxy_client.rs" ``` -Create the `src/bin/` directory: +### Server -```sh -mkdir -p src/bin -``` - -## Step 2: Build the server - -The server connects to the Mixnet and forwards incoming traffic to a local TCP service (e.g. a web server on port 3000). - -Create `src/bin/proxy_server.rs`: +The server connects to the Mixnet and forwards incoming traffic to a local TCP service (e.g. a web server on port 8000). ```rust use nym_sdk::tcp_proxy::NymProxyServer; #[tokio::main] -async fn main() -> anyhow::Result<()> { - // Forward traffic to localhost:3000 (your upstream service). - // The second argument is a directory for persistent key storage. +async fn main() -> Result<(), Box> { + nym_bin_common::logging::setup_tracing_logger(); + let mut server = NymProxyServer::new( - "127.0.0.1:3000", // upstream address (host:port) + "127.0.0.1:8000", // upstream address (host:port) "./proxy-server-config", // config directory for persistent keys None, // env file (None = mainnet) None, // gateway (None = auto-select) ).await?; - // Print the Nym address — the client needs this to connect. println!("Proxy server address: {}", server.nym_address()); - - // Blocks until shutdown. Traffic from the Mixnet is forwarded to - // localhost:3000, and responses are sent back via reply SURBs. server.run_with_shutdown().await?; Ok(()) } ``` -## Step 3: Build the client +### Client The client opens a localhost TCP socket and tunnels all traffic through the Mixnet to the server. -Create `src/bin/proxy_client.rs`: - ```rust use nym_sdk::tcp_proxy::NymProxyClient; use nym_sdk::mixnet::Recipient; +use nym_network_defaults::setup_env; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpStream; #[tokio::main] -async fn main() -> anyhow::Result<()> { - // Parse the server's Nym address from the command line. +async fn main() -> Result<(), Box> { + nym_bin_common::logging::setup_tracing_logger(); + // Load mainnet network defaults into env vars (required by NymProxyClient's internal ClientPool) + setup_env(None::); + let server_addr: Recipient = std::env::args() .nth(1).expect("Usage: proxy_client ") .parse()?; - // Create the proxy client — listens on localhost:1080. let client = NymProxyClient::new( server_addr, "127.0.0.1", // listen host - "1080", // listen port + "8070", // listen port 60, // close timeout (seconds) None, // env file (None = mainnet) - 2, // client pool size + 1, // client pool size ).await?; - // Spawn the proxy in the background. let proxy = tokio::spawn(async move { client.run().await }); - // Give the proxy a moment to start listening. - tokio::time::sleep(std::time::Duration::from_secs(3)).await; + // Wait for the pool to create a client and the proxy to be ready. + // The first startup takes ~10-15s while the client connects to the Mixnet. + println!("Waiting for proxy to be ready..."); + tokio::time::sleep(std::time::Duration::from_secs(15)).await; - // Connect to the local proxy socket — traffic goes through the Mixnet. - let mut stream = TcpStream::connect("127.0.0.1:1080").await?; + let mut stream = TcpStream::connect("127.0.0.1:8070").await?; stream.write_all(b"GET / HTTP/1.0\r\nHost: localhost\r\n\r\n").await?; let mut response = Vec::new(); @@ -3226,78 +3186,41 @@ async fn main() -> anyhow::Result<()> { } ``` -## Step 4: Run it +### Run it -Start a simple upstream service (e.g. Python's HTTP server): +Start an upstream TCP service (e.g. a simple HTTP server): ```sh -cd /tmp && echo "hello from upstream" > index.html -python3 -m http.server 3000 +python3 -m http.server 8000 ``` -Terminal 1 — start the proxy server: +In a second terminal, start the proxy server: + ```sh -cargo run --bin proxy_server -# Proxy server address: 8gk4Y...@2xU4d... +RUST_LOG=info cargo run --bin proxy_server ``` -Terminal 2 — start the proxy client: +Copy the Nym address it prints, then in a third terminal: + ```sh -cargo run --bin proxy_client 8gk4Y...@2xU4d... -# Response: -# HTTP/1.0 200 OK -# ... -# hello from upstream +RUST_LOG=info cargo run --bin proxy_client -- ``` -The HTTP request travelled through the Mixnet — the upstream server only sees a connection from `localhost`, not the client's real IP. +The response will take 30–60 seconds to arrive as it traverses the Mixnet in both directions. -## How it differs from streams +## Architecture -TcpProxy handles **message ordering** internally using session IDs and sequence numbers, which the [Stream module](../stream) does not yet provide. This means TcpProxy can work with protocols that depend on byte ordering (HTTP, TLS). The trade-off is higher overhead: each side runs a localhost TCP socket, and ordering adds latency. For new code, the stream API is simpler and more efficient. +Each sub-module handles Nym clients differently: +- **`NymProxyClient`** relies on the [Client Pool](/developers/rust/client-pool) to create clients and keep a reserve. If incoming TCP connections outpace the pool, it creates an ephemeral client per connection. One client maps to one TCP connection. +- **`NymProxyServer`** has a single Nym client with a persistent identity. -## What you've learned +### Sessions & message ordering -- **`NymProxyServer::new(upstream, config_dir, env, gateway)`** creates a server that forwards Mixnet traffic to a local TCP service -- **`NymProxyClient::new(recipient, host, port, timeout, env, pool_size)`** creates a client that tunnels localhost TCP through the Mixnet -- **The server uses persistent keys** (stored in `config_dir`) so its Nym address stays the same across restarts -- **The client uses ephemeral keys** from a `ClientPool` — one per TCP connection -- **TcpProxy handles message ordering** — unlike the Stream module, it can work with order-dependent protocols like HTTP +Messages are wrapped in a session ID per connection, with individual messages given an incrementing message ID. Once all messages are sent, the client sends a `Close` message to notify the server that there are no more outbound messages for this session. ---- -title: TcpProxy Architecture -description: Architecture of the Nym TcpProxy module: client and server design, byte framing, session management, and message ordering over the mixnet. -url: https://nym.com/docs/developers/rust/tcpproxy/architecture ---- +> Session management and message IDs are necessary since *the Mixnet guarantees message delivery but not message ordering*: in the case of trying to e.g. send gRPC protobuf through the Mixnet, ordering is required so that a buffer is not split across Sphinx packet payloads, and that the 2nd half of the frame is not passed upstream to the parser before the 1st half. -# Architecture - -**This module is unmaintained.** See the [Stream module](../stream) for the actively developed replacement. Existing users should plan to migrate when possible. - -## Motivations -The motivation behind the creation of the `TcpProxy` module is to allow developers to interact with the Mixnet in a way that is far more familiar to them: simply setting up a connection with a transport, being returned a socket, and then being able to stream data to/from it, similar to something like the Tor [`arti`](https://gitlab.torproject.org/tpo/core/arti/-/tree/main/crates/arti-client) client. - -## Clients -Each of the sub-modules exposed by the `TcpProxy` deal with Nym clients in a different way. -- the `NymProxyClient` relies on the [`Client Pool`](../client-pool) to create clients and keep a certain number of them in reserve. If the amount of incoming TCP connections rises quicker than the Client Pool can create clients, or you have the pool size set to `0`, the `TcpProxyClient` creates an ephemeral client per new TCP connection, which is closed according to the configurable timeout: we map one ephemeral client per TCP connection. This is to deal with multiple simultaneous streams. -- the `NymProxyServer` has a single Nym client with a persistent identity. - -## Framing -We are currently relying on the [`tokio::Bytecodec`](https://docs.rs/tokio-util/latest/tokio_util/codec/struct.BytesCodec.html) and [`framedRead`](https://docs.rs/tokio-util/latest/tokio_util/codec/struct.Framed.html) to frame bytes moving through the `NymProxyClient` and `NymProxyServer`. - -> For those interested, under the hood the client uses our own [`NymCodec`](https://github.com/nymtech/nym/blob/27ac34522cf0f8bfe1ca265e0b57ee52f2ded0d2/common/nymsphinx/framing/src/codec.rs) to frame message bytes as Sphinx packet payloads. - -## Sessions & Message Ordering -We have implemented session management and message ordering, where messages are wrapped in a session ID per connection, with individual messages being given an incrementing message ID. Once all the messages have been sent, the `NymProxyClient` then sends a `Close` message as the last outgoing message. This is to notify the `NymProxyServer` that there are no more outbound messages for this session, and that it can trigger the session timeout. - -> Session management and message IDs are necessary since *the Mixnet guarantees message delivery but not message ordering*: in the case of trying to e.g. send gRPC protobuf through the Mixnet, ordering is required so that a buffer is not split across Sphinx packet payloads, and that the 2nd half of the frame is not passed upstream to the gRPC parser before the 1st half, even if it is received first. - -Lets step through a full request/response path between a client process communicating with a remote host via the proxies: - -### Outgoing Client Request -The `NymProxyClient` instance, once initialised and running, listens out for incoming TCP connections on its localhost port. - -On receiving one, it will create a new session ID and packetise the incoming bytes into messages of the following structure: +The key data structure: ```rust pub struct ProxiedMessage { @@ -3307,106 +3230,8 @@ pub struct ProxiedMessage { } ``` -> This code can be found [here](https://github.com/nymtech/nym/blob/develop/sdk/rust/nym-sdk/src/tcp_proxy/utils.rs#L147C1-L152C2) +### Full request/response flow -And then send these to the Nym address of the `NymProxyServer` instance. Not much to see here regarding message ordering, as the potential for reordering only starts once packets are travelling through the Mixnet. - -```mermaid ---- -config: - theme: neo-dark - layout: elk ---- -sequenceDiagram - box Local Machine - participant Client Process - participant NymProxyClient - end - Client Process->>NymProxyClient: Request bytes - NymProxyClient->>NymProxyClient: New session - NymProxyClient->>EntryGateway: Sphinx Packets: Message 1 - EntryGateway-->>NymProxyClient: Acks - NymProxyClient->>EntryGateway: Sphinx Packets: Message 2 - EntryGateway-->>NymProxyClient: Acks - NymProxyClient->>EntryGateway: Sphinx Packets: Message 3 - EntryGateway-->>NymProxyClient: Acks - NymProxyClient->>EntryGateway: Sphinx Packets: Close Message - NymProxyClient->>NymProxyClient: Start Client Close timeout - EntryGateway-->>NymProxyClient: Acks -``` - -### Server Receives Request & Responds - -Here is a diagrammatic representation of a situation in which the request arrives out of order, and how the message buffer deals with this so as not to pass a malformed request upstream to the process running on the same remote host: - -```mermaid ---- -config: - theme: neo-dark - layout: elk ---- -sequenceDiagram - Exit Gateway->>NymProxyServer: Sphinx Packets: Message 2 - NymProxyServer-->>Exit Gateway: Acks - Exit Gateway->>NymProxyServer: Sphinx Packets: Message 3 - NymProxyServer-->>Exit Gateway: Acks - loop Message Buffer - NymProxyServer->>NymProxyServer: Wait for Message 1 - Exit Gateway->>NymProxyServer: Sphinx Packets: Message 1 - NymProxyServer-->>Exit Gateway: Acks - NymProxyServer->>NymProxyServer: Message Received: trigger upstream send - end - Note right of NymProxyServer: Note this happens **per session** - NymProxyServer->>Upstream Process: Reconstructed request bytes - Upstream Process->>Upstream Process: Do something with request - Exit Gateway->>NymProxyServer: Sphinx Packets: Message Close - NymProxyServer-->>Exit Gateway: Acks - NymProxyServer->>NymProxyServer: Trigger Client timeout start for session - Upstream Process->>NymProxyServer: Response bytes - NymProxyServer->>NymProxyServer: Write to provided SURB payloads - NymProxyServer->>Exit Gateway: Anonymous replies - - box Remote Host - participant NymProxyServer - participant Upstream Process - end -``` - -> Note that this is per-session, with a session mapped to a single TCP connection. Both the `NymProxyClient` and `Server` are able to handle multiple concurrent connections. - -### Client Receives Response - -The `ProxyClient` deals with incoming traffic in the same way as the `ProxyServer`, with a per-session message queue: - -```mermaid ---- -config: - theme: neo-dark - layout: elk ---- -sequenceDiagram - box Local Machine - participant Client Process - participant NymProxyClient - end - Entry Gateway--xNymProxyClient: Sphinx Packets: Reply Message 1 dropped: No Ack! - Entry Gateway->>NymProxyClient: Sphinx Packets: Reply Message 2 - NymProxyClient-->Entry Gateway: Ack - Entry Gateway->>NymProxyClient: Sphinx Packets: Reply Message 3 - NymProxyClient-->Entry Gateway: Ack - Loop Message Buffer: - NymProxyClient->>NymProxyClient: Wait for Message 1 - Entry Gateway->>NymProxyClient: Sphinx Packets: Message 1 - NymProxyClient-->>Entry Gateway: Acks - NymProxyClient->>NymProxyClient: Message Received: trigger send - NymProxyClient->>Client Process: Response bytes - end - Note right of NymProxyClient: Note this happens **per session** -``` - -After receiving the packets, it can then forward the recorded bytes to the requesting process. - -### Full Flow Diagram ```mermaid --- config: @@ -3424,8 +3249,6 @@ sequenceDiagram Entry Gateway-->>NymProxyClient: Acks NymProxyClient->>Entry Gateway: Sphinx Packets: Message 2 Entry Gateway-->>NymProxyClient: Acks - NymProxyClient->>Entry Gateway: Sphinx Packets: Message 3 - Entry Gateway-->>NymProxyClient: Acks NymProxyClient->>Entry Gateway: Sphinx Packets: Close Message Entry Gateway-->>NymProxyClient: Acks @@ -3435,8 +3258,6 @@ sequenceDiagram Exit Gateway->>NymProxyServer: Sphinx Packets: Message 2 NymProxyServer-->>Exit Gateway: Acks - Exit Gateway->>NymProxyServer: Sphinx Packets: Message 3 - NymProxyServer-->>Exit Gateway: Acks loop Message Buffer NymProxyServer->>NymProxyServer: Wait for Message 1 Exit Gateway->>NymProxyServer: Sphinx Packets: Message 1 @@ -3457,11 +3278,8 @@ sequenceDiagram participant Upstream Process end - Entry Gateway--xNymProxyClient: Sphinx Packets: Reply Message 1 dropped: No Ack! Entry Gateway->>NymProxyClient: Sphinx Packets: Reply Message 2 NymProxyClient-->Entry Gateway: Ack - Entry Gateway->>NymProxyClient: Sphinx Packets: Reply Message 3 - NymProxyClient-->Entry Gateway: Ack Loop Message Buffer: NymProxyClient->>NymProxyClient: Wait for Message 1 Entry Gateway->>NymProxyClient: Sphinx Packets: Message 1 @@ -3472,6 +3290,12 @@ sequenceDiagram Note right of NymProxyClient: Note this happens **per session** ``` +## Troubleshooting + +### Lots of `duplicate fragment received` messages + +`WARN` level logs about duplicate fragments are caused by Mixnet-level packet retransmission, where both the original and the retransmitted copy arrive at the destination. This is expected behaviour, not a bug in the client or TcpProxy module. + --- title: Client Pool: Pre-Connected Mixnet Clients description: The Nym ClientPool maintains ready-to-use MixnetClient instances, eliminating connection latency for bursty traffic patterns. @@ -3480,7 +3304,7 @@ url: https://nym.com/docs/developers/rust/client-pool # Client Pool -The `ClientPool` maintains a configurable number of connected ephemeral `MixnetClient` instances, ready for immediate use. This eliminates the connection latency (gateway handshake, key generation, topology fetch) that comes with creating a new client on each request. +The `ClientPool` maintains a configurable number of connected ephemeral `MixnetClient` instances, ready for immediate use. This eliminates the connection latency that comes with creating a new client on each request: the gateway handshake, key generation, and topology fetch all happen ahead of time. ## How it works @@ -3497,22 +3321,27 @@ flowchart LR ``` 1. **Create** the pool with a target reserve size: `ClientPool::new(5)` -2. **Start** the background loop: `pool.start()` — it immediately begins connecting clients +2. **Start** the background loop: `pool.start()`. It immediately begins connecting clients 3. **Pop** a client when needed: `pool.get_mixnet_client()` returns `Some(client)` or `None` if the pool is empty -4. **Use** the client normally — send messages, open streams, etc. -5. **Disconnect** the client when done — the background loop notices the pool is below reserve and creates a replacement +4. **Use** the client normally: send messages, open streams, etc. +5. **Disconnect** the client when done. The background loop notices the pool is below reserve and creates a replacement Clients are **consumed, not returned**. The pool creates new ones to maintain the reserve. If the pool is empty, you can fall back to `MixnetClient::connect_new()` (slower, but keeps things working). -The `NymProxyClient` (TcpProxy) uses a `ClientPool` internally — one client per incoming TCP connection. +The `NymProxyClient` (TcpProxy) uses a `ClientPool` internally: one client per incoming TCP connection. ## Quick example ```rust use nym_sdk::client_pool::ClientPool; +use nym_network_defaults::setup_env; #[tokio::main] -async fn main() -> anyhow::Result<()> { +async fn main() -> Result<(), Box> { + nym_bin_common::logging::setup_tracing_logger(); + // Load mainnet network defaults into env vars (required by ClientPool) + setup_env(None::); + let pool = ClientPool::new(5); // maintain 5 clients in reserve let pool_clone = pool.clone(); @@ -3531,9 +3360,9 @@ async fn main() -> anyhow::Result<()> { ## Further reading -- [Tutorial: Handle bursty traffic](./client-pool/tutorial) — step-by-step guide covering pool creation, burst handling, and fallback logic -- [API reference on docs.rs](https://docs.rs/nym-sdk/latest/nym_sdk/client_pool/) — type details, method signatures, and architecture docs -- [Example source on GitHub](https://github.com/nymtech/nym/blob/develop/sdk/rust/nym-sdk/examples/client_pool.rs) — complete working example +- [Tutorial: Handle bursty traffic](./client-pool/tutorial): step-by-step guide covering pool creation, burst handling, and fallback logic +- [API reference on docs.rs](https://docs.rs/nym-sdk/latest/nym_sdk/client_pool/): type details, method signatures, and architecture docs +- [Example source on GitHub](https://github.com/nymtech/nym/blob/develop/sdk/rust/nym-sdk/examples/client_pool.rs): complete working example --- title: Client Pool Tutorial: Handle Bursty Traffic @@ -3553,11 +3382,9 @@ In this tutorial you'll build a program that uses `ClientPool` to handle bursts - Observing pool replenishment - Graceful shutdown -Code verified against `nym-sdk` v1.20.4 ([`4077717`](https://github.com/nymtech/nym/commit/4077717d3)). If the API has changed since then, check the [examples in the repo](https://github.com/nymtech/nym/tree/develop/sdk/rust/nym-sdk/examples) for the latest usage. - ## Prerequisites -- Rust toolchain (1.70+) +- Rust toolchain ({RUST_MSRV}+) - A working internet connection ## Step 1: Set up the project @@ -3571,23 +3398,31 @@ Add dependencies to `Cargo.toml`: ```toml [dependencies] -nym-sdk = "1.20.4" +nym-sdk = "1.21.0" +nym-network-defaults = "1.21.0" +nym-bin-common = { version = "1.21.0", features = ["basic_tracing"] } tokio = { version = "1", features = ["full"] } ``` ## Step 2: Create and start the pool -The pool is created with a **reserve size** — the number of connected clients it tries to maintain at all times. The `start()` method runs a background loop that creates clients whenever the pool drops below the reserve. +The pool is created with a **reserve size**: the number of connected clients it tries to maintain at all times. The `start()` method runs a background loop that creates clients whenever the pool drops below the reserve. Create `src/main.rs`: ```rust use nym_sdk::client_pool::ClientPool; use nym_sdk::mixnet::MixnetMessageSender; +use nym_network_defaults::setup_env; use std::time::Duration; #[tokio::main] async fn main() { + nym_bin_common::logging::setup_tracing_logger(); + + // Load mainnet network defaults into env vars (required by ClientPool) + setup_env(None::); + // Create a pool that maintains 3 clients in reserve let pool = ClientPool::new(3); @@ -3598,7 +3433,7 @@ async fn main() { pool_bg.start().await.unwrap(); }); - println!("Pool started — waiting for clients to connect..."); + println!("Pool started, waiting for clients to connect..."); tokio::time::sleep(Duration::from_secs(15)).await; // Check how many are ready @@ -3621,20 +3456,20 @@ When you call `get_mixnet_client()`, the pool removes a client and returns it. T let handle = tokio::spawn(async move { // Pop a client from the pool - let client = match pool.get_mixnet_client().await { + let mut client = match pool.get_mixnet_client().await { Some(c) => { println!("Task {i}: got client {} from pool", c.nym_address()); c } None => { - // Pool is empty — fall back to creating one on the fly. + // Pool is empty: fall back to creating one on the fly. // This is slower but keeps things working. println!("Task {i}: pool empty, creating client on the fly..."); nym_sdk::mixnet::MixnetClient::connect_new().await.unwrap() } }; - // Do something with the client — here, send a message to ourselves + // Do something with the client: here, send a message to ourselves let addr = *client.nym_address(); client .send_plain_message(addr, format!("hello from task {i}")) @@ -3653,7 +3488,7 @@ When you call `get_mixnet_client()`, the pool removes a client and returns it. T } } - // Disconnect when done — the pool will create a replacement + // Disconnect when done: the pool will create a replacement client.disconnect().await; println!("Task {i}: done"); }); @@ -3692,13 +3527,13 @@ After popping all 3 clients, the pool background loop starts creating replacemen ## Step 6: Run it ```sh -cargo run +RUST_LOG=info cargo run ``` You'll see output like: ``` -Pool started — waiting for clients to connect... +Pool started, waiting for clients to connect... Pool has 3 clients ready Task 1: got client 8gk4Y...@2xU4d... from pool Task 2: got client F3qR7...@9nK2m... from pool @@ -3719,20 +3554,20 @@ Pool shut down The pool is most useful when: -- **You have bursty traffic** — many concurrent operations that each need their own client -- **Latency matters** — you can't afford the several-second delay of creating a client on each request -- **You're building a service** — an API endpoint that creates a client per request would benefit from pre-warmed clients +- **You have bursty traffic:** many concurrent operations that each need their own client +- **Latency matters:** you can't afford the several-second delay of creating a client on each request +- **You're building a service:** an API endpoint that creates a client per request would benefit from pre-warmed clients If your application only ever needs one client at a time, just use `MixnetClient::connect_new()` directly. -The `NymProxyClient` (TcpProxy module) uses a `ClientPool` internally — one client per incoming TCP connection. +The `NymProxyClient` (TcpProxy module) uses a `ClientPool` internally: one client per incoming TCP connection. ## What you've learned - **`ClientPool::new(n)`** creates a pool targeting `n` reserve clients - **`pool.start()`** runs a background loop that creates clients whenever the pool is below reserve -- **`pool.get_mixnet_client()`** pops a client — returns `None` if the pool is empty -- **Clients are consumed, not returned** — the pool automatically creates replacements +- **`pool.get_mixnet_client()`** pops a client; returns `None` if the pool is empty +- **Clients are consumed, not returned.** The pool automatically creates replacements - **`pool.disconnect_pool()`** shuts down all remaining clients and stops the background loop - **Fall back to on-demand creation** when the pool is empty for resilience @@ -3741,25 +3576,42 @@ The `NymProxyClient` (TcpProxy module) uses a `ClientPool` internally — one cl ```rust use nym_sdk::client_pool::ClientPool; use nym_sdk::mixnet::MixnetMessageSender; +use nym_network_defaults::setup_env; use std::time::Duration; #[tokio::main] async fn main() { - let pool = ClientPool::new(3); - let pool_bg = pool.clone(); - tokio::spawn(async move { pool_bg.start().await.unwrap() }); + nym_bin_common::logging::setup_tracing_logger(); + setup_env(None::); - println!("Waiting for pool to fill..."); + let pool = ClientPool::new(3); + + let pool_bg = pool.clone(); + tokio::spawn(async move { + pool_bg.start().await.unwrap(); + }); + + println!("Pool started, waiting for clients to connect..."); tokio::time::sleep(Duration::from_secs(15)).await; - println!("Pool has {} clients", pool.get_client_count().await); + + let count = pool.get_client_count().await; + println!("Pool has {count} clients ready"); let mut handles = vec![]; + for i in 1..=3 { let pool = pool.clone(); - handles.push(tokio::spawn(async move { - let client = match pool.get_mixnet_client().await { - Some(c) => c, - None => nym_sdk::mixnet::MixnetClient::connect_new().await.unwrap(), + + let handle = tokio::spawn(async move { + let mut client = match pool.get_mixnet_client().await { + Some(c) => { + println!("Task {i}: got client {} from pool", c.nym_address()); + c + } + None => { + println!("Task {i}: pool empty, creating client on the fly..."); + nym_sdk::mixnet::MixnetClient::connect_new().await.unwrap() + } }; let addr = *client.nym_address(); @@ -3769,28 +3621,56 @@ async fn main() { .unwrap(); if let Some(msgs) = client.wait_for_messages().await { - for msg in msgs.iter().filter(|m| !m.message.is_empty()) { - println!("Task {i}: {}", String::from_utf8_lossy(&msg.message)); + for msg in msgs { + if !msg.message.is_empty() { + println!( + "Task {i}: received {:?}", + String::from_utf8_lossy(&msg.message) + ); + } } } client.disconnect().await; - })); + println!("Task {i}: done"); + }); + + handles.push(handle); } for h in handles { h.await.unwrap(); } - println!("Waiting for replenishment..."); + println!("\nWaiting for pool to replenish..."); tokio::time::sleep(Duration::from_secs(15)).await; - println!("Pool has {} clients", pool.get_client_count().await); + + let count = pool.get_client_count().await; + println!("Pool has {count} clients ready again"); pool.disconnect_pool().await; - println!("Done"); + println!("Pool shut down"); } ``` +--- +title: Client Pool Examples +description: Runnable Rust example for the Nym Client Pool: managing multiple MixnetClients with ephemeral fallback. +url: https://nym.com/docs/developers/rust/client-pool/examples +--- + +# Examples + +Runnable examples in [`sdk/rust/nym-sdk/examples/`](https://github.com/nymtech/nym/tree/develop/sdk/rust/nym-sdk/examples). Each file is self-contained with step-by-step comments. + +```bash +cargo run --example +``` + +| Example | Source | What it demonstrates | +|---|---|---| +| Client Pool | [`client_pool.rs`](https://github.com/nymtech/nym/blob/develop/sdk/rust/nym-sdk/examples/client_pool.rs) | Creating a pool of `MixnetClient`s, retrieving clients from the pool, and falling back to ephemeral clients when the pool is empty | + --- title: FFI Bindings: Go and C/C++ description: Use the Nym SDK from Go and C/C++ via FFI bindings. Covers mixnet messaging, anonymous replies, and TcpProxy lifecycle from non-Rust languages. @@ -3812,13 +3692,13 @@ Core logic lives in `shared/` and is imported into language-specific wrappers. T ## What's exposed -**Mixnet** (Go and C/C++) — ephemeral and persistent client creation, sending messages, anonymous replies via SURBs, and listening for incoming messages. +**Mixnet** (Go and C/C++): ephemeral and persistent client creation, sending messages, anonymous replies via SURBs, listening for incoming messages. -**TcpProxy** (Go only) — client and server creation and lifecycle. +**TcpProxy** (Go only): client and server creation and lifecycle. The TcpProxy module is deprecated. For new projects, use the [Stream module](./stream) instead. -**Client Pool and Stream** — no standalone FFI bindings yet. The TcpProxy bindings use the Client Pool internally. +**Client Pool and Stream** have no standalone FFI bindings yet. The TcpProxy bindings use the Client Pool internally. ## Quick example (Go) @@ -3886,10 +3766,10 @@ Each language has a `build.sh` script that compiles the Rust shared library and ## Examples and source -- [Go mixnet example](https://github.com/nymtech/nym/blob/develop/sdk/ffi/go/example.go) — full client lifecycle: init, send, receive, SURB reply -- [Go TcpProxy example](https://github.com/nymtech/nym/blob/develop/sdk/ffi/go/proxy_example.go) — proxy client and server with TCP echo -- [C++ example](https://github.com/nymtech/nym/blob/develop/sdk/ffi/cpp/src/main.cpp) — same flow using Boost threads -- [`sdk/ffi` source](https://github.com/nymtech/nym/tree/develop/sdk/ffi) — full source and build scripts +- [Go mixnet example](https://github.com/nymtech/nym/blob/develop/sdk/ffi/go/example.go): init, send, receive, SURB reply +- [Go TcpProxy example](https://github.com/nymtech/nym/blob/develop/sdk/ffi/go/proxy_example.go): proxy client and server with TCP echo +- [C++ example](https://github.com/nymtech/nym/blob/develop/sdk/ffi/cpp/src/main.cpp): same flow using Boost threads +- [`sdk/ffi` source](https://github.com/nymtech/nym/tree/develop/sdk/ffi): full source and build scripts --- title: Nym TypeScript SDK: Privacy for Web Apps @@ -3901,7 +3781,7 @@ url: https://nym.com/docs/developers/typescript The TypeScript SDK lets you build browser-based applications that communicate through the Nym mixnet. Import SDK packages via NPM as you would any other TypeScript library. -**The Nym mixnet is not like regular internet networking.** There are no persistent connections, no guaranteed message ordering, and no TCP underneath. Traffic is Sphinx-encrypted, mixed through multiple nodes, and independently reconstructed at the destination. This means sending data through the mixnet works differently from what web developers typically expect. The SDK abstracts the complexity, but understanding the underlying model helps when debugging. +The Nym Mixnet routes traffic through multiple nodes with no persistent connections or guaranteed ordering. The SDK abstracts the complexity, but understanding the [underlying model](/developers/rust/tour) helps when debugging. ## Packages @@ -3930,10 +3810,10 @@ The TypeScript SDK lets you build browser-based applications that communicate th All packages (except Contract Clients) come in four variants: -- **ESM** — For new projects with current tooling. You may need to [configure your bundler](./typescript/bundling) to handle WASM and web worker components. -- **ESM full-fat** — Pre-bundled with inline WASM and web workers. No bundler config needed. -- **CommonJS** — For older projects using CommonJS. WASM and web workers need to be [bundled](./typescript/bundling/webpack). -- **CommonJS full-fat** — Pre-bundled, works without additional configuration. +- **ESM:** For new projects with current tooling. You may need to [configure your bundler](./typescript/bundling) to handle WASM and web worker components. +- **ESM full-fat:** Pre-bundled with inline WASM and web workers. No bundler config needed. +- **CommonJS:** For older projects using CommonJS. WASM and web workers need to be [bundled](./typescript/bundling/webpack). +- **CommonJS full-fat:** Pre-bundled, works without additional configuration. All `*-full-fat` variants have large bundle sizes because they include WASM and web workers as inline Base64 strings. Use the standard ESM variant if bundle size matters. @@ -4033,28 +3913,26 @@ console.log(`Tx Hash = ${result.transactionHash}`); ## Next steps -- **[Step-by-step examples](./typescript/examples)** — Full working projects for each package -- **[Live playground](./typescript/playground)** — Try the SDK in your browser -- **[Bundling](./typescript/bundling)** — Configure Webpack or ESBuild for WASM and web workers -- **[TypeDoc reference](./typescript/api)** — generated reference for all packages +- **[Step-by-step examples](./typescript/examples):** Full working projects for each package +- **[Live playground](./typescript/playground):** Try the SDK in your browser +- **[Bundling](./typescript/bundling):** Configure Webpack or ESBuild for WASM and web workers +- **[TypeDoc reference](./typescript/api):** generated reference for all packages --- title: mixFetch Example: Private HTTP Requests -description: Replace browser fetch with mixFetch to route HTTP requests through the Nym mixnet. Covers setup, CA certificates, WSS gateways, and usage examples. +description: Replace browser fetch with mixFetch to route HTTP requests through the Nym mixnet. Covers setup, CA certificates, TLS configuration, and usage examples. url: https://nym.com/docs/developers/typescript/examples/mix-fetch --- # mixFetch -An easy way to secure parts or all of your web app is to replace calls to [`fetch`](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch) with `mixFetch`. It works the same as vanilla `fetch` — it's a proxied wrapper around the original function. +An easy way to secure parts or all of your web app is to replace calls to [`fetch`](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch) with `mixFetch`. It works the same as vanilla `fetch`: it's a proxied wrapper around the original function. Things to be aware of: -- CA certificates in `mixFetch` are periodically updated. If you get a certificate error, the root certificate you need might not be valid yet — [send a PR](https://github.com/nymtech/nym/pulls) if you need changes to the certificates. -- If you are using `mixFetch` in a web app with HTTPS, you will need to use a gateway that has Secure Websockets (WSS) to avoid a [mixed content](https://developer.mozilla.org/en-US/docs/Web/Security/Mixed_content) error. -- `mixFetch` supports concurrent requests (up to 10) to the same or different URLs. - -Right now Gateways are not required to run a Secure Websocket (WSS) listener, so only a subset of nodes running in Gateway mode have configured their nodes to do so. You need to select a Gateway that has WSS from [Harbourmaster](https://harbourmaster.nymtech.net/). +- **CA certificates** are bundled into the WASM binary at build time. They're updated with each SDK release, so if you hit a certificate error, update to the latest `@nymproject/mix-fetch-full-fat` version. +- **HTTPS and WSS.** When serving your app over HTTPS, the mixnet connection must also use Secure WebSockets to avoid a [mixed content](https://developer.mozilla.org/en-US/docs/Web/Security/Mixed_content) error. Set `forceTls: true` in your `SetupMixFetchOps` config (see below) and the SDK will automatically select a WSS-capable gateway. +- `mixFetch` supports **concurrent requests** (up to 10) to the same or different URLs. ## Environment Setup @@ -4075,28 +3953,32 @@ npm run dev ## Installation ```bash -npm install @nymproject/mix-fetch-full-fat +npm install @nymproject/mix-fetch-full-fat @mui/material @emotion/react @emotion/styled ``` +The MUI packages are used by the example UI below. If you only need `mixFetch` itself, install just `@nymproject/mix-fetch-full-fat`. + ## Configuration ```ts const mixFetchOptions: SetupMixFetchOps = { - clientId: "docs-mixfetch-demo", + clientId: "my-app", preferredGateway: "q2A2cbooyC16YJzvdYaSMH9X3cSiieZNtfBr8cE8Fi1", mixFetchOverride: { requestTimeoutMs: 60_000, }, - forceTls: true, // force WSS + forceTls: true, // use Secure WebSockets (required when serving over HTTPS) }; ``` +`preferredGateway` is optional. If omitted, the SDK auto-selects a gateway. You can pin a specific one via [Harbourmaster](https://harbourmaster.nymtech.net/). + ## Full Example This example shows explicit initialization via `createMixFetch`, single URL fetch, and concurrent requests. Results appear both in the UI and in a visible log panel. -For this example we use the `full-fat` version of the ESM SDK. If you use the unbundled ESM variant, make sure your [bundler configuration](../bundling/bundling) copies the WASM and web worker files to the output bundle. +For this example we use the `full-fat` version of the ESM SDK. If you use the unbundled ESM variant, make sure your [bundler configuration](/developers/typescript/bundling/bundling) copies the WASM and web worker files to the output bundle. ```tsx @@ -4176,7 +4058,7 @@ export const MixFetch = () => { } }; - // Single URL fetch — reuses the existing MixFetch singleton + // Single URL fetch: reuses the existing MixFetch singleton const handleFetch = async () => { try { setBusy(true); @@ -4241,7 +4123,7 @@ export const MixFetch = () => { status === "ready" ? "Ready" : `Error: ${errorMsg}`} - {/* Fetch controls — disabled until MixFetch is ready */} + {/* Fetch controls: disabled until MixFetch is ready */} {/* Single fetch */} @@ -4308,7 +4190,7 @@ url: https://nym.com/docs/developers/typescript/examples/mixnet The [`SDK Client`](https://www.npmjs.com/package/@nymproject/sdk) lets you send and receive messages over the Nym mixnet. -The client is message-based — it sends one-way messages to another client's address. Replying can be achieved in two ways: +The client is message-based: it sends one-way messages to another client's address. Replying can be achieved in two ways: - Reveal the sender's address to the recipient (as part of the payload) - Use a SURB (single use reply block) that lets the recipient reply without compromising the identity of either party @@ -4338,7 +4220,7 @@ npm install @nymproject/sdk-full-fat This example creates a Mixnet client, connects to a gateway, and provides a UI for sending and receiving messages through the mixnet. -For this example we use the `full-fat` version of the ESM SDK. If you use the unbundled ESM variant, make sure your [bundler configuration](../bundling/bundling) copies the WASM and web worker files to the output bundle. +For this example we use the `full-fat` version of the ESM SDK. If you use the unbundled ESM variant, make sure your [bundler configuration](/developers/typescript/bundling/bundling) copies the WASM and web worker files to the output bundle. ```ts copy filename="App.tsx" @@ -4871,8 +4753,8 @@ You'll have to experiment with either adjusting the CSP or use another variant t The mixnet client will complain about insufficient topology in the following cases: - There are empty mix layers (rare) -- The gateway you've registered with does not appear in the network topology — it is either unbonded or was blacklisted -- The gateway you want to send packets to does not appear in the network topology — it is either unbonded or was blacklisted +- The gateway you've registered with does not appear in the network topology; it is either unbonded or was blacklisted +- The gateway you want to send packets to does not appear in the network topology; it is either unbonded or was blacklisted To avoid the last two, make sure the gateway you are using is bonded and whitelisted. @@ -4884,7 +4766,7 @@ For example: `DpB3cHAchJi...suko.ANNWrvHq...U2Vx@2BuMSfMW...3SEh` - First part: client's identity key - Second part: client's Diffie-Hellman key -- After `@`: gateway's identity key — search for this in the [Nym Explorer](https://nym.com/explorer) to check its status +- After `@`: gateway's identity key. Search for this in the [Nym Explorer](https://nym.com/explorer) to check its status --- title: Troubleshooting bundling with ESbuild @@ -5029,9 +4911,9 @@ Download and run instructions for the GUIs can be found [here](https://nymvpn.co ## Download & Extract Binary Check the [release page](https://github.com/nymtech/nym-vpn-client/releases/) page for the latest release version and modify the instructions accordingly. These instructions use the latest as of the time of writing. ```sh -wget -q https://github.com/nymtech/nym-vpn-client/releases/download/nym-vpn-core-v1.27.0-beta/nym-vpn-core-v1.27.0-beta_.tar.gz && -tar -xzf nym-vpn-core-v1.27.0-beta_.tar.gz && -cd nym-vpn-core-v1.27.0-beta_/ && +wget -q https://github.com/nymtech/nym-vpn-client/releases/download/nym-vpn-core-v1.29.0/nym-vpn-core-v1.29.0_.tar.gz && +tar -xzf nym-vpn-core-v1.29.0_.tar.gz && +cd nym-vpn-core-v1.29.0_/ && chmod u+x * ``` @@ -5173,6 +5055,64 @@ View the current device identity: nym-vpnc device get ``` +## Pay as You Go: Decentralized Access to Nym + +You can fund your VPN usage directly from your own wallet instead of going through the NymVPN account system. You deposit `$NYM` into the ticketbook smart contract and receive zk-nym ticketbooks that authenticate you on the network. + + If you already have an account stored in `nym-vpnc`, you must remove it first with `nym-vpnc account forget` before setting a new mnemonic. + +### Set Your Mnemonic + +Store the recovery phrase for your on-chain wallet address (`n1...`) that holds your `$NYM` tokens: + +```sh +nym-vpnc account set "" --location blockchain +``` + +You must fund this address yourself, for example by transferring `$NYM` from an exchange or another wallet. The `--location blockchain` flag tells `nym-vpnc` to use the on-chain wallet directly rather than the NymVPN account system. + +### Obtain Ticketbooks + +Deposit `$NYM` into the ticketbook smart contract and receive zk-nym credentials: + +```sh +nym-vpnc account obtain-ticketbooks --amount 1 --source smartcontract +``` + +You can omit `--source` to use the default: + +```sh +nym-vpnc account obtain-ticketbooks --amount 1 +``` + +The `--amount` flag specifies how many ticketbooks to obtain **per ticket type**. Each request issues one ticketbook for each of the three types listed below, so `--amount 1` produces 3 ticketbooks total and `--amount 2` produces 6. + +Each ticketbook contains **50 tickets** and is valid for **7 days**. Each ticketbook costs **75 NYM**, so `--amount 1` deposits **225 NYM** (75 × 3 types) and `--amount 2` deposits **450 NYM**. + +| Kind | Ticket size | Ticketbook capacity | Used for | +|------|-------------|---------------------|----------| +| Mixnet Entry | 200 MB | 10 GB (200 MB × 50) | 5-hop mixnet mode | +| WireGuard Entry | 500 MB | 25 GB (500 MB × 50) | 2-hop WireGuard mode (entry side) | +| WireGuard Exit | 500 MB | 25 GB (500 MB × 50) | 2-hop WireGuard mode (exit side) | + + If you only use two-hop (WireGuard) mode, the Mixnet Entry ticketbooks will go unused. There is currently no way to obtain ticketbooks for a single type. + +This command: + +- Deposits `$NYM` into the ticketbook smart contract (plus a small fee buffer per deposit) +- Requests credential issuance from the decentralised Nym API validators +- Stores the resulting zk-nym ticketbooks locally on the device + +### Connect + +Connect using the locally stored ticketbooks: + +```sh +nym-vpnc connect-v2 +``` + +The CLI uses the ticketbooks to authenticate with entry nodes (gateways) and connect to the Nym network. You will see the connection happening in the `nym-vpnd` logs. + ## Tunnel Configuration Print current tunnel configuration: @@ -5181,13 +5121,13 @@ Print current tunnel configuration: nym-vpnc tunnel get ``` -Enable two-hop mode (WireGuard) — traffic jumps directly from entry gateway to exit gateway: +Enable two-hop mode (WireGuard): traffic jumps directly from entry gateway to exit gateway: ```sh nym-vpnc tunnel set --two-hop on ``` -Enable Mixnet (5-hop) — disable two-hop to route traffic through the full mixnet for maximum privacy: +Enable Mixnet (5-hop): disable two-hop to route traffic through the full mixnet for maximum privacy: ```sh nym-vpnc tunnel set --two-hop off @@ -5298,7 +5238,7 @@ Disable ad-block: nym-vpnc ad-block set disabled ``` - You can test ad-blocking with [adblock.turtlecute.org](https://adblock.turtlecute.org/). Some browsers cache DNS internally, so toggling ad-block on/off at runtime may not have an immediate effect — a browser restart may be needed. Use `nslookup` or `dig` to verify that domains are being blocked. + You can test ad-blocking with [adblock.turtlecute.org](https://adblock.turtlecute.org/). Some browsers cache DNS internally, so toggling ad-block on/off at runtime may not have an immediate effect; a browser restart may be needed. Use `nslookup` or `dig` to verify that domains are being blocked. ## DNS @@ -5391,7 +5331,7 @@ There are two options for interacting with the blockchain to send tokens or inte * `nyxd` binary ## Nym-CLI tool (recommended in most cases) -The `nym-cli` tool is a binary offering a simple interface for interacting with deployed smart contract (for instance, bonding and unbonding a mix node from the CLI), as well as creating and managing accounts and keypairs, sending tokens, and querying the blockchain. +The `nym-cli` tool is a binary offering a simple interface for interacting with deployed smart contract (for instance, bonding and unbonding a Mix Node from the CLI), as well as creating and managing accounts and keypairs, sending tokens, and querying the blockchain. Instructions on how to do so can be found on the [`nym-cli` docs page](./tools/nym-cli) @@ -5471,7 +5411,7 @@ nyxd tx bank send ledger_account $DESTINATION_ACCOOUNT 1000000unym --ledger --no > When a command is run, the transaction will appear on the Ledger device and will require physical confirmation from the device before being signed. ## Nym-specific transactions -Nym-specific commands and queries, like bonding a mix node or delegating unvested tokens, are available in the `wasm` module, and follow the following pattern: +Nym-specific commands and queries, like bonding a Mix Node or delegating unvested tokens, are available in the `wasm` module, and follow the following pattern: ``` # Executing commands @@ -5486,8 +5426,8 @@ You can find the value of `$CONTRACT_ADDRESS` in the [`network defaults`](https: The value of `$JSON_MSG` will be a blog of `json` formatted as defined for each command and query. You can find these definitions for the mixnet smart contract [here](https://github.com/nymtech/nym/blob/master/common/cosmwasm-smart-contracts/mixnet-contract/src/msg.rs) and for the vesting contract [here](https://github.com/nymtech/nym/blob/master/common/cosmwasm-smart-contracts/vesting-contract/src/messages.rs) under `ExecuteMsg` and `QueryMsg`. ### Example command execution: -#### Delegate to a mix node -You can delegate to a mix node from the CLI using `nyxd` and signing the transaction with your ledger by filling in the values of this example: +#### Delegate to a Mix Node +You can delegate to a Mix Node from the CLI using `nyxd` and signing the transaction with your ledger by filling in the values of this example: ``` CONTRACT_ADDRESS=mixnet_contract_address @@ -5528,13 +5468,13 @@ url: https://nym.com/docs/developers/tools # Tools -Standalone binaries for development and testing. These don't require an SDK — download or compile them and use them directly. +Standalone binaries for development and testing that don't require an SDK. Download or compile them and use them directly. | Tool | Use case | |---|---| -| [nym-cli](./tools/nym-cli) | Command-line interface for interacting with the Nyx blockchain — querying state, submitting transactions, managing keys. An easier-to-use wrapper around `nyxd`. | +| [nym-cli](./tools/nym-cli) | Command-line interface for interacting with the Nyx blockchain: querying state, submitting transactions, managing keys. An easier-to-use wrapper around `nyxd`. | | [Diagnostic Tool](./tools/diagnostic-tool) | Network diagnostic utility for troubleshooting connectivity issues. | -| [Standalone TcpProxy](./tools/standalone-tcpproxy) | Pre-built binaries of the TcpProxy client and server for proxying TCP traffic through the Mixnet. Note: the TcpProxy module is unmaintained — use the [Stream module](./rust/stream) for new projects. | +| [Standalone TcpProxy](./tools/standalone-tcpproxy) | Pre-built binaries of the TcpProxy client and server for proxying TCP traffic through the Mixnet. Note: the TcpProxy module is unmaintained; use the [Stream module](./rust/stream) for new projects. | --- title: Nym CLI: Mixnet & Blockchain Commands @@ -5562,21 +5502,21 @@ url: https://nym.com/docs/developers/tools/nym-cli/usage The `nym-cli` binary can be built by running `cargo build --release` in the `nym/tools/nym-cli` directory. ## Usage -See the [commands](commands.mdx) page for an overview of all command options. +See the [commands](/developers/tools/nym-cli/commands) page for an overview of all command options. ## Staking on someone's behalf (for custodians) There is a limitation the staking address can only perform the following actions (and are visible via the Nym Wallet: -- Bond on the gateway's or mix node's behalf. -- Delegate or Un-delegate (to a mix node in order to begin receiving rewards) +- Bond on the gateway's or Mix Node's behalf. +- Delegate or Un-delegate (to a Mix Node in order to begin receiving rewards) - Claiming the rewards on the account ```admonish note title="" The staking address has no ability to withdraw any coins from the parent's account. ``` -The staking address must maintain the same level of security as the parent mnemonic; while the parent mnemonic's delegations and bonding events will be visible to the parent owner, the staking address will be the only account capable of undoing the bonding and delegating from the mix nodes or gateway. +The staking address must maintain the same level of security as the parent mnemonic; while the parent mnemonic's delegations and bonding events will be visible to the parent owner, the staking address will be the only account capable of undoing the bonding and delegating from the Mix Nodes or gateway. Query for staking on behalf of someone else ``` @@ -5625,7 +5565,7 @@ Options: Print help ``` -## `account` +## `account` ```sh Query and manage Nyx blockchain accounts @@ -5667,7 +5607,7 @@ Options: --mnemonic Provide the mnemonic for your account. You can also provide this is an env var called MNEMONIC. --word-count - + -c, --config-env-file Overrides configuration as a file of environment variables. Note: individual env vars take precedence over this file. --nyxd-url @@ -5760,7 +5700,7 @@ Options: -c, --config-env-file Overrides configuration as a file of environment variables. Note: individual env vars take precedence over this file. --memo - + --nyxd-url Overrides the nyxd URL provided either as an environment variable NYXD_VALIDATOR or in a config file --nym-api-url @@ -5781,7 +5721,7 @@ Usage: nym-cli account send-multiple [OPTIONS] --input Options: --memo - + --mnemonic Provide the mnemonic for your account. You can also provide this is an env var called MNEMONIC. -c, --config-env-file @@ -5802,7 +5742,7 @@ Options: Print help ``` -## `signature` +## `signature` ```sh Sign and verify messages @@ -5885,7 +5825,7 @@ Options: Print help ``` -## `ecash` +## `ecash` ```sh Ecash related stuff @@ -5893,13 +5833,13 @@ Usage: nym-cli ecash [OPTIONS] nym-cli ecash Commands: - issue-ticket-book - recover-ticket-book - import-ticket-book - generate-ticket - import-coin-index-signatures - import-expiration-date-signatures - import-master-verification-key + issue-ticket-book + recover-ticket-book + import-ticket-book + generate-ticket + import-coin-index-signatures + import-expiration-date-signatures + import-master-verification-key help Print this message or the help of the given subcommand(s) Options: @@ -6010,7 +5950,7 @@ Options: Print help ``` -## `coconut` +## `coconut` ## `coconut generate-freepass` @@ -6020,7 +5960,7 @@ Options: ## `coconut import-credential` -## `block` +## `block` ```sh Query chain blocks @@ -6125,7 +6065,7 @@ Options: Print help ``` -## `cosmwasm` +## `cosmwasm` ```sh Manage and execute WASM smart contracts @@ -6168,11 +6108,11 @@ Options: --mnemonic Provide the mnemonic for your account. You can also provide this is an env var called MNEMONIC. --wasm-path - + -c, --config-env-file Overrides configuration as a file of environment variables. Note: individual env vars take precedence over this file. --memo - + --nyxd-url Overrides the nyxd URL provided either as an environment variable NYXD_VALIDATOR or in a config file --nym-api-url @@ -6195,19 +6135,19 @@ Arguments: Options: --memo - + --mnemonic Provide the mnemonic for your account. You can also provide this is an env var called MNEMONIC. -c, --config-env-file Overrides configuration as a file of environment variables. Note: individual env vars take precedence over this file. --label