Merge pull request #6869 from nymtech/release/2026.11-xynomizithra

merge release/2026.11 xynomizithra to master
This commit is contained in:
benedetta davico
2026-06-09 16:25:10 +02:00
committed by GitHub
827 changed files with 79115 additions and 43478 deletions
+2
View File
@@ -0,0 +1,2 @@
[target.wasm32-unknown-unknown]
rustflags = ["--cfg=getrandom_backend=\"wasm_js\""]
+2 -2
View File
@@ -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
+6 -3
View File
@@ -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
+7 -4
View File
@@ -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
@@ -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: |
@@ -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/"
-1
View File
@@ -23,7 +23,6 @@ on:
- 'sdk/ffi/**'
- 'sdk/rust/**'
- 'service-providers/**'
- 'nym-browser-extension/storage/**'
- 'tools/**'
- 'wasm/**'
- 'Cargo.toml'
+19
View File
@@ -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
@@ -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: |
+5 -1
View File
@@ -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 }}
+30 -3
View File
@@ -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
+6 -3
View File
@@ -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
+11 -9
View File
@@ -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
+15 -10
View File
@@ -1,6 +1,7 @@
name: ci-nym-wallet-frontend
on:
workflow_dispatch:
pull_request:
paths:
- 'nym-wallet/**'
@@ -12,30 +13,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
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 20
node-version: 24
- uses: actions-rs/toolchain@v1
with:
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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: |
+2 -2
View File
@@ -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
+13 -10
View File
@@ -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
@@ -27,11 +27,16 @@ jobs:
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: |
+11 -14
View File
@@ -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: |
@@ -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
+8 -6
View File
@@ -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:
+1 -1
View File
@@ -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 }}
+1 -1
View File
@@ -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 }}
+1 -1
View File
@@ -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 }}
@@ -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 }}
+1 -1
View File
@@ -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 }}
+1 -1
View File
@@ -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 }}
+1 -1
View File
@@ -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 }}
@@ -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 }}
@@ -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 }}
@@ -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 }}
+2 -2
View File
@@ -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
+5
View File
@@ -78,3 +78,8 @@ CLAUDE.md
/notes
/target-otel
test-tutorials/
# pnpm
.pnpm-store/
+9
View File
@@ -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
+88
View File
@@ -4,6 +4,94 @@ Post 1.0.0 release, the changelog format is based on [Keep a Changelog](https://
## [Unreleased]
## [2026.11-xynomizithra] (2026-06-08)
- bugfix: allow re-inviting expired members ([#6863])
- feat: disable Nagle's algorithm for LP between nym-nodes ([#6857])
- Keep peer in wg table when updating psk ([#6856])
- chore: minor nym-node improvements ([#6850])
- chore: LP registration adjustments ([#6845])
- crates release: bump version to 1.21.1 ([#6844])
- fix gateways being penalised for no stress testing ([#6843])
- fix score inflation for throttled nodes ([#6842])
- Bugfix/cherry pick/waterloo stres testing floats ([#6841])
- bugfix: NMv3 race condition ([#6837])
- feat: implement UpdateFamily for the node families contract ([#6834])
- Bugfix/cherry pick/waterloo ns api ([#6833])
- experiment: attempt to retroactively generate specs for node families and ecash contracts ([#6813])
- moving lp packets in lp-data crate ([#6810])
- upgrade axum to 0.8.9 (and side deps) ([#6808])
- chore: expose admin method for migrating vesting delegations/mixnodes ([#6795])
- [chore] fix clippy 1.95 lints for future version update ([#6794])
- Handle Rate Limit Challenge Response ([#6786])
- NYM-583: Avoid corrupted database on Windows. ([#6785])
- Max/smolmix wasm ([#6784])
- Chore/bugfixes ([#6783])
- Switch from yarn to pnpm ([#6779])
- feat: Node Families: expose stake information inside DVpnGateway ([#6778])
- feat: Node Families: expose family information for NS API consumers ([#6777])
- feat: Node Families: cache and expose family data within nym API ([#6774])
- Re-order default API urls for network details ([#6767])
- add ci for NM agent binary ([#6764])
- feat/refactor: introduce shared contract caches within Nym API ([#6760])
- chore: removed dead code for redundant mixnet-vesting integration tests ([#6759])
- feat: Node Families: remove nodes upon unbonding ([#6752])
- feat: Node Families: contract transactions ([#6750])
- feat: Node Families: contract queries ([#6731])
- feat: Node Families: initial contract storage ([#6717])
- start node families topic branch ([#6715])
- Bump rand from 0.8.5 to 0.8.6 in /contracts ([#6702])
- Testing port checks in NS Agents ([#6694])
- build(deps): bump microsoft/setup-msbuild from 2 to 3 ([#6602])
- build(deps): bump tar from 0.4.44 to 0.4.45 ([#6595])
- build(deps): bump quinn-proto from 0.11.12 to 0.11.14 ([#6549])
- build(deps): bump docker/login-action from 3 to 4 ([#6518])
- build(deps): bump actions/download-artifact from 7 to 8 ([#6497])
- build(deps): bump actions/upload-artifact from 6 to 7 ([#6496])
[#6863]: https://github.com/nymtech/nym/pull/6863
[#6857]: https://github.com/nymtech/nym/pull/6857
[#6856]: https://github.com/nymtech/nym/pull/6856
[#6850]: https://github.com/nymtech/nym/pull/6850
[#6845]: https://github.com/nymtech/nym/pull/6845
[#6844]: https://github.com/nymtech/nym/pull/6844
[#6843]: https://github.com/nymtech/nym/pull/6843
[#6842]: https://github.com/nymtech/nym/pull/6842
[#6841]: https://github.com/nymtech/nym/pull/6841
[#6837]: https://github.com/nymtech/nym/pull/6837
[#6834]: https://github.com/nymtech/nym/pull/6834
[#6833]: https://github.com/nymtech/nym/pull/6833
[#6813]: https://github.com/nymtech/nym/pull/6813
[#6810]: https://github.com/nymtech/nym/pull/6810
[#6808]: https://github.com/nymtech/nym/pull/6808
[#6795]: https://github.com/nymtech/nym/pull/6795
[#6794]: https://github.com/nymtech/nym/pull/6794
[#6786]: https://github.com/nymtech/nym/pull/6786
[#6785]: https://github.com/nymtech/nym/pull/6785
[#6784]: https://github.com/nymtech/nym/pull/6784
[#6783]: https://github.com/nymtech/nym/pull/6783
[#6779]: https://github.com/nymtech/nym/pull/6779
[#6778]: https://github.com/nymtech/nym/pull/6778
[#6777]: https://github.com/nymtech/nym/pull/6777
[#6774]: https://github.com/nymtech/nym/pull/6774
[#6767]: https://github.com/nymtech/nym/pull/6767
[#6764]: https://github.com/nymtech/nym/pull/6764
[#6760]: https://github.com/nymtech/nym/pull/6760
[#6759]: https://github.com/nymtech/nym/pull/6759
[#6752]: https://github.com/nymtech/nym/pull/6752
[#6750]: https://github.com/nymtech/nym/pull/6750
[#6731]: https://github.com/nymtech/nym/pull/6731
[#6717]: https://github.com/nymtech/nym/pull/6717
[#6715]: https://github.com/nymtech/nym/pull/6715
[#6702]: https://github.com/nymtech/nym/pull/6702
[#6694]: https://github.com/nymtech/nym/pull/6694
[#6602]: https://github.com/nymtech/nym/pull/6602
[#6595]: https://github.com/nymtech/nym/pull/6595
[#6549]: https://github.com/nymtech/nym/pull/6549
[#6518]: https://github.com/nymtech/nym/pull/6518
[#6497]: https://github.com/nymtech/nym/pull/6497
[#6496]: https://github.com/nymtech/nym/pull/6496
## [2026.10-waterloo] (2026-05-27)
- Re-order default API urls for network details - Waterloo release ([#6799])
Generated
+2286 -2153
View File
File diff suppressed because it is too large Load Diff
+151 -148
View File
@@ -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,31 @@ 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/smolmix",
"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 +212,7 @@ edition = "2024"
license = "Apache-2.0"
rust-version = "1.87.0"
readme = "README.md"
version = "1.20.4"
version = "1.21.1"
[workspace.dependencies]
addr = "0.15.6"
@@ -225,16 +226,17 @@ 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"
async-tungstenite = { version = "0.24", default-features = false }
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 +255,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"
@@ -278,24 +280,27 @@ eyre = "0.6.9"
fastrand = "2.1.1"
flate2 = "1.1.1"
futures = "0.3.31"
futures-rustls = { version = "0.26", default-features = false }
futures-util = "0.3"
generic-array = "0.14.7"
getrandom = "0.2.10"
getrandom03 = { package = "getrandom", version = "=0.3.3" }
getrandom04 = { package = "getrandom", version = "0.4" }
glob = "0.3"
handlebars = "3.5.5"
hex = "0.4.3"
hickory-proto = "0.26.1"
hickory-proto = { version = "0.26.1", default-features = false }
hickory-resolver = "0.26.1"
hkdf = "0.12.3"
hmac = "0.12.1"
http = "1"
http-body-util = "0.1"
httparse = "1.10"
httpcodec = "0.2.3"
human-repr = "1.1.0"
humantime = "2.2.0"
humantime-serde = "1.1.1"
hyper = "1.6.0"
hyper = { version = "1.6.0", default-features = false }
hyper-util = "0.1"
indicatif = "0.18.0"
inquire = "0.6.2"
@@ -330,7 +335,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" }
@@ -341,12 +346,14 @@ regex = "1.10.6"
reqwest = { version = "0.13.1", default-features = false }
rs_merkle = "1.5.0"
rustls = { version = "0.23.37", default-features = false }
rustls-pki-types = "1"
rustls-rustcrypto = "0.0.2-alpha"
schemars = "0.8.22"
semver = "1.0.26"
serde = "1.0.219"
serde_bytes = "0.11.17"
serde_derive = "1.0"
serde_json = "1.0.140"
serde_json = { version = "1.0.140", features = ["float_roundtrip"] }
serde_json_path = "0.7.2"
serde_repr = "0.1"
serde_with = "3.9.0"
@@ -354,7 +361,7 @@ 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" }
simple-dns = "0.7"
smoltcp = "0.12"
snow = "0.9.6"
sphinx-packet = "=0.6.0"
@@ -365,7 +372,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"
@@ -378,7 +385,7 @@ tokio-test = "0.4.4"
tokio-tun = "0.11.5"
tokio-rustls = "0.26"
tokio-smoltcp = "0.5"
tokio-tungstenite = { version = "0.20.1" }
tokio-tungstenite = "0.20.1"
tokio-util = "0.7.15"
toml = "0.8.22"
tower = "0.5.2"
@@ -396,11 +403,10 @@ 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 }
vergen-gitcl = { version = "1.0.8", default-features = false }
walkdir = "2"
x25519-dalek = "2.0.0"
zeroize = "1.7.0"
@@ -423,111 +429,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-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-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-api-requests = { version = "1.21.1", path = "nym-api/nym-api-requests" }
nym-authenticator-requests = { version = "1.21.1", path = "common/authenticator-requests" }
nym-async-file-watcher = { version = "1.21.1", path = "common/async-file-watcher" }
nym-authenticator-client = { version = "1.21.1", path = "nym-authenticator-client" }
nym-bandwidth-controller = { version = "1.21.1", path = "common/bandwidth-controller" }
nym-bin-common = { version = "1.21.1", path = "common/bin-common" }
nym-cache = { version = "1.21.1", path = "common/nym-cache" }
nym-client-core = { version = "1.21.1", path = "common/client-core", default-features = false }
nym-client-core-config-types = { version = "1.21.1", path = "common/client-core/config-types" }
nym-client-core-gateways-storage = { version = "1.21.1", path = "common/client-core/gateways-storage" }
nym-client-core-surb-storage = { version = "1.21.1", path = "common/client-core/surb-storage" }
nym-client-websocket-requests = { version = "1.21.1", path = "clients/native/websocket-requests" }
nym-common = { version = "1.21.1", path = "common/nym-common" }
nym-compact-ecash = { version = "1.21.1", path = "common/nym_offline_compact_ecash" }
nym-config = { version = "1.21.1", path = "common/config" }
nym-contracts-common = { version = "1.21.1", path = "common/cosmwasm-smart-contracts/contracts-common" }
nym-coconut-dkg-common = { version = "1.21.1", path = "common/cosmwasm-smart-contracts/coconut-dkg" }
nym-credential-storage = { version = "1.21.1", path = "common/credential-storage" }
nym-credential-utils = { version = "1.21.1", path = "common/credential-utils" }
nym-credential-proxy-lib = { version = "1.21.1", path = "common/credential-proxy" }
nym-credentials = { version = "1.21.1", path = "common/credentials", default-features = false }
nym-credentials-interface = { version = "1.21.1", path = "common/credentials-interface" }
nym-credential-proxy-requests = { version = "1.21.1", path = "nym-credential-proxy/nym-credential-proxy-requests", default-features = false }
nym-credential-verification = { version = "1.21.1", path = "common/credential-verification" }
nym-crypto = { version = "1.21.1", path = "common/crypto", default-features = false }
nym-dkg = { version = "1.21.1", path = "common/dkg" }
nym-ecash-contract-common = { version = "1.21.1", path = "common/cosmwasm-smart-contracts/ecash-contract" }
nym-ecash-signer-check = { version = "1.21.1", path = "common/ecash-signer-check" }
nym-ecash-signer-check-types = { version = "1.21.1", path = "common/ecash-signer-check-types" }
nym-ecash-time = { version = "1.21.1", path = "common/ecash-time" }
nym-exit-policy = { version = "1.21.1", path = "common/exit-policy" }
nym-ffi-shared = { version = "1.21.1", path = "sdk/ffi/shared" }
nym-gateway-client = { version = "1.21.1", path = "common/client-libs/gateway-client", default-features = false }
nym-gateway-probe = { version = "1.21.1", path = "nym-gateway-probe" }
nym-gateway-requests = { version = "1.21.1", path = "common/gateway-requests" }
nym-gateway-storage = { version = "1.21.1", path = "common/gateway-storage" }
nym-gateway-stats-storage = { version = "1.21.1", path = "common/gateway-stats-storage" }
nym-group-contract-common = { version = "1.21.1", path = "common/cosmwasm-smart-contracts/group-contract" }
nym-http-api-client = { version = "1.21.1", path = "common/http-api-client" }
nym-http-api-client-macro = { version = "1.21.1", path = "common/http-api-client-macro" }
nym-http-api-common = { version = "1.21.1", path = "common/http-api-common", default-features = false }
nym-id = { version = "1.21.1", path = "common/nym-id" }
nym-ip-packet-client = { version = "1.21.1", path = "nym-ip-packet-client" }
nym-ip-packet-requests = { version = "1.21.1", path = "common/ip-packet-requests" }
nym-lp = { version = "1.21.1", path = "common/nym-lp" }
nym-lp-data = { version = "1.21.1", path = "common/nym-lp-data" }
nym-kkt = { version = "1.21.1", path = "common/nym-kkt" }
nym-kkt-ciphersuite = { version = "1.21.1", path = "common/nym-kkt-ciphersuite" }
nym-kkt-context = { version = "1.21.1", path = "common/nym-kkt-context" }
nym-metrics = { version = "1.21.1", path = "common/nym-metrics" }
nym-mixnet-client = { version = "1.21.1", path = "common/client-libs/mixnet-client" }
nym-mixnet-contract-common = { version = "1.21.1", path = "common/cosmwasm-smart-contracts/mixnet-contract" }
nym-multisig-contract-common = { version = "1.21.1", path = "common/cosmwasm-smart-contracts/multisig-contract" }
nym-network-defaults = { version = "1.21.1", path = "common/network-defaults" }
nym-node-tester-utils = { version = "1.21.1", path = "common/node-tester-utils" }
nym-noise = { version = "1.21.1", path = "common/nymnoise" }
nym-noise-keys = { version = "1.21.1", path = "common/nymnoise/keys" }
nym-nonexhaustive-delayqueue = { version = "1.21.1", path = "common/nonexhaustive-delayqueue" }
nym-node-requests = { version = "1.21.1", path = "nym-node/nym-node-requests", default-features = false }
nym-node-metrics = { version = "1.21.1", path = "nym-node/nym-node-metrics" }
nym-node-families-contract-common = { version = "1.21.1", path = "common/cosmwasm-smart-contracts/node-families-contract" }
nym-ordered-buffer = { version = "1.21.1", path = "common/socks5/ordered-buffer" }
nym-outfox = { version = "1.21.1", path = "nym-outfox" }
nym-registration-common = { version = "1.21.1", path = "common/registration" }
nym-pemstore = { version = "1.21.1", path = "common/pemstore" }
nym-performance-contract-common = { version = "1.21.1", path = "common/cosmwasm-smart-contracts/nym-performance-contract" }
nym-sdk = { version = "1.21.1", path = "sdk/rust/nym-sdk" }
nym-serde-helpers = { version = "1.21.1", path = "common/serde-helpers" }
nym-service-providers-common = { version = "1.21.1", path = "service-providers/common" }
nym-service-provider-requests-common = { version = "1.21.1", path = "common/service-provider-requests-common" }
nym-socks5-client-core = { version = "1.21.1", path = "common/socks5-client-core" }
nym-socks5-proxy-helpers = { version = "1.21.1", path = "common/socks5/proxy-helpers" }
nym-socks5-requests = { version = "1.21.1", path = "common/socks5/requests" }
nym-sphinx = { version = "1.21.1", path = "common/nymsphinx" }
nym-sphinx-acknowledgements = { version = "1.21.1", path = "common/nymsphinx/acknowledgements" }
nym-sphinx-addressing = { version = "1.21.1", path = "common/nymsphinx/addressing" }
nym-sphinx-anonymous-replies = { version = "1.21.1", path = "common/nymsphinx/anonymous-replies" }
nym-sphinx-chunking = { version = "1.21.1", path = "common/nymsphinx/chunking" }
nym-sphinx-cover = { version = "1.21.1", path = "common/nymsphinx/cover" }
nym-sphinx-forwarding = { version = "1.21.1", path = "common/nymsphinx/forwarding" }
nym-sphinx-framing = { version = "1.21.1", path = "common/nymsphinx/framing" }
nym-sphinx-params = { version = "1.21.1", path = "common/nymsphinx/params" }
nym-sphinx-routing = { version = "1.21.1", path = "common/nymsphinx/routing" }
nym-sphinx-types = { version = "1.21.1", path = "common/nymsphinx/types" }
nym-statistics-common = { version = "1.21.1", path = "common/statistics" }
nym-store-cipher = { version = "1.21.1", path = "common/store-cipher" }
nym-task = { version = "1.21.1", path = "common/task" }
nym-tun = { version = "1.21.1", path = "common/tun" }
nym-test-utils = { version = "1.21.1", path = "common/test-utils" }
nym-ticketbooks-merkle = { version = "1.21.1", path = "common/ticketbooks-merkle" }
nym-topology = { version = "1.21.1", path = "common/topology" }
nym-types = { version = "1.21.1", path = "common/types" }
nym-upgrade-mode-check = { version = "1.21.1", path = "common/upgrade-mode-check" }
nym-validator-client = { version = "1.21.1", path = "common/client-libs/validator-client", default-features = false }
nym-vesting-contract-common = { version = "1.21.1", path = "common/cosmwasm-smart-contracts/vesting-contract" }
nym-network-monitors-contract-common = { version = "1.21.1", path = "common/cosmwasm-smart-contracts/network-monitors-contract" }
nym-verloc = { version = "1.21.1", path = "common/verloc" }
nym-wireguard = { version = "1.21.1", path = "common/wireguard" }
nym-wireguard-types = { version = "1.21.1", path = "common/wireguard-types" }
nym-wireguard-private-metadata-shared = { version = "1.21.1", path = "common/wireguard-private-metadata/shared" }
nym-wireguard-private-metadata-client = { version = "1.21.1", path = "common/wireguard-private-metadata/client" }
nym-wireguard-private-metadata-server = { version = "1.21.1", path = "common/wireguard-private-metadata/server" }
nym-sqlx-pool-guard = { version = "1.21.1", path = "nym-sqlx-pool-guard" }
nym-wasm-client-core = { version = "1.21.1", path = "common/wasm/client-core" }
nym-wasm-storage = { version = "1.21.1", path = "common/wasm/storage" }
nym-wasm-utils = { version = "1.21.1", path = "common/wasm/utils", default-features = false }
nyxd-scraper-shared = { version = "1.21.1", path = "common/nyxd-scraper-shared" }
smolmix = { version = "1.21.1", path = "smolmix/core" }
# coconut/DKG related
# unfortunately until https://github.com/zkcrypto/nym-bls12_381-fork/issues/10 is resolved, we have to rely on the fork
@@ -594,18 +604,11 @@ opt-level = 3
# lto = true
opt-level = 'z'
[profile.release.package.nym-node-tester-wasm]
[profile.release.package.mix-fetch-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]
[profile.release.package.smolmix-wasm]
# lto = true
opt-level = 'z'
+6 -8
View File
@@ -104,23 +104,20 @@ $(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/smolmix
# $(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
@@ -128,6 +125,7 @@ sdk-wasm-test:
sdk-wasm-lint:
RUSTFLAGS='--cfg getrandom_backend="wasm_js"' cargo clippy $(addprefix -p , $(WASM_CRATES)) --target wasm32-unknown-unknown -- -Dwarnings
$(MAKE) -C wasm/mix-fetch check-fmt
$(MAKE) -C wasm/smolmix check-fmt
# Add to top-level targets
build: sdk-wasm-build
@@ -223,7 +221,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:
+2 -2
View File
@@ -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
```
@@ -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
@@ -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') }}"
+1 -1
View File
@@ -1,7 +1,7 @@
[package]
name = "nym-client"
description = "Implementation of the Nym Client"
version = "1.1.77"
version = "1.1.78"
authors = ["Dave Hrycyszyn <futurechimp@users.noreply.github.com>", "Jędrzej Stuczyński <andrew@nymtech.net>"]
edition = "2021"
license.workspace = true
+2
View File
@@ -472,6 +472,7 @@ impl Handler {
fn prepare_reconstructed_binary(
reconstructed_messages: Vec<ReconstructedMessage>,
) -> Vec<Result<WsMessage, WsError>> {
#[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<ReconstructedMessage>,
) -> Vec<Result<WsMessage, WsError>> {
#[allow(clippy::result_large_err)] // TODO : remove this once tungstenite is updated
reconstructed_messages
.into_iter()
.map(ServerResponse::Received)
+1 -1
View File
@@ -1,7 +1,7 @@
[package]
name = "nym-socks5-client"
description = "A SOCKS5 localhost proxy that converts incoming messages to Sphinx and sends them to a Nym address"
version = "1.1.77"
version = "1.1.78"
authors = ["Dave Hrycyszyn <futurechimp@users.noreply.github.com>"]
edition = "2021"
license.workspace = true
+10
View File
@@ -25,6 +25,8 @@ pub trait BandwidthTicketProvider: Send + Sync {
) -> Result<PreparedCredential, BandwidthControllerError>;
async fn get_upgrade_mode_token(&self) -> Result<Option<String>, 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<T: BandwidthTicketProvider + ?Sized + Send> BandwidthTicketProvider for Box
async fn get_upgrade_mode_token(&self) -> Result<Option<String>, BandwidthControllerError> {
(**self).get_upgrade_mode_token().await
}
async fn close(&self) {
(**self).close().await;
}
}
@@ -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
@@ -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<P: AsRef<Path>>(
db_path: P,
surb_config: &config::ReplySurbs,
@@ -74,13 +80,58 @@ async fn archive_corrupted_database<P: AsRef<Path>>(db_path: P) -> io::Result<()
};
let renamed = db_path.with_extension(new_extension);
tokio::fs::rename(db_path, &renamed).await.inspect_err(|_| {
// 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<P: AsRef<Path>>(
@@ -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)]
@@ -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
}
}
+5 -3
View File
@@ -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")
+35 -5
View File
@@ -218,6 +218,11 @@ impl ManagedConnection {
"Managed to establish connection to {}", self.address
);
// disable Nagle: mix packets are latency-sensitive and flushed one at a time.
if let Err(err) = stream.set_nodelay(true) {
warn!(peer = %address, error = %err, "failed to set TCP_NODELAY on outbound mixnet connection");
}
// 3. perform noise handshake (if applicable)
let noise_start = tokio::time::Instant::now();
let noise_stream = match upgrade_noise_initiator(stream, &self.noise_config).await {
@@ -246,25 +251,42 @@ impl ManagedConnection {
noise_handshake_ms,
"Noise initiator handshake completed for {:?}", address
);
let conn = Framed::new(noise_stream, NymCodec);
let mut conn = Framed::new(noise_stream, NymCodec);
// let the write buffer accumulate several packets before flushing (see run_io_loop)
conn.set_backpressure_boundary(OUTBOUND_WRITE_BUFFER);
// 4. start handling the framed stream
run_io_loop(conn, self.message_receiver, address).await;
}
}
/// Upper bound on how many already-queued packets we drain into a single flush.
/// Bounds the per-batch allocation and how often we re-check the read side; the actual
/// write coalescing is governed by the Framed backpressure boundary below.
const OUTBOUND_FLUSH_BATCH: usize = 1024;
/// Write-buffer high-water mark for the egress `Framed`: packets are coalesced up to
/// roughly this many bytes before a flush, trading a larger write burst for far fewer
/// syscalls (and noise frames) under load. Kept under the ~64KiB noise frame ceiling so
/// a flush is usually a single frame.
const OUTBOUND_WRITE_BUFFER: usize = 32 * 1024;
// The connection is unidirectional (send-only); we read from it solely to
// notice peer FIN/RST while idle so we can evict the cache entry before the
// next outbound send finds it stale.
async fn run_io_loop<T>(
conn: Framed<T, NymCodec>,
mut receiver: ReceiverStream<FramedNymPacket>,
receiver: ReceiverStream<FramedNymPacket>,
address: SocketAddr,
) where
T: AsyncRead + AsyncWrite + Unpin,
{
let (mut sink, mut stream) = conn.split();
// drain all currently-queued packets into one flush rather than flushing per packet,
// which otherwise caps egress throughput and backs up the per-connection queue under load
let mut receiver = receiver.ready_chunks(OUTBOUND_FLUSH_BATCH);
loop {
tokio::select! {
msg = stream.next() => {
@@ -305,14 +327,22 @@ async fn run_io_loop<T>(
);
break;
}
Some(packet) => {
if let Err(err) = sink.send(packet).await {
Some(batch) => {
// feed the whole ready batch, then flush once
let res = async {
for packet in batch {
sink.feed(packet).await?;
}
sink.flush().await
}
.await;
if let Err(err) = res {
debug!(
event = "connection.forward_error",
peer = %address,
error = %err,
exit_reason = "forward_error",
"Failed to forward packet to {address}: {err}"
"failed to forward packet batch to {address}: {err}"
);
break;
}
@@ -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 }
@@ -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<u32>,
per_page: Option<u32>,
) -> Result<PaginatedResponse<NodeFamily>, 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],
&params,
)
.await
}
async fn get_all_node_families(&self) -> Result<Vec<NodeFamily>, 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<CachedNodesResponse<SkimmedNodeV1>, NymAPIError> {
@@ -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 {
@@ -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(),
};
}
}
@@ -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<AccountId>,
pub performance_contract_address: Option<AccountId>,
pub network_monitors_contract_address: Option<AccountId>,
pub node_families_contract_address: Option<AccountId>,
pub ecash_contract_address: Option<AccountId>,
pub group_contract_address: Option<AccountId>,
@@ -98,6 +104,10 @@ impl TryFrom<NymContracts> 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())
@@ -0,0 +1,441 @@
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
// 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<T>(
&self,
query: NodeFamiliesQueryMsg,
) -> Result<T, NyxdError>
where
for<'a> T: Deserialize<'a>;
async fn get_family_by_id(
&self,
family_id: NodeFamilyId,
) -> Result<NodeFamilyResponse, NyxdError> {
self.query_node_families_contract(NodeFamiliesQueryMsg::GetFamilyById { family_id })
.await
}
async fn get_family_by_owner(
&self,
owner: &AccountId,
) -> Result<NodeFamilyByOwnerResponse, NyxdError> {
self.query_node_families_contract(NodeFamiliesQueryMsg::GetFamilyByOwner {
owner: owner.to_string(),
})
.await
}
async fn get_family_by_name(
&self,
name: String,
) -> Result<NodeFamilyByNameResponse, NyxdError> {
self.query_node_families_contract(NodeFamiliesQueryMsg::GetFamilyByName { name })
.await
}
async fn get_families_paged(
&self,
start_after: Option<NodeFamilyId>,
limit: Option<u32>,
) -> Result<FamiliesPagedResponse, NyxdError> {
self.query_node_families_contract(NodeFamiliesQueryMsg::GetFamiliesPaged {
start_after,
limit,
})
.await
}
async fn get_family_membership(
&self,
node_id: NodeId,
) -> Result<NodeFamilyMembershipResponse, NyxdError> {
self.query_node_families_contract(NodeFamiliesQueryMsg::GetFamilyMembership { node_id })
.await
}
async fn get_family_members_paged(
&self,
family_id: NodeFamilyId,
start_after: Option<NodeId>,
limit: Option<u32>,
) -> Result<FamilyMembersPagedResponse, NyxdError> {
self.query_node_families_contract(NodeFamiliesQueryMsg::GetFamilyMembersPaged {
family_id,
start_after,
limit,
})
.await
}
async fn get_all_family_members_paged(
&self,
start_after: Option<NodeId>,
limit: Option<u32>,
) -> Result<AllFamilyMembersPagedResponse, NyxdError> {
self.query_node_families_contract(NodeFamiliesQueryMsg::GetAllFamilyMembersPaged {
start_after,
limit,
})
.await
}
async fn get_pending_invitation(
&self,
family_id: NodeFamilyId,
node_id: NodeId,
) -> Result<PendingFamilyInvitationResponse, NyxdError> {
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<NodeId>,
limit: Option<u32>,
) -> Result<PendingFamilyInvitationsPagedResponse, NyxdError> {
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<NodeFamilyId>,
limit: Option<u32>,
) -> Result<PendingInvitationsForNodePagedResponse, NyxdError> {
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<u32>,
) -> Result<PendingInvitationsPagedResponse, NyxdError> {
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<PastFamilyInvitationCursor>,
limit: Option<u32>,
) -> Result<PastFamilyInvitationsPagedResponse, NyxdError> {
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<PastFamilyInvitationForNodeCursor>,
limit: Option<u32>,
) -> Result<PastFamilyInvitationsForNodePagedResponse, NyxdError> {
self.query_node_families_contract(NodeFamiliesQueryMsg::GetPastInvitationsForNodePaged {
node_id,
start_after,
limit,
})
.await
}
async fn get_all_past_invitations_paged(
&self,
start_after: Option<GlobalPastFamilyInvitationCursor>,
limit: Option<u32>,
) -> Result<AllPastFamilyInvitationsPagedResponse, NyxdError> {
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<PastFamilyMemberCursor>,
limit: Option<u32>,
) -> Result<PastFamilyMembersPagedResponse, NyxdError> {
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<PastFamilyMemberForNodeCursor>,
limit: Option<u32>,
) -> Result<PastFamilyMembersForNodePagedResponse, NyxdError> {
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<Vec<NodeFamily>, NyxdError> {
collect_paged!(self, get_families_paged, families)
}
async fn get_all_family_members_for_family(
&self,
family_id: NodeFamilyId,
) -> Result<Vec<FamilyMemberRecord>, NyxdError> {
collect_paged!(self, get_family_members_paged, members, family_id)
}
async fn get_all_family_members(&self) -> Result<Vec<FamilyMemberRecord>, NyxdError> {
collect_paged!(self, get_all_family_members_paged, members)
}
async fn get_all_pending_invitations_for_family(
&self,
family_id: NodeFamilyId,
) -> Result<Vec<PendingFamilyInvitationDetails>, 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<Vec<PendingFamilyInvitationDetails>, NyxdError> {
collect_paged!(
self,
get_pending_invitations_for_node_paged,
invitations,
node_id
)
}
async fn get_all_pending_invitations(
&self,
) -> Result<Vec<PendingFamilyInvitationDetails>, NyxdError> {
collect_paged!(self, get_all_pending_invitations_paged, invitations)
}
async fn get_all_past_invitations_for_family(
&self,
family_id: NodeFamilyId,
) -> Result<Vec<PastFamilyInvitation>, 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<Vec<PastFamilyInvitation>, NyxdError> {
collect_paged!(
self,
get_past_invitations_for_node_paged,
invitations,
node_id
)
}
async fn get_all_past_invitations(&self) -> Result<Vec<PastFamilyInvitation>, NyxdError> {
collect_paged!(self, get_all_past_invitations_paged, invitations)
}
async fn get_all_past_members_for_family(
&self,
family_id: NodeFamilyId,
) -> Result<Vec<PastFamilyMember>, 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<Vec<PastFamilyMember>, NyxdError> {
collect_paged!(self, get_past_members_for_node_paged, members, node_id)
}
}
#[async_trait]
impl<T> PagedNodeFamiliesQueryClient for T where T: NodeFamiliesQueryClient {}
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
impl<C> NodeFamiliesQueryClient for C
where
C: CosmWasmClient + NymContractsProvider + Send + Sync,
{
async fn query_node_families_contract<T>(
&self,
query: NodeFamiliesQueryMsg,
) -> Result<T, NyxdError>
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<C: NodeFamiliesQueryClient + Send + Sync>(
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(),
};
}
}
@@ -0,0 +1,281 @@
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
// 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<Fee>,
msg: NodeFamiliesExecuteMsg,
memo: String,
funds: Vec<Coin>,
) -> Result<ExecuteResult, NyxdError>;
async fn update_node_families_config(
&self,
config: Config,
fee: Option<Fee>,
) -> Result<ExecuteResult, NyxdError> {
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<Fee>,
creation_fee: Vec<Coin>,
) -> Result<ExecuteResult, NyxdError> {
self.execute_node_families_contract(
fee,
NodeFamiliesExecuteMsg::CreateFamily { name, description },
"NodeFamiliesContract::CreateFamily".to_string(),
creation_fee,
)
.await
}
/// Update the name and/or description of the caller's family. Each
/// argument follows `None = keep` / `Some(_) = replace` semantics; a
/// call with both `None` is a server-side no-op.
async fn update_family(
&self,
updated_name: Option<String>,
updated_description: Option<String>,
fee: Option<Fee>,
) -> Result<ExecuteResult, NyxdError> {
self.execute_node_families_contract(
fee,
NodeFamiliesExecuteMsg::UpdateFamily {
updated_name,
updated_description,
},
"NodeFamiliesContract::UpdateFamily".to_string(),
vec![],
)
.await
}
async fn disband_family(&self, fee: Option<Fee>) -> Result<ExecuteResult, NyxdError> {
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<u64>,
fee: Option<Fee>,
) -> Result<ExecuteResult, NyxdError> {
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<Fee>,
) -> Result<ExecuteResult, NyxdError> {
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<Fee>,
) -> Result<ExecuteResult, NyxdError> {
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<Fee>,
) -> Result<ExecuteResult, NyxdError> {
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<Fee>,
) -> Result<ExecuteResult, NyxdError> {
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<Fee>,
) -> Result<ExecuteResult, NyxdError> {
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<Fee>,
) -> Result<ExecuteResult, NyxdError> {
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<C> NodeFamiliesSigningClient for C
where
C: SigningCosmWasmClient + NymContractsProvider + Sync,
NyxdError: From<<Self as OfflineSigner>::Error>,
{
async fn execute_node_families_contract(
&self,
fee: Option<Fee>,
msg: NodeFamiliesExecuteMsg,
memo: String,
funds: Vec<Coin>,
) -> Result<ExecuteResult, NyxdError> {
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<C: NodeFamiliesSigningClient + Send + Sync>(
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::UpdateFamily {
updated_name,
updated_description,
} => client
.update_family(updated_name, updated_description, None)
.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()
}
};
}
}
@@ -304,6 +304,10 @@ impl<C, S> NyxdClient<C, S> {
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<C, S> NymContractsProvider for NyxdClient<C, S> {
.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()
}
@@ -30,6 +30,9 @@ pub struct Args {
#[clap(long)]
pub vesting_contract_address: Option<AccountId>,
#[clap(long)]
pub node_families_contract_address: Option<AccountId>,
#[clap(long)]
pub rewarding_denom: Option<String>,
@@ -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),
@@ -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)
@@ -1,8 +1,14 @@
// Copyright 2022 - Nym Technologies SA <contact@nymtech.net>
// 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<BlacklistedAccount>,
@@ -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<Blacklisting>,
@@ -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.
@@ -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<Self> {
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<DepositData>,
}
/// 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>,
}
/// `(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<DepositData>,
@@ -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<Invariants>`. 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})"
)]
@@ -1,4 +1,7 @@
// Copyright 2022 - Nym Technologies SA <contact@nymtech.net>
// 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";
@@ -1,9 +1,21 @@
// Copyright 2021 - Nym Technologies SA <contact@nymtech.net>
// 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";
@@ -1,6 +1,12 @@
// Copyright 2024 - Nym Technologies SA <contact@nymtech.net>
// 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;
@@ -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<u32>,
start_after: Option<String>,
},
/// 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<Coin>))]
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<u32>,
start_after: Option<u32>,
},
/// 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 {},
}
@@ -1,5 +1,8 @@
// Copyright 2024 - Nym Technologies SA <contact@nymtech.net>
// 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";
@@ -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<WhitelistedAccount>,
@@ -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<T: DeserializeOwned>(
&self,
address: impl Into<String>,
msg: &QueryMsg,
) -> StdResult<T>;
fn query_mixnet_contract_storage(
&self,
address: impl Into<String>,
key: impl Into<Binary>,
) -> StdResult<Option<Vec<u8>>>;
fn query_mixnet_contract_storage_value<T: DeserializeOwned>(
&self,
address: impl Into<String>,
key: impl Into<Binary>,
) -> StdResult<Option<T>> {
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<String>) -> StdResult<Interval> {
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<String>,
) -> StdResult<EpochId> {
self.query_current_mixnet_interval(address)
.map(|interval| interval.current_epoch_absolute_id())
}
fn check_node_existence(&self, address: impl Into<String>, node_id: NodeId) -> StdResult<bool> {
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<String>,
node_id: NodeId,
) -> StdResult<Option<NymNodeBond>> {
// construct proper map key
let pk_namespace = "nn";
let path: Path<NymNodeBond> = Path::new(
Namespace::from_static_str(pk_namespace).as_slice(),
&node_id.key().iter().map(Key::as_ref).collect::<Vec<_>>(),
);
let storage_key = path.deref();
self.query_mixnet_contract_storage_value(address, storage_key)
}
fn query_nymnode_ownership(
&self,
address: impl Into<String>,
owner: &Addr,
) -> StdResult<Option<NymNodeBond>> {
let resp: NodeOwnershipResponse = self.query_mixnet_contract(
address,
&QueryMsg::GetOwnedNymNode {
address: owner.to_string(),
},
)?;
Ok(resp.details.map(|d| d.bond_information))
}
}
impl<C> MixnetContractQuerier for QuerierWrapper<'_, C>
where
C: CustomQuery,
{
fn query_mixnet_contract<T: DeserializeOwned>(
&self,
address: impl Into<String>,
msg: &QueryMsg,
) -> StdResult<T> {
self.query_wasm_smart(address, msg)
}
fn query_mixnet_contract_storage(
&self,
address: impl Into<String>,
key: impl Into<Binary>,
) -> StdResult<Option<Vec<u8>>> {
self.query_wasm_raw(address, key)
}
}
#[track_caller]
pub fn compare_decimals(a: Decimal, b: Decimal, epsilon: Option<Decimal>) {
@@ -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,
};
@@ -190,6 +190,10 @@ impl NodeRewarding {
truncate_reward(self.operator, denom)
}
pub fn delegations_with_reward(&self, denom: impl Into<String>) -> Coin {
truncate_reward(self.delegates, denom)
}
pub fn pending_delegator_reward(&self, delegation: &Delegation) -> StdResult<Coin> {
let delegator_reward = self.determine_delegation_reward(delegation)?;
Ok(truncate_reward(delegator_reward, &delegation.amount.denom))
@@ -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<VestedDelegationMigrationEntry>,
},
// 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<bool>,
pub vesting_contract_address: Option<String>,
pub node_families_contract_address: String,
}
@@ -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,
@@ -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
@@ -0,0 +1,114 @@
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
// 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<Addr>`: address of the mixnet contract used to validate node existence.
pub const MIXNET_CONTRACT_ADDRESS: &str = "mixnet-contract-address";
/// `Item<Config>`: 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<NodeFamilyId>`: 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";
pub const FAMILY_UPDATE_EVENT_NAME: &str = "family_update";
pub const FAMILY_UPDATE_EVENT_FAMILY_ID: &str = "family_id";
pub const FAMILY_UPDATE_EVENT_OWNER_ADDRESS: &str = "owner_address";
/// Attribute carrying the new family name. Only emitted when the
/// `UpdateFamily` message carried `updated_name = Some(_)`.
pub const FAMILY_UPDATE_EVENT_UPDATED_NAME: &str = "updated_name";
/// Attribute carrying the new family description. Only emitted when the
/// `UpdateFamily` message carried `updated_description = Some(_)`.
pub const FAMILY_UPDATE_EVENT_UPDATED_DESCRIPTION: &str = "updated_description";
}
@@ -0,0 +1,161 @@
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
// 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<Coin> },
/// 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),
}
@@ -0,0 +1,22 @@
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
// 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::*;
@@ -0,0 +1,221 @@
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
// 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 },
/// Update the name and/or description of the family owned by the message
/// sender. Each field is independently optional: `None` leaves the
/// existing value unchanged, `Some(_)` replaces it. Updated values are
/// validated against the same length / normalisation / global-uniqueness
/// rules as [`Self::CreateFamily`].
UpdateFamily {
updated_name: Option<String>,
updated_description: Option<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<u64>,
},
/// 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<NodeFamilyId>,
limit: Option<u32>,
},
/// 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<NodeId>,
limit: Option<u32>,
},
/// 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<NodeId>,
limit: Option<u32>,
},
/// 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<NodeId>,
limit: Option<u32>,
},
/// Page through every pending invitation issued for a given node.
#[cfg_attr(feature = "schema", returns(PendingInvitationsForNodePagedResponse))]
GetPendingInvitationsForNodePaged {
node_id: NodeId,
start_after: Option<NodeFamilyId>,
limit: Option<u32>,
},
/// Page through every pending invitation across all families.
#[cfg_attr(feature = "schema", returns(PendingInvitationsPagedResponse))]
GetAllPendingInvitationsPaged {
start_after: Option<(NodeFamilyId, NodeId)>,
limit: Option<u32>,
},
/// 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<PastFamilyInvitationCursor>,
limit: Option<u32>,
},
/// 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<PastFamilyInvitationForNodeCursor>,
limit: Option<u32>,
},
/// Page through every archived (terminal-state) invitation across all
/// families.
#[cfg_attr(feature = "schema", returns(AllPastFamilyInvitationsPagedResponse))]
GetAllPastInvitationsPaged {
start_after: Option<GlobalPastFamilyInvitationCursor>,
limit: Option<u32>,
},
/// 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<PastFamilyMemberCursor>,
limit: Option<u32>,
},
/// 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<PastFamilyMemberForNodeCursor>,
limit: Option<u32>,
},
}
/// Message passed to the contract's `migrate` entry point.
#[cw_serde]
pub struct MigrateMsg {
//
}
@@ -0,0 +1,413 @@
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
// 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, or revoked. Once the
/// chain advances past `expires_at` an invitation becomes inert but stays in storage
/// — there is no background process clearing expired invitations. A timed-out
/// invitation is cleared either when explicitly revoked/rejected, or when the family
/// issues a fresh invitation for the same node, which archives the stale one as
/// `Expired` and supersedes it.
#[cw_serde]
pub struct FamilyInvitation {
/// The family that issued the invitation.
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: an invitation that merely times out is **not** archived here on its own —
/// it is left inert in the pending set (see `FamilyInvitation::expires_at`). It only
/// reaches `Expired` if the family issues a fresh invitation for the same node, which
/// supersedes and archives the stale one.
#[cw_serde]
pub enum FamilyInvitationStatus {
/// Still awaiting a response. Recorded with a timestamp for completeness even
/// 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 },
/// The invitation had already expired and was superseded by a fresh invitation
/// for the same node from the same family, issued at the given timestamp. This is
/// the only path that archives a timed-out invitation.
Expired { at: u64 },
}
/// Historical record of an invitation that has reached a terminal state
/// (`Accepted`, `Rejected`, `Revoked`, or `Expired`). A timed-out invitation is
/// archived here only when a fresh invitation for the same node supersedes it
/// (status `Expired`); otherwise it stays in the pending map until explicitly cleared.
#[cw_serde]
pub struct PastFamilyInvitation {
/// The original invitation as it was issued.
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<NodeFamily>,
}
/// 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<NodeFamily>,
}
/// 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<NodeFamily>,
}
/// 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<NodeFamilyId>,
}
/// 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<PendingFamilyInvitationDetails>,
}
/// 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<FamilyMemberRecord>,
/// 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<NodeId>,
}
/// 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<FamilyMemberRecord>,
/// 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<NodeId>,
}
/// 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<PendingFamilyInvitationDetails>,
/// 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<NodeId>,
}
/// 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<PendingFamilyInvitationDetails>,
/// 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<NodeFamilyId>,
}
/// 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<PendingFamilyInvitationDetails>,
/// 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<PastFamilyInvitation>,
/// 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<PastFamilyInvitationCursor>,
}
/// 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<PastFamilyInvitation>,
/// 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<PastFamilyInvitationForNodeCursor>,
}
/// 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<PastFamilyInvitation>,
/// 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<GlobalPastFamilyInvitationCursor>,
}
/// 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<PastFamilyMember>,
/// 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<PastFamilyMemberCursor>,
}
/// 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<PastFamilyMember>,
/// 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<PastFamilyMemberForNodeCursor>,
}
/// Response to [`QueryMsg::GetFamiliesPaged`](crate::QueryMsg::GetFamiliesPaged).
#[cw_serde]
pub struct FamiliesPagedResponse {
/// The families on this page, in ascending [`NodeFamilyId`] order.
pub families: Vec<NodeFamily>,
/// 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<NodeFamilyId>,
}
+23 -3
View File
@@ -27,6 +27,9 @@ pub struct QuorumStateChecker {
cancellation_token: CancellationToken,
check_interval: Duration,
quorum_state: QuorumState,
/// indicates whether the last check has been a failure
last_failed: bool,
}
impl QuorumStateChecker {
@@ -42,6 +45,7 @@ impl QuorumStateChecker {
quorum_state: QuorumState {
available: Arc::new(Default::default()),
},
last_failed: false,
};
// first check MUST succeed, otherwise we shouldn't start
@@ -65,7 +69,7 @@ impl QuorumStateChecker {
let dkg_details = dkg_details_with_client(client_guard.deref()).await?;
drop(client_guard);
let res = check_known_dealers(dkg_details).await?;
let res = check_known_dealers(dkg_details, 4).await?;
info!("there are {} known DKG dealers", res.results.len());
let Some(signing_threshold) = res.threshold else {
@@ -107,7 +111,7 @@ impl QuorumStateChecker {
Ok(available)
}
pub async fn run_forever(self) {
pub async fn run_forever(mut self) {
info!("starting quorum state checker");
loop {
tokio::select! {
@@ -117,7 +121,23 @@ impl QuorumStateChecker {
}
_ = tokio::time::sleep(self.check_interval) => {
match self.check_quorum_state().await {
Ok(available) => self.quorum_state.available.store(available, Ordering::SeqCst),
Ok(available) => {
let previous = self.quorum_state.available.load(Ordering::SeqCst);
// only update the quorum state to a failed state if we've had two consecutive failures
if available {
if !previous {
info!("quorum recovered");
}
self.quorum_state.available.store(true, Ordering::SeqCst);
} else if self.last_failed {
if previous {
warn!("quorum became unavailable after 2 consecutive failed checks");
}
self.quorum_state.available.store(false, Ordering::SeqCst);
}
self.last_failed = !available;
},
Err(err) => error!("failed to check current quorum state: {err}"),
}
}
@@ -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<IssuedTicketBook> {
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<i64> = 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());
}
}
+8
View File
@@ -36,5 +36,13 @@ nym-ecash-contract-common = { workspace = true }
nym-network-defaults = { workspace = true }
nym-serde-helpers = { workspace = true, features = ["date"] }
[target."cfg(not(target_arch = \"wasm32\"))".dependencies.tokio]
workspace = true
features = ["time"]
[target."cfg(target_arch = \"wasm32\")".dependencies.wasmtimer]
workspace = true
features = ["tokio"]
[dev-dependencies]
rand = { workspace = true }
@@ -6,6 +6,7 @@ use crate::ecash::bandwidth::serialiser::VersionedSerialise;
use crate::ecash::bandwidth::CredentialSigningData;
use crate::ecash::utils::cred_exp_date;
use crate::error::Error;
use log::{debug, warn};
use nym_api_requests::ecash::BlindSignRequestBody;
use nym_credentials_interface::{
aggregate_wallets, generate_keypair_user_from_seed, issue_verify, withdrawal_request,
@@ -17,8 +18,15 @@ use nym_ecash_contract_common::deposit::DepositId;
use nym_ecash_time::{ecash_default_expiration_date, ecash_today, EcashTime};
use nym_validator_client::nym_api::{EpochId, NymApiClientExt};
use serde::{Deserialize, Serialize};
use std::time::Duration;
use time::Date;
#[cfg(not(target_arch = "wasm32"))]
use tokio::time::sleep;
#[cfg(target_arch = "wasm32")]
use wasmtimer::tokio::sleep;
pub use nym_validator_client::nyxd::{Coin, Hash};
#[derive(Serialize, Deserialize)]
@@ -192,6 +200,49 @@ impl IssuanceTicketBook {
Ok(unblinded_signature)
}
// ideally this would have been generic over credential type, but we really don't need secp256k1 keys for bandwidth vouchers
pub async fn obtain_partial_ticketbook_credential_with_retries(
&self,
client: &nym_http_api_client::Client,
signer_index: u64,
validator_vk: &VerificationKeyAuth,
signing_data: CredentialSigningData,
max_attempts: usize,
) -> Result<PartialWallet, Error> {
let Some(client_url) = client.base_urls().first() else {
return Err(Error::CredentialShareObtainFailed);
};
let mut last_err = None;
for attempt in 0..max_attempts {
if attempt > 0 {
sleep(Duration::from_millis(500 * attempt as u64)).await;
}
debug!(
"attempt {} / {max_attempts} to obtain partial ticketbook credential from {client_url}",
attempt + 1,
);
match self
.obtain_partial_ticketbook_credential(
client,
signer_index,
validator_vk,
signing_data.clone(),
)
.await
{
Ok(partial_wallet) => return Ok(partial_wallet),
Err(err) => {
warn!(
"attempt {} / {max_attempts} to obtain partial ticketbook credential from {client_url} failed: {err}",
attempt + 1,
);
last_err = Some(err);
}
}
}
Err(last_err.unwrap_or(Error::CredentialShareObtainFailed))
}
// ideally this would have been generic over credential type, but we really don't need secp256k1 keys for bandwidth vouchers
pub async fn obtain_partial_ticketbook_credential(
&self,
+9 -1
View File
@@ -137,6 +137,8 @@ pub async fn obtain_aggregate_wallet(
ecash_api_clients: &[EcashApiClient],
threshold: u64,
) -> Result<WalletSignatures, Error> {
const MAX_ATTEMPTS: usize = 2;
if ecash_api_clients.len() < threshold as usize {
return Err(Error::NoValidatorsAvailable);
}
@@ -154,11 +156,12 @@ pub async fn obtain_aggregate_wallet(
);
match voucher
.obtain_partial_ticketbook_credential(
.obtain_partial_ticketbook_credential_with_retries(
&ecash_api_client.api_client,
ecash_api_client.node_id,
&ecash_api_client.verification_key,
request.clone(),
MAX_ATTEMPTS,
)
.await
{
@@ -167,6 +170,11 @@ pub async fn obtain_aggregate_wallet(
warn!("failed to obtain partial credential from API {ecash_api_client}: {err}",);
}
};
// we got sufficient number of shares
if wallets.len() >= threshold as usize {
break;
}
}
if wallets.len() < threshold as usize {
return Err(Error::NotEnoughShares);
+3
View File
@@ -63,6 +63,9 @@ pub enum Error {
#[error("failed to create a secp256k1 signature")]
Secp256k1SignFailure,
#[error("failed to obtain a valid credential share")]
CredentialShareObtainFailed,
}
impl From<NymAPIError> for Error {
+3 -1
View File
@@ -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
@@ -195,9 +195,9 @@ impl ClientUnderTest {
pub(crate) async fn check_client(
dealer_details: DealerDetails,
dkg_epoch: u64,
contract_share: Option<&ContractVKShare>,
contract_share: Option<ContractVKShare>,
) -> TypedSignerResult {
let dealer_information = RawDealerInformation::new(&dealer_details, contract_share);
let dealer_information = RawDealerInformation::new(&dealer_details, contract_share.as_ref());
// 7. attempt to construct client instances out of them
let Ok(parsed_information) = dealer_information.parse() else {
+16 -12
View File
@@ -2,7 +2,8 @@
// SPDX-License-Identifier: Apache-2.0
use crate::client_check::check_client;
use futures::stream::{FuturesUnordered, StreamExt};
use futures::stream;
use futures::stream::StreamExt;
use nym_ecash_signer_check_types::status::{SignerResult, Status};
use nym_network_defaults::NymNetworkDetails;
use nym_validator_client::QueryHttpRpcNyxdClient;
@@ -65,7 +66,7 @@ where
C: DkgQueryClient + Sync,
{
let dkg_details = dkg_details_with_client(client).await?;
check_known_dealers(dkg_details).await
check_known_dealers(dkg_details, None).await
}
pub async fn dkg_details_with_client<C>(client: &C) -> Result<DkgDetails, SignerCheckError>
@@ -109,18 +110,21 @@ where
pub async fn check_known_dealers(
dkg_details: DkgDetails,
concurrency: impl Into<Option<usize>>,
) -> Result<SignersTestResult, SignerCheckError> {
// 6. for each dealer attempt to perform the checks
let results = dkg_details
.network_dealers
.into_iter()
.map(|d| {
let share = dkg_details.submitted_shared.get(&d.assigned_index);
check_client(d, dkg_details.dkg_epoch.epoch_id, share)
})
.collect::<FuturesUnordered<_>>()
.collect::<Vec<_>>()
.await;
let epoch_id = dkg_details.dkg_epoch.epoch_id;
let submitted = dkg_details.submitted_shared;
let dealers = dkg_details.network_dealers.len();
let tasks = dkg_details.network_dealers.into_iter().map(move |d| {
let share = submitted.get(&d.assigned_index).cloned();
check_client(d, epoch_id, share)
});
let limit = concurrency.into().filter(|&n| n > 0).unwrap_or(dealers);
let results = stream::iter(tasks).buffer_unordered(limit).collect().await;
Ok(SignersTestResult {
threshold: dkg_details.threshold,
+46 -8
View File
@@ -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::<Mime>().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 {
@@ -0,0 +1,39 @@
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
// 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<S> FromRequestParts<S> for ClientIpAddr
where
S: Send + Sync,
{
type Rejection = Infallible;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
if let Ok(RightmostXForwardedFor(ip)) =
RightmostXForwardedFor::from_request_parts(parts, state).await
{
return Ok(ClientIpAddr(ip));
}
if let Ok(ConnectInfo(addr)) =
ConnectInfo::<SocketAddr>::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)))
}
}
@@ -1,12 +1,12 @@
// Copyright 2024 - Nym Technologies SA <contact@nymtech.net>
// 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,
@@ -2,4 +2,5 @@
// SPDX-License-Identifier: Apache-2.0
pub mod bearer_auth;
pub mod client_ip;
pub mod logging;
@@ -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<OutputV2>,
}
impl OutputParamsV2 {
pub fn get_output(&self) -> OutputV2 {
self.output.unwrap_or_default()
}
pub fn to_response<T: Serialize>(self, data: T) -> FormattedResponse<T> {
self.get_output().to_response(data)
}
}
impl OutputV2 {
pub fn to_response<T: Serialize>(self, data: T) -> FormattedResponse<T> {
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")]
+3 -2
View File
@@ -14,6 +14,8 @@ publish = true
[features]
default = ["codec"]
codec = ["dep:tokio-util"]
test-utils = ["pnet_packet"]
[dependencies]
@@ -29,6 +31,5 @@ semver = { workspace = true }
serde = { workspace = true, features = ["derive"] }
thiserror = { workspace = true }
time = { workspace = true }
tokio = { workspace = true, features = ["time"] }
tokio-util = { workspace = true, features = ["codec"] }
tokio-util = { workspace = true, features = ["codec"], optional = true }
tracing = { workspace = true }
+21 -1
View File
@@ -1,6 +1,7 @@
use std::time::Duration;
use bytes::{Buf, Bytes, BytesMut};
#[cfg(feature = "codec")]
use tokio_util::codec::{Decoder, Encoder};
#[derive(thiserror::Error, Debug)]
@@ -38,6 +39,23 @@ impl MultiIpPacketCodec {
bundled_packets.extend_from_slice(&packet);
bundled_packets.freeze()
}
/// Decode one length-prefixed packet from `src`, advancing past it.
///
/// Same logic as the `Decoder` impl but available without the `codec`
/// feature (i.e. without depending on `tokio-util`).
pub fn decode_one(&mut self, src: &mut BytesMut) -> Result<Option<IprPacket>, Error> {
if src.len() < LENGTH_PREFIX_SIZE {
return Ok(None);
}
let packet_size = u16::from_be_bytes([src[0], src[1]]) as usize;
if src.len() < packet_size + LENGTH_PREFIX_SIZE {
return Ok(None);
}
src.advance(LENGTH_PREFIX_SIZE);
let packet = src.split_to(packet_size);
Ok(Some(IprPacket::Data(packet.freeze())))
}
}
impl Default for MultiIpPacketCodec {
@@ -82,6 +100,7 @@ impl From<Vec<u8>> for IprPacket {
}
}
#[cfg(feature = "codec")]
impl Encoder<IprPacket> for MultiIpPacketCodec {
type Error = Error;
@@ -125,6 +144,7 @@ impl Encoder<IprPacket> for MultiIpPacketCodec {
}
}
#[cfg(feature = "codec")]
impl Decoder for MultiIpPacketCodec {
type Item = IprPacket;
type Error = Error;
@@ -152,7 +172,7 @@ impl Decoder for MultiIpPacketCodec {
}
}
#[cfg(test)]
#[cfg(all(test, feature = "codec"))]
mod tests {
use super::*;

Some files were not shown because too many files have changed in this diff Show More