Compare commits

...

46 Commits

Author SHA1 Message Date
Jędrzej Stuczyński b6eb391e85 bugfix: clippy issues (#6876) 2026-06-12 11:10:06 +01:00
Jędrzej Stuczyński 931ec03b28 feat: expose node's chain address on self-described API (#6815)
* feat: expose nym-nodes' on-chain address on v2 auxiliary endpoint

* moved swagger page outside the v1 route

* fixed swagger endpoint for nym-api

* post rebasing fixes

* remove redundant impl OfflineSigner for Arc<DirectSecp256k1HdWallet>
2026-06-12 10:36:27 +01:00
Jędrzej Stuczyński 8a93bce32f feat: additional mixnet improvements and metrics (#6874)
* wip

* batch processing of forward packets

* tmp: additional metrics for remote node

* fixed incorrect prometheus metric registration

* unified runtime metrics

* unify mixnet client metrics

* packet forwarding cleanup

* add batching for emptying the delay queue

* cleanup client io loop

* feat(nym-node): reap idle mixnet connections (ingress + egress)

Close mixnet connections that sit with no traffic past a configurable idle period (mixnet.debug.connection_idle_timeout, default 5min, 0 disables) to bound lingering tokio tasks/sockets.

Ingress handle_stream is read-only, so a silently-gone peer (NAT drop, crash without FIN, half-open) never triggers FIN/RST and the task would block on .next() forever; a new idle select arm closes it (the post-loop replay flush still runs, so nothing is stranded). Egress run_io_loop gets the symmetric arm keyed on last_send; on close EvictOnDrop clears the cache entry and the next packet transparently reconnects.

Adds a cumulative nym_node_network_idle_closed_ingress_mixnet_connections counter; egress reaping is observed via the existing active-egress gauge plus an exit_reason=idle_timeout log.

* downgrade sysinfo

* refactor(nym-node): split PacketForwarder into router + delay-queue tasks

Split the single PacketForwarder task into two concurrently-scheduled tasks connected by a bounded handoff channel, so intake and delayed-release no longer block each other.

PacketRouter (router.rs) is the intake task: sole consumer of the ingress channel, it applies the routing filter and either forwards zero/already-elapsed-delay packets directly or hands delayed ones to the delay task. Its per-packet work is sub-µs, so new packets no longer wait behind delayed-release processing (collapses the ForwarderQueue tail).

DelayForwarder (delay.rs) owns the NonExhaustiveDelayQueue exclusively (it can't be shared by reference). Its run loop services BOTH branches on every wakeup - draining pending inserts first to bring the queue current, then flushing everything now due - so the biased select can't let releases and inserts starve each other, and a freshly-arrived-but-already-due packet releases in the same pass (marginally improving DelayQueueOverrun).

The mixnet client is shared as Arc<C>; handoff-channel overflow is dropped as an egress drop rather than blocking, keeping intake decoupled from release.

* feat(nym-node): bound egress flush with a write timeout

Cap how long a single egress batch flush may block on a congested peer socket (mixnet.debug.connection_write_timeout, default 500ms, 0 disables), so a slow peer can no longer back this connection's egress queue up into the multi-second range - the root of the EgressQueue and SocketWrite tails.

A single timeout is treated as transient congestion: the un-fed tail of the batch is abandoned but the connection is retained. This is sound because NoiseStream::poll_write encrypts and buffers each frame synchronously, so a cancelled flush leaves the noise transport nonce-consistent and a later flush resumes the byte stream in order - so a momentary spike costs no re-handshake. Only MAX_CONSECUTIVE_WRITE_TIMEOUTS (3) timeouts in a row, i.e. a persistently congested peer, tears the connection down (it reconnects on the next packet); a successful flush resets the counter.

Buffer-size tuning (maximum_connection_buffer_size) deliberately left for live data.

* revert PacketForwarder split in favour of a single task that clears both channels on wake
2026-06-12 10:31:54 +01:00
import this 56846fee77 fix gw landing page (#6873)
* fix landing page image

* use better url
2026-06-10 16:32:12 +02:00
Jędrzej Stuczyński 52949f825a chore: add retries for retrieving chain data (#6847)
* chore: add retries for retrieving chain data

* added retry backoff
2026-06-10 09:54:36 +01:00
Jędrzej Stuczyński 2705330595 feat: introduce node families contract query for Config retrieval (#6870) 2026-06-10 09:54:13 +01:00
benedetta davico fa842ceb4f Merge pull request #6871 from nymtech/master 2026-06-09 18:17:22 +02:00
Jędrzej Stuczyński 3730260cf0 feat(nym-node): mixnet packet latency instrumentation (#6852)
- PacketTrace stopwatch + generic Traced<T> carrier threaded receive -> socket-write
- TraceStage enum owns each stage's metric name/help/buckets; observations go straight
  to the global nym-metrics registry under a uniform mixnet_packet_* family
- stages: Unwrap, ReplayCheck (incl. deferral), ForwarderQueue, DelayQueue,
  DelayQueueOverrun (lateness beyond target release), EgressQueue, SocketWrite, Total
- node-side 1-in-N sampling via MixnetDebug.egress_trace_sample_rate (default 100, 0 disables)
2026-06-09 16:22:14 +01:00
benedetta davico 6bf48de7ba Merge pull request #6869 from nymtech/release/2026.11-xynomizithra
merge release/2026.11 xynomizithra to master
2026-06-09 16:25:10 +02:00
benedettadavico 799dfd59bb Merge branch 'release/2026.11-xynomizithra' into develop 2026-06-09 16:24:28 +02:00
Jędrzej Stuczyński f944348216 bugfix: restore and fix node throughput tester (#6849) 2026-06-09 15:19:37 +01:00
Merve 26955dd74b [DOCs/operators]: Release notes for v2026.11-xynomizithra (#6866)
* test identity

* docs: add changelog for v2026.11-xynomizithra

* operators tools and updates

* add automated stats

* add hosting domains

* add ts sdk to changelog

* fix lang flow

---------

Co-authored-by: serinko <97586125+serinko@users.noreply.github.com>
Co-authored-by: mfahampshire <maxhampshire@pm.me>
2026-06-09 16:14:01 +02:00
mfahampshire 7c890ea0c5 TS SDK docs (#6840)
* First sweep packages + some minor tweaking

* Second sweep

* Regenerate lockfile + package.json mods

* Regenerate lockfile again

* Fix CI

* Fix CI again

* All building properly

* unblock

* Tweak examples

* Comments + readme + fix rotten unit test

* First pass docs

* Big pass

* Massive pass on new docs

* Update integrations.md w mobile

* Partial overhaul review

* new playground + big pass

* new fix lychee err

* IPR notice tweak
2026-06-09 13:31:08 +00:00
benedetta davico 1bc5169691 Remove TODO comments for contract addresses 2026-06-09 15:07:03 +02:00
benedettadavico a900656ec8 add NF contract address 2026-06-09 15:04:40 +02:00
import this 4fcec99cc2 feat: automated bond (#6860)
* initialise bonding automation

* initialise autobond flow

* docs for autobond

* tweak docs and add scraped stats

* resolve issues

* fix issues

* add extra command advice

* fix rabbitai suggestions

* fix rabbitai suggestions
2026-06-09 14:48:53 +02:00
Tommy Verrall 8ce06dbc0e Merge pull request #6867 from nymtech/wallet/bump-for-release
Wallet version bumps
2026-06-09 10:51:54 +02:00
Tommy Verrall b866be4fcf Wallet version Bumps
- Update the wallet to the latest version
2026-06-09 09:54:35 +02:00
Tommy Verrall 37bd66236a Merge pull request #6865 from nymtech/fix/wallet-ux-improvements
Fix wallet minor UX improvements
2026-06-09 09:48:24 +02:00
Tommy Verrall 906a93719f Fix CI Ubuntu 2026-06-08 20:02:37 +02:00
Tommy Verrall 54779175f1 Fix CI linting 2026-06-08 19:19:59 +02:00
Tommy Verrall 6f5e831127 PR comments
- Account loading now dedupes in-flight requests per network instead of sharing one global promise across all networks.
- Regression tests cover same-network reuse and cross-network isolation.
2026-06-08 19:10:36 +02:00
Tommy Verrall 13d48b4bb6 Another round of fixes
- Transaction success is now checked through a shared helper that validates hash, gas usage, and response payloads, not hash presence alone.
- Node settings error helper renamed to match its broader scope.
- Balance refresh now owns the loading flag so nested balance and vesting fetches do not race each other.
- Unbond modal removes the non-null assertion on compounded rewards.
2026-06-08 19:06:13 +02:00
Tommy Verrall 7374ceae6f More fixes
- Unbond totals no longer default malformed amounts to zero; a warning appears when exact totals cannot be calculated.
- Hostname updates no longer treat an empty transaction hash as success.
- Sign-in navigation is gated on successful account load with regression tests.
2026-06-08 18:56:07 +02:00
Tommy Verrall 500200db45 Resolve eslint and prettier issues on balance load changes 2026-06-08 18:26:33 +02:00
Tommy Verrall b9cd2aa12e Fixes
- Account loading is deduplicated so sign-in no longer fires two concurrent network switches.
- Main window boot relies on the network effect only; rust state init no longer double-loads the account.
- NYM price cache clears on sign-out.
2026-06-08 18:23:19 +02:00
Tommy Verrall 002bb3b0f8 UX fixes window geometry, balance load UX, hostname fees, unbond summary
- Wallet no longer forces fullscreen on launch - auth and main windows keep the same size and position when switching.
- Sign-in and balance loading feel smoother, with less layout jump on the home screen.
- Saving a node hostname shows the transaction fee upfront, warns when funds are low, and surfaces clear errors on failure.
- Operator unbond confirmation shows pledge plus compounded operator rewards (delegator stake stays separate).
2026-06-08 18:20:15 +02:00
Tommy Verrall fcab100ec7 Merge pull request #6864 from nymtech/chore/delegation-visibility-touch-ups
Show unbonded delegations in wallet delegation list
2026-06-08 17:33:16 +02:00
benedettadavico 34709e76a1 update changelog 2026-06-08 16:30:10 +02:00
Tommy Verrall f7fbf942f9 PR comments
- Fix addressing concern around unbonding
- Amend existing tests to reflect this
2026-06-08 13:20:15 +02:00
Tommy Verrall d609d30f3a Formatting fixes 2026-06-08 12:47:01 +02:00
Tommy Verrall 2c1b5f59a3 Last fixes
- Pending Delegate events with registry-miss identity keep explorer navigation; unbonded label limited to pending Undelegate rows (`isPendingUndelegateWithRegistryMiss`, `formatPendingDelegationLinkLabel`).
- Tests in `delegationIdentity.test.ts`.
2026-06-08 12:38:09 +02:00
Tommy Verrall c52fc0c9af Last round of fixes
- Align `get_pending_delegation_events` with active delegation identity resolution: `get_node_information` + `delegation_node_identity` synthetic fallback on registry miss.
- Stop hiding pending delegation rows in `shouldHideDelegationFromList` when bonded-registry identity lookup missed.
2026-06-08 12:29:19 +02:00
benedetta davico 153645dabf Merge pull request #6863 from nymtech/bugfig/nf-reinvite-expired-members
bugfix: allow re-inviting expired members
2026-06-08 12:25:17 +02:00
Tommy Verrall 959a986e2c PR review comments
- Add `historical_node_identity` to `DelegationWithEverything` and populate via `lookup_historical_node_identity` in `delegate.rs` so search works after unbond.
- `searchDelegations` searches `historical_node_identity` and guards null/empty `node_identity` with optional chaining.
- Acceptance tests: historical identity search, bonded-unbonding vs synthetic branch semantics, empty-identity search safety.
- Fix linting
2026-06-08 12:23:00 +02:00
Tommy Verrall 9ff2ec249d Show unbonded delegations in wallet delegation list
- In `delegate.rs`, add `delegation_node_identity` and `delegation_mixnode_is_unbonding` so missing node details emit `unbonded:{mix_id}` with `mixnode_is_unbonding: true` instead of an empty `node_identity`.
- Add `delegationListVisibility.ts` (`shouldHideDelegationFromList`, `filterVisibleDelegations`, `searchDelegations`) and wire `DelegationList.tsx` to the shared helpers.
- Update `useSortDelegations.tsx` to pin fully unbonded delegations to the top via `isFullyUnbondedDelegation`.
- In `UndelegateModal.tsx`, display `Node unbonded (mix N)` instead of raw `unbonded:{mix_id}` on the confirm screen.
- Add jest tests
- Add Rust unit test
2026-06-08 12:00:00 +02:00
Jędrzej Stuczyński c85fb161d4 feat: allow re-inviting a node whose family invitation has expired
InviteToFamily previously rejected any second invitation for a (family, node)
pair with PendingInvitationAlreadyExists, even once the existing invitation had
expired and was left inert in the pending map. Now a still-valid invitation still
blocks a duplicate, but an expired one is archived under the new terminal status
FamilyInvitationStatus::Expired and superseded by the fresh invitation.

Regenerated the contract JSON schema and updated the openspec capability.
2026-06-08 10:45:30 +01:00
Jędrzej Stuczyński e27cf142f9 fix: pin ed25519-zebra to 4.0.3 in contracts workspace
cosmwasm-crypto 2.2.2 targets ed25519-zebra 4.0.3 (default-features = false) and
uses its `batch` module, but the lockfile had resolved to 4.2.0, which gates
`batch` behind the `alloc` feature. That left cosmwasm-crypto - and therefore the
whole contracts workspace - failing to compile. Pin back to 4.0.3 so it builds.
2026-06-08 10:45:22 +01:00
Bogdan-Ștefan Neacşu a9bf1954bc Keep peer in wg table when updating psk (#6856)
* Keep peer in wg table when updating psk

* Fix unit test

* update handle_update_peer_psk_request

---------

Co-authored-by: Jędrzej Stuczyński <jedrzej.stuczynski@gmail.com>
2026-06-08 09:19:42 +01:00
Jędrzej Stuczyński b6202b5a6b chore: minor nym-node improvements (#6850)
* set TCP_NODELAY for mixnet connections

* bugfix: correctly compute count deferral threshold

* bugfix: make sure to flush pending packets waiting for bloomfilter check

* implement batch sending into mixnet connection

* adjust default nym-node connection settings
2026-06-08 08:37:41 +01:00
Jędrzej Stuczyński e8410b2302 feat: disable Nagle's algorithm for LP between nym-nodes (#6857) 2026-06-05 16:37:12 +01:00
mfahampshire cff370a943 Update scripts + CI for dryrun + version checking (#6858)
* Update scripts + CI for dryrun + version checking

* Update publish.sh w trap

* Coderabbit
2026-06-05 14:00:04 +00:00
benedettadavico 8de781f750 fix ci builds 2026-06-04 12:15:21 +01:00
benedettadavico 225024d428 update cargo lock 2026-06-04 12:15:21 +01:00
Nym bot 367716612f crates release: bump version to 1.21.1 2026-06-04 12:15:20 +01:00
benedettadavico e82b116230 bump versions 2026-06-04 12:15:20 +01:00
341 changed files with 13126 additions and 6039 deletions
@@ -48,7 +48,7 @@ jobs:
run: cd .. && pnpm i
- name: Install app dependencies
run: pnpm
run: pnpm i
- name: Create env file
uses: timheuer/base64-to-file@v1.2
+15
View File
@@ -1,6 +1,19 @@
name: publish-sdk-npm
on:
workflow_dispatch:
inputs:
dry_run:
description: "Rehearse the publish (pnpm publish --dry-run, no tarballs uploaded). Untick to publish for real."
type: boolean
default: true
dist_tag:
description: "Tag mode. 'auto' picks per package: new packages and same-major releases -> latest; a breaking major (e.g. mix-fetch v2 over v1) -> next, promote later with `npm dist-tag add`. 'next'/'latest' force that tag on all four."
type: choice
options:
- auto
- next
- latest
default: auto
jobs:
publish:
@@ -42,4 +55,6 @@ jobs:
- name: Publish to NPM
env:
NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }}
DRY_RUN: ${{ inputs.dry_run && '1' || '0' }}
NPM_DIST_TAG: ${{ inputs.dist_tag }}
run: ./sdk/typescript/scripts/publish.sh
+3
View File
@@ -83,3 +83,6 @@ test-tutorials/
# pnpm
.pnpm-store/
tmp/
# operator tools
scripts/nym-node-setup/auto-bond/nodes.csv
+88
View File
@@ -4,6 +4,94 @@ Post 1.0.0 release, the changelog format is based on [Keep a Changelog](https://
## [Unreleased]
## [2026.11-xynomizithra] (2026-06-08)
- bugfix: allow re-inviting expired members ([#6863])
- feat: disable Nagle's algorithm for LP between nym-nodes ([#6857])
- Keep peer in wg table when updating psk ([#6856])
- chore: minor nym-node improvements ([#6850])
- chore: LP registration adjustments ([#6845])
- crates release: bump version to 1.21.1 ([#6844])
- fix gateways being penalised for no stress testing ([#6843])
- fix score inflation for throttled nodes ([#6842])
- Bugfix/cherry pick/waterloo stres testing floats ([#6841])
- bugfix: NMv3 race condition ([#6837])
- feat: implement UpdateFamily for the node families contract ([#6834])
- Bugfix/cherry pick/waterloo ns api ([#6833])
- experiment: attempt to retroactively generate specs for node families and ecash contracts ([#6813])
- moving lp packets in lp-data crate ([#6810])
- upgrade axum to 0.8.9 (and side deps) ([#6808])
- chore: expose admin method for migrating vesting delegations/mixnodes ([#6795])
- [chore] fix clippy 1.95 lints for future version update ([#6794])
- Handle Rate Limit Challenge Response ([#6786])
- NYM-583: Avoid corrupted database on Windows. ([#6785])
- Max/smolmix wasm ([#6784])
- Chore/bugfixes ([#6783])
- Switch from yarn to pnpm ([#6779])
- feat: Node Families: expose stake information inside DVpnGateway ([#6778])
- feat: Node Families: expose family information for NS API consumers ([#6777])
- feat: Node Families: cache and expose family data within nym API ([#6774])
- Re-order default API urls for network details ([#6767])
- add ci for NM agent binary ([#6764])
- feat/refactor: introduce shared contract caches within Nym API ([#6760])
- chore: removed dead code for redundant mixnet-vesting integration tests ([#6759])
- feat: Node Families: remove nodes upon unbonding ([#6752])
- feat: Node Families: contract transactions ([#6750])
- feat: Node Families: contract queries ([#6731])
- feat: Node Families: initial contract storage ([#6717])
- start node families topic branch ([#6715])
- Bump rand from 0.8.5 to 0.8.6 in /contracts ([#6702])
- Testing port checks in NS Agents ([#6694])
- build(deps): bump microsoft/setup-msbuild from 2 to 3 ([#6602])
- build(deps): bump tar from 0.4.44 to 0.4.45 ([#6595])
- build(deps): bump quinn-proto from 0.11.12 to 0.11.14 ([#6549])
- build(deps): bump docker/login-action from 3 to 4 ([#6518])
- build(deps): bump actions/download-artifact from 7 to 8 ([#6497])
- build(deps): bump actions/upload-artifact from 6 to 7 ([#6496])
[#6863]: https://github.com/nymtech/nym/pull/6863
[#6857]: https://github.com/nymtech/nym/pull/6857
[#6856]: https://github.com/nymtech/nym/pull/6856
[#6850]: https://github.com/nymtech/nym/pull/6850
[#6845]: https://github.com/nymtech/nym/pull/6845
[#6844]: https://github.com/nymtech/nym/pull/6844
[#6843]: https://github.com/nymtech/nym/pull/6843
[#6842]: https://github.com/nymtech/nym/pull/6842
[#6841]: https://github.com/nymtech/nym/pull/6841
[#6837]: https://github.com/nymtech/nym/pull/6837
[#6834]: https://github.com/nymtech/nym/pull/6834
[#6833]: https://github.com/nymtech/nym/pull/6833
[#6813]: https://github.com/nymtech/nym/pull/6813
[#6810]: https://github.com/nymtech/nym/pull/6810
[#6808]: https://github.com/nymtech/nym/pull/6808
[#6795]: https://github.com/nymtech/nym/pull/6795
[#6794]: https://github.com/nymtech/nym/pull/6794
[#6786]: https://github.com/nymtech/nym/pull/6786
[#6785]: https://github.com/nymtech/nym/pull/6785
[#6784]: https://github.com/nymtech/nym/pull/6784
[#6783]: https://github.com/nymtech/nym/pull/6783
[#6779]: https://github.com/nymtech/nym/pull/6779
[#6778]: https://github.com/nymtech/nym/pull/6778
[#6777]: https://github.com/nymtech/nym/pull/6777
[#6774]: https://github.com/nymtech/nym/pull/6774
[#6767]: https://github.com/nymtech/nym/pull/6767
[#6764]: https://github.com/nymtech/nym/pull/6764
[#6760]: https://github.com/nymtech/nym/pull/6760
[#6759]: https://github.com/nymtech/nym/pull/6759
[#6752]: https://github.com/nymtech/nym/pull/6752
[#6750]: https://github.com/nymtech/nym/pull/6750
[#6731]: https://github.com/nymtech/nym/pull/6731
[#6717]: https://github.com/nymtech/nym/pull/6717
[#6715]: https://github.com/nymtech/nym/pull/6715
[#6702]: https://github.com/nymtech/nym/pull/6702
[#6694]: https://github.com/nymtech/nym/pull/6694
[#6602]: https://github.com/nymtech/nym/pull/6602
[#6595]: https://github.com/nymtech/nym/pull/6595
[#6549]: https://github.com/nymtech/nym/pull/6549
[#6518]: https://github.com/nymtech/nym/pull/6518
[#6497]: https://github.com/nymtech/nym/pull/6497
[#6496]: https://github.com/nymtech/nym/pull/6496
## [2026.10-waterloo] (2026-05-27)
- Re-order default API urls for network details - Waterloo release ([#6799])
Generated
+1347 -1405
View File
File diff suppressed because it is too large Load Diff
+110 -111
View File
@@ -211,7 +211,7 @@ edition = "2024"
license = "Apache-2.0"
rust-version = "1.87.0"
readme = "README.md"
version = "1.21.0"
version = "1.21.1"
[workspace.dependencies]
addr = "0.15.6"
@@ -369,7 +369,7 @@ strum = "0.28.0"
strum_macros = "0.28.0"
subtle-encoding = "0.5"
syn = "2"
sysinfo = "0.37.0"
sysinfo = "0.38.4"
tap = "1.0.1"
tar = "0.4.45"
test-with = { version = "0.15.4", default-features = false }
@@ -406,7 +406,6 @@ utoipa-swagger-ui = "9.0.2"
utoipauto = "0.2"
uuid = "1.19.0"
vergen = { version = "=8.3.1", default-features = false }
vergen-gitcl = { version = "1.0.8", default-features = false }
walkdir = "2"
x25519-dalek = "2.0.0"
zeroize = "1.7.0"
@@ -429,115 +428,115 @@ libcrux-sha3 = "0.0.8"
libcrux-traits = "0.0.6"
# Workspace dep definitions required by crates.io publication - we need a workspace version since `cargo workspaces` doesn't work with path imports from crate manifests
nym-api-requests = { version = "1.21.0", path = "nym-api/nym-api-requests" }
nym-authenticator-requests = { version = "1.21.0", path = "common/authenticator-requests" }
nym-async-file-watcher = { version = "1.21.0", path = "common/async-file-watcher" }
nym-authenticator-client = { version = "1.21.0", path = "nym-authenticator-client" }
nym-bandwidth-controller = { version = "1.21.0", path = "common/bandwidth-controller" }
nym-bin-common = { version = "1.21.0", path = "common/bin-common" }
nym-cache = { version = "1.21.0", path = "common/nym-cache" }
nym-client-core = { version = "1.21.0", path = "common/client-core", default-features = false }
nym-client-core-config-types = { version = "1.21.0", path = "common/client-core/config-types" }
nym-client-core-gateways-storage = { version = "1.21.0", path = "common/client-core/gateways-storage" }
nym-client-core-surb-storage = { version = "1.21.0", path = "common/client-core/surb-storage" }
nym-client-websocket-requests = { version = "1.21.0", path = "clients/native/websocket-requests" }
nym-common = { version = "1.21.0", path = "common/nym-common" }
nym-compact-ecash = { version = "1.21.0", path = "common/nym_offline_compact_ecash" }
nym-config = { version = "1.21.0", path = "common/config" }
nym-contracts-common = { version = "1.21.0", path = "common/cosmwasm-smart-contracts/contracts-common" }
nym-coconut-dkg-common = { version = "1.21.0", path = "common/cosmwasm-smart-contracts/coconut-dkg" }
nym-credential-storage = { version = "1.21.0", path = "common/credential-storage" }
nym-credential-utils = { version = "1.21.0", path = "common/credential-utils" }
nym-credential-proxy-lib = { version = "1.21.0", path = "common/credential-proxy" }
nym-credentials = { version = "1.21.0", path = "common/credentials", default-features = false }
nym-credentials-interface = { version = "1.21.0", path = "common/credentials-interface" }
nym-credential-proxy-requests = { version = "1.21.0", path = "nym-credential-proxy/nym-credential-proxy-requests", default-features = false }
nym-credential-verification = { version = "1.21.0", path = "common/credential-verification" }
nym-crypto = { version = "1.21.0", path = "common/crypto", default-features = false }
nym-dkg = { version = "1.21.0", path = "common/dkg" }
nym-ecash-contract-common = { version = "1.21.0", path = "common/cosmwasm-smart-contracts/ecash-contract" }
nym-ecash-signer-check = { version = "1.21.0", path = "common/ecash-signer-check" }
nym-ecash-signer-check-types = { version = "1.21.0", path = "common/ecash-signer-check-types" }
nym-ecash-time = { version = "1.21.0", path = "common/ecash-time" }
nym-exit-policy = { version = "1.21.0", path = "common/exit-policy" }
nym-ffi-shared = { version = "1.21.0", path = "sdk/ffi/shared" }
nym-gateway-client = { version = "1.21.0", path = "common/client-libs/gateway-client", default-features = false }
nym-gateway-probe = { version = "1.18.0", path = "nym-gateway-probe" }
nym-gateway-requests = { version = "1.21.0", path = "common/gateway-requests" }
nym-gateway-storage = { version = "1.21.0", path = "common/gateway-storage" }
nym-gateway-stats-storage = { version = "1.21.0", path = "common/gateway-stats-storage" }
nym-group-contract-common = { version = "1.21.0", path = "common/cosmwasm-smart-contracts/group-contract" }
nym-http-api-client = { version = "1.21.0", path = "common/http-api-client" }
nym-http-api-client-macro = { version = "1.21.0", path = "common/http-api-client-macro" }
nym-http-api-common = { version = "1.21.0", path = "common/http-api-common", default-features = false }
nym-id = { version = "1.21.0", path = "common/nym-id" }
nym-ip-packet-client = { version = "1.21.0", path = "nym-ip-packet-client" }
nym-ip-packet-requests = { version = "1.21.0", path = "common/ip-packet-requests" }
nym-lp = { version = "1.21.0", path = "common/nym-lp" }
nym-lp-data = { version = "1.21.0", path = "common/nym-lp-data" }
nym-kkt = { version = "1.21.0", path = "common/nym-kkt" }
nym-kkt-ciphersuite = { version = "1.21.0", path = "common/nym-kkt-ciphersuite" }
nym-kkt-context = { version = "1.21.0", path = "common/nym-kkt-context" }
nym-metrics = { version = "1.21.0", path = "common/nym-metrics" }
nym-mixnet-client = { version = "1.21.0", path = "common/client-libs/mixnet-client" }
nym-mixnet-contract-common = { version = "1.21.0", path = "common/cosmwasm-smart-contracts/mixnet-contract" }
nym-multisig-contract-common = { version = "1.21.0", path = "common/cosmwasm-smart-contracts/multisig-contract" }
nym-network-defaults = { version = "1.21.0", path = "common/network-defaults" }
nym-node-tester-utils = { version = "1.21.0", path = "common/node-tester-utils" }
nym-noise = { version = "1.21.0", path = "common/nymnoise" }
nym-noise-keys = { version = "1.21.0", path = "common/nymnoise/keys" }
nym-nonexhaustive-delayqueue = { version = "1.21.0", path = "common/nonexhaustive-delayqueue" }
nym-node-requests = { version = "1.21.0", path = "nym-node/nym-node-requests", default-features = false }
nym-node-metrics = { version = "1.21.0", path = "nym-node/nym-node-metrics" }
nym-node-families-contract-common = { version = "1.21.0", path = "common/cosmwasm-smart-contracts/node-families-contract" }
nym-ordered-buffer = { version = "1.21.0", path = "common/socks5/ordered-buffer" }
nym-outfox = { version = "1.21.0", path = "nym-outfox" }
nym-registration-common = { version = "1.21.0", path = "common/registration" }
nym-pemstore = { version = "1.21.0", path = "common/pemstore" }
nym-performance-contract-common = { version = "1.21.0", path = "common/cosmwasm-smart-contracts/nym-performance-contract" }
nym-sdk = { version = "1.21.0", path = "sdk/rust/nym-sdk" }
nym-serde-helpers = { version = "1.21.0", path = "common/serde-helpers" }
nym-service-providers-common = { version = "1.21.0", path = "service-providers/common" }
nym-service-provider-requests-common = { version = "1.21.0", path = "common/service-provider-requests-common" }
nym-socks5-client-core = { version = "1.21.0", path = "common/socks5-client-core" }
nym-socks5-proxy-helpers = { version = "1.21.0", path = "common/socks5/proxy-helpers" }
nym-socks5-requests = { version = "1.21.0", path = "common/socks5/requests" }
nym-sphinx = { version = "1.21.0", path = "common/nymsphinx" }
nym-sphinx-acknowledgements = { version = "1.21.0", path = "common/nymsphinx/acknowledgements" }
nym-sphinx-addressing = { version = "1.21.0", path = "common/nymsphinx/addressing" }
nym-sphinx-anonymous-replies = { version = "1.21.0", path = "common/nymsphinx/anonymous-replies" }
nym-sphinx-chunking = { version = "1.21.0", path = "common/nymsphinx/chunking" }
nym-sphinx-cover = { version = "1.21.0", path = "common/nymsphinx/cover" }
nym-sphinx-forwarding = { version = "1.21.0", path = "common/nymsphinx/forwarding" }
nym-sphinx-framing = { version = "1.21.0", path = "common/nymsphinx/framing" }
nym-sphinx-params = { version = "1.21.0", path = "common/nymsphinx/params" }
nym-sphinx-routing = { version = "1.21.0", path = "common/nymsphinx/routing" }
nym-sphinx-types = { version = "1.21.0", path = "common/nymsphinx/types" }
nym-statistics-common = { version = "1.21.0", path = "common/statistics" }
nym-store-cipher = { version = "1.21.0", path = "common/store-cipher" }
nym-task = { version = "1.21.0", path = "common/task" }
nym-tun = { version = "1.21.0", path = "common/tun" }
nym-test-utils = { version = "1.21.0", path = "common/test-utils" }
nym-ticketbooks-merkle = { version = "1.21.0", path = "common/ticketbooks-merkle" }
nym-topology = { version = "1.21.0", path = "common/topology" }
nym-types = { version = "1.21.0", path = "common/types" }
nym-upgrade-mode-check = { version = "1.21.0", path = "common/upgrade-mode-check" }
nym-validator-client = { version = "1.21.0", path = "common/client-libs/validator-client", default-features = false }
nym-vesting-contract-common = { version = "1.21.0", path = "common/cosmwasm-smart-contracts/vesting-contract" }
nym-network-monitors-contract-common = { version = "1.21.0", path = "common/cosmwasm-smart-contracts/network-monitors-contract" }
nym-verloc = { version = "1.21.0", path = "common/verloc" }
nym-wireguard = { version = "1.21.0", path = "common/wireguard" }
nym-wireguard-types = { version = "1.21.0", path = "common/wireguard-types" }
nym-wireguard-private-metadata-shared = { version = "1.21.0", path = "common/wireguard-private-metadata/shared" }
nym-wireguard-private-metadata-client = { version = "1.21.0", path = "common/wireguard-private-metadata/client" }
nym-wireguard-private-metadata-server = { version = "1.21.0", path = "common/wireguard-private-metadata/server" }
nym-sqlx-pool-guard = { version = "1.2.0", path = "nym-sqlx-pool-guard" }
nym-wasm-client-core = { version = "1.21.0", path = "common/wasm/client-core" }
nym-wasm-storage = { version = "1.21.0", path = "common/wasm/storage" }
nym-wasm-utils = { version = "1.21.0", path = "common/wasm/utils", default-features = false }
nyxd-scraper-shared = { version = "1.21.0", path = "common/nyxd-scraper-shared" }
nym-api-requests = { version = "1.21.1", path = "nym-api/nym-api-requests" }
nym-authenticator-requests = { version = "1.21.1", path = "common/authenticator-requests" }
nym-async-file-watcher = { version = "1.21.1", path = "common/async-file-watcher" }
nym-authenticator-client = { version = "1.21.1", path = "nym-authenticator-client" }
nym-bandwidth-controller = { version = "1.21.1", path = "common/bandwidth-controller" }
nym-bin-common = { version = "1.21.1", path = "common/bin-common" }
nym-cache = { version = "1.21.1", path = "common/nym-cache" }
nym-client-core = { version = "1.21.1", path = "common/client-core", default-features = false }
nym-client-core-config-types = { version = "1.21.1", path = "common/client-core/config-types" }
nym-client-core-gateways-storage = { version = "1.21.1", path = "common/client-core/gateways-storage" }
nym-client-core-surb-storage = { version = "1.21.1", path = "common/client-core/surb-storage" }
nym-client-websocket-requests = { version = "1.21.1", path = "clients/native/websocket-requests" }
nym-common = { version = "1.21.1", path = "common/nym-common" }
nym-compact-ecash = { version = "1.21.1", path = "common/nym_offline_compact_ecash" }
nym-config = { version = "1.21.1", path = "common/config" }
nym-contracts-common = { version = "1.21.1", path = "common/cosmwasm-smart-contracts/contracts-common" }
nym-coconut-dkg-common = { version = "1.21.1", path = "common/cosmwasm-smart-contracts/coconut-dkg" }
nym-credential-storage = { version = "1.21.1", path = "common/credential-storage" }
nym-credential-utils = { version = "1.21.1", path = "common/credential-utils" }
nym-credential-proxy-lib = { version = "1.21.1", path = "common/credential-proxy" }
nym-credentials = { version = "1.21.1", path = "common/credentials", default-features = false }
nym-credentials-interface = { version = "1.21.1", path = "common/credentials-interface" }
nym-credential-proxy-requests = { version = "1.21.1", path = "nym-credential-proxy/nym-credential-proxy-requests", default-features = false }
nym-credential-verification = { version = "1.21.1", path = "common/credential-verification" }
nym-crypto = { version = "1.21.1", path = "common/crypto", default-features = false }
nym-dkg = { version = "1.21.1", path = "common/dkg" }
nym-ecash-contract-common = { version = "1.21.1", path = "common/cosmwasm-smart-contracts/ecash-contract" }
nym-ecash-signer-check = { version = "1.21.1", path = "common/ecash-signer-check" }
nym-ecash-signer-check-types = { version = "1.21.1", path = "common/ecash-signer-check-types" }
nym-ecash-time = { version = "1.21.1", path = "common/ecash-time" }
nym-exit-policy = { version = "1.21.1", path = "common/exit-policy" }
nym-ffi-shared = { version = "1.21.1", path = "sdk/ffi/shared" }
nym-gateway-client = { version = "1.21.1", path = "common/client-libs/gateway-client", default-features = false }
nym-gateway-probe = { version = "1.21.1", path = "nym-gateway-probe" }
nym-gateway-requests = { version = "1.21.1", path = "common/gateway-requests" }
nym-gateway-storage = { version = "1.21.1", path = "common/gateway-storage" }
nym-gateway-stats-storage = { version = "1.21.1", path = "common/gateway-stats-storage" }
nym-group-contract-common = { version = "1.21.1", path = "common/cosmwasm-smart-contracts/group-contract" }
nym-http-api-client = { version = "1.21.1", path = "common/http-api-client" }
nym-http-api-client-macro = { version = "1.21.1", path = "common/http-api-client-macro" }
nym-http-api-common = { version = "1.21.1", path = "common/http-api-common", default-features = false }
nym-id = { version = "1.21.1", path = "common/nym-id" }
nym-ip-packet-client = { version = "1.21.1", path = "nym-ip-packet-client" }
nym-ip-packet-requests = { version = "1.21.1", path = "common/ip-packet-requests" }
nym-lp = { version = "1.21.1", path = "common/nym-lp" }
nym-lp-data = { version = "1.21.1", path = "common/nym-lp-data" }
nym-kkt = { version = "1.21.1", path = "common/nym-kkt" }
nym-kkt-ciphersuite = { version = "1.21.1", path = "common/nym-kkt-ciphersuite" }
nym-kkt-context = { version = "1.21.1", path = "common/nym-kkt-context" }
nym-metrics = { version = "1.21.1", path = "common/nym-metrics" }
nym-mixnet-client = { version = "1.21.1", path = "common/client-libs/mixnet-client" }
nym-mixnet-contract-common = { version = "1.21.1", path = "common/cosmwasm-smart-contracts/mixnet-contract" }
nym-multisig-contract-common = { version = "1.21.1", path = "common/cosmwasm-smart-contracts/multisig-contract" }
nym-network-defaults = { version = "1.21.1", path = "common/network-defaults" }
nym-node-tester-utils = { version = "1.21.1", path = "common/node-tester-utils" }
nym-noise = { version = "1.21.1", path = "common/nymnoise" }
nym-noise-keys = { version = "1.21.1", path = "common/nymnoise/keys" }
nym-nonexhaustive-delayqueue = { version = "1.21.1", path = "common/nonexhaustive-delayqueue" }
nym-node-requests = { version = "1.21.1", path = "nym-node/nym-node-requests", default-features = false }
nym-node-metrics = { version = "1.21.1", path = "nym-node/nym-node-metrics" }
nym-node-families-contract-common = { version = "1.21.1", path = "common/cosmwasm-smart-contracts/node-families-contract" }
nym-ordered-buffer = { version = "1.21.1", path = "common/socks5/ordered-buffer" }
nym-outfox = { version = "1.21.1", path = "nym-outfox" }
nym-registration-common = { version = "1.21.1", path = "common/registration" }
nym-pemstore = { version = "1.21.1", path = "common/pemstore" }
nym-performance-contract-common = { version = "1.21.1", path = "common/cosmwasm-smart-contracts/nym-performance-contract" }
nym-sdk = { version = "1.21.1", path = "sdk/rust/nym-sdk" }
nym-serde-helpers = { version = "1.21.1", path = "common/serde-helpers" }
nym-service-providers-common = { version = "1.21.1", path = "service-providers/common" }
nym-service-provider-requests-common = { version = "1.21.1", path = "common/service-provider-requests-common" }
nym-socks5-client-core = { version = "1.21.1", path = "common/socks5-client-core" }
nym-socks5-proxy-helpers = { version = "1.21.1", path = "common/socks5/proxy-helpers" }
nym-socks5-requests = { version = "1.21.1", path = "common/socks5/requests" }
nym-sphinx = { version = "1.21.1", path = "common/nymsphinx" }
nym-sphinx-acknowledgements = { version = "1.21.1", path = "common/nymsphinx/acknowledgements" }
nym-sphinx-addressing = { version = "1.21.1", path = "common/nymsphinx/addressing" }
nym-sphinx-anonymous-replies = { version = "1.21.1", path = "common/nymsphinx/anonymous-replies" }
nym-sphinx-chunking = { version = "1.21.1", path = "common/nymsphinx/chunking" }
nym-sphinx-cover = { version = "1.21.1", path = "common/nymsphinx/cover" }
nym-sphinx-forwarding = { version = "1.21.1", path = "common/nymsphinx/forwarding" }
nym-sphinx-framing = { version = "1.21.1", path = "common/nymsphinx/framing" }
nym-sphinx-params = { version = "1.21.1", path = "common/nymsphinx/params" }
nym-sphinx-routing = { version = "1.21.1", path = "common/nymsphinx/routing" }
nym-sphinx-types = { version = "1.21.1", path = "common/nymsphinx/types" }
nym-statistics-common = { version = "1.21.1", path = "common/statistics" }
nym-store-cipher = { version = "1.21.1", path = "common/store-cipher" }
nym-task = { version = "1.21.1", path = "common/task" }
nym-tun = { version = "1.21.1", path = "common/tun" }
nym-test-utils = { version = "1.21.1", path = "common/test-utils" }
nym-ticketbooks-merkle = { version = "1.21.1", path = "common/ticketbooks-merkle" }
nym-topology = { version = "1.21.1", path = "common/topology" }
nym-types = { version = "1.21.1", path = "common/types" }
nym-upgrade-mode-check = { version = "1.21.1", path = "common/upgrade-mode-check" }
nym-validator-client = { version = "1.21.1", path = "common/client-libs/validator-client", default-features = false }
nym-vesting-contract-common = { version = "1.21.1", path = "common/cosmwasm-smart-contracts/vesting-contract" }
nym-network-monitors-contract-common = { version = "1.21.1", path = "common/cosmwasm-smart-contracts/network-monitors-contract" }
nym-verloc = { version = "1.21.1", path = "common/verloc" }
nym-wireguard = { version = "1.21.1", path = "common/wireguard" }
nym-wireguard-types = { version = "1.21.1", path = "common/wireguard-types" }
nym-wireguard-private-metadata-shared = { version = "1.21.1", path = "common/wireguard-private-metadata/shared" }
nym-wireguard-private-metadata-client = { version = "1.21.1", path = "common/wireguard-private-metadata/client" }
nym-wireguard-private-metadata-server = { version = "1.21.1", path = "common/wireguard-private-metadata/server" }
nym-sqlx-pool-guard = { version = "1.21.1", path = "nym-sqlx-pool-guard" }
nym-wasm-client-core = { version = "1.21.1", path = "common/wasm/client-core" }
nym-wasm-storage = { version = "1.21.1", path = "common/wasm/storage" }
nym-wasm-utils = { version = "1.21.1", path = "common/wasm/utils", default-features = false }
nyxd-scraper-shared = { version = "1.21.1", path = "common/nyxd-scraper-shared" }
smolmix = { version = "1.21.0", path = "smolmix/core" }
smolmix = { version = "1.21.1", path = "smolmix/core" }
# coconut/DKG related
# unfortunately until https://github.com/zkcrypto/nym-bls12_381-fork/issues/10 is resolved, we have to rely on the fork
+8
View File
@@ -0,0 +1,8 @@
---
- name: Nym node auto-bonding
hosts: all
gather_facts: false
serial: 1
roles:
- role: postinstall-auto
@@ -0,0 +1,38 @@
- name: Show which node is being bonded
tags: bonding
debug:
msg: "Bonding Nym node: {{ hostname }}"
- name: Get bonding details
tags: bonding
command: "/root/nym-binaries/nym-node bonding-information"
register: bondinfo
changed_when: false
- name: Display bonding info
tags: bonding
debug:
msg: "{{ item }}"
loop: "{{ bondinfo.stdout_lines }}"
- name: Sign bonding contract message on the node
tags: bonding
command:
argv:
- /root/nym-binaries/nym-node
- sign
- --contract-msg
- "{{ contract_msg }}"
- --output
- json
register: sign_output
- name: Display full signed message exactly as returned
tags: bonding
debug:
msg: "{{ sign_output.stdout }}"
- name: Display encoded signature
tags: bonding
debug:
msg: "ENCODED_SIGNATURE={{ (sign_output.stdout | from_json).encoded_signature }}"
+10 -6
View File
@@ -1,16 +1,20 @@
- name: Download quic_bridge_deployment.sh
tags: quic bridge deployment
get_url:
url: "{{ quic_bridge_deployment_url }}"
dest: "/root/nym-binaries/quic_bridge_deployment.sh"
command:
cmd: "curl -fsSL {{ quic_bridge_deployment_url }} -o /root/nym-binaries/quic_bridge_deployment.sh"
tags: quic
- name: Set quic_bridge_deployment permissions
file:
path: /root/nym-binaries/quic_bridge_deployment.sh
mode: "0755"
tags: quic
- name: Configure tunnel manager
tags: quic bridge deployment
become: true
command:
cmd: "/root/nym-binaries/quic_bridge_deployment.sh {{ item }}"
environment:
NONINTERACTIVE: "1"
loop:
- full_bridge_setup
- full_bridge_setup
tags: quic
+10 -4
View File
@@ -10,11 +10,17 @@
- ntm
- name: Download network tunnel manager
get_url:
url: "{{ tunnel_manager_url }}"
dest: /root/nym-binaries/network-tunnel-manager.sh
command:
cmd: "curl -fsSL {{ tunnel_manager_url }} -o /root/nym-binaries/network-tunnel-manager.sh"
tags:
- tunnel
- network_tunnel_manager
- ntm
- name: Set network tunnel manager permissions
file:
path: /root/nym-binaries/network-tunnel-manager.sh
mode: "0755"
force: yes
tags:
- tunnel
- network_tunnel_manager
+1 -1
View File
@@ -1,7 +1,7 @@
[package]
name = "nym-client"
description = "Implementation of the Nym Client"
version = "1.1.77"
version = "1.1.78"
authors = ["Dave Hrycyszyn <futurechimp@users.noreply.github.com>", "Jędrzej Stuczyński <andrew@nymtech.net>"]
edition = "2021"
license.workspace = true
+1 -1
View File
@@ -1,7 +1,7 @@
[package]
name = "nym-socks5-client"
description = "A SOCKS5 localhost proxy that converts incoming messages to Sphinx and sends them to a Nym address"
version = "1.1.77"
version = "1.1.78"
authors = ["Dave Hrycyszyn <futurechimp@users.noreply.github.com>"]
edition = "2021"
license.workspace = true
+4 -2
View File
@@ -17,6 +17,7 @@ publish = true
[dependencies]
dashmap = { workspace = true }
futures = { workspace = true }
strum = { workspace = true }
tracing = { workspace = true }
tokio = { workspace = true, features = ["time", "sync"] }
tokio-util = { workspace = true, features = ["codec"], optional = true }
@@ -26,12 +27,13 @@ tokio-stream = { workspace = true }
nym-noise = { workspace = true }
nym-sphinx = { workspace = true }
nym-task = { workspace = true, optional = true }
nym-metrics = { workspace = true, optional = true }
[features]
default = ["client"]
client = ["tokio-util", "nym-task", "tokio/net", "tokio/rt"]
client = ["tokio-util", "nym-task", "nym-metrics", "tokio/net", "tokio/rt"]
[dev-dependencies]
nym-crypto = { workspace = true }
rand = { workspace = true }
tokio = { workspace = true, features = ["macros", "io-util", "rt", "rt-multi-thread"] }
tokio = { workspace = true, features = ["macros", "io-util", "rt", "rt-multi-thread", "test-util"] }
+302 -62
View File
@@ -1,8 +1,9 @@
// Copyright 2021-2024 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::metrics::{MixnetMetric, Traced};
use dashmap::DashMap;
use futures::{SinkExt, StreamExt};
use futures::{Sink, SinkExt, StreamExt};
use nym_noise::config::NoiseConfig;
use nym_noise::upgrade_noise_initiator;
use nym_sphinx::forwarding::packet::MixPacket;
@@ -10,7 +11,7 @@ use nym_sphinx::framing::codec::NymCodec;
use nym_sphinx::framing::packet::FramedNymPacket;
use std::io;
use std::net::SocketAddr;
use std::ops::Deref;
use std::ops::{ControlFlow, Deref};
use std::sync::atomic::{AtomicU32, AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Duration;
@@ -18,7 +19,7 @@ use tokio::io::{AsyncRead, AsyncWrite};
use tokio::net::TcpStream;
use tokio::sync::mpsc;
use tokio::sync::mpsc::error::TrySendError;
use tokio::time::sleep;
use tokio::time::{sleep, Instant};
use tokio_stream::wrappers::ReceiverStream;
use tokio_util::codec::Framed;
use tracing::*;
@@ -30,6 +31,13 @@ pub struct Config {
pub initial_connection_timeout: Duration,
pub maximum_connection_buffer_size: usize,
pub use_legacy_packet_encoding: bool,
/// Close an egress connection after this long with no packets sent (0 disables). The cache
/// entry is evicted on close and the next packet to that peer transparently reconnects.
pub connection_idle_timeout: Duration,
/// Max time a single batch flush may block on the peer socket before we give up on it
/// (0 disables). One timeout is treated as transient congestion - the batch is abandoned but
/// the connection is retained (no re-handshake); only a few *consecutive* timeouts tear it down.
pub connection_write_timeout: Duration,
}
impl Config {
@@ -39,6 +47,8 @@ impl Config {
initial_connection_timeout: Duration,
maximum_connection_buffer_size: usize,
use_legacy_packet_encoding: bool,
connection_idle_timeout: Duration,
connection_write_timeout: Duration,
) -> Self {
Config {
initial_reconnection_backoff,
@@ -46,14 +56,18 @@ impl Config {
initial_connection_timeout,
maximum_connection_buffer_size,
use_legacy_packet_encoding,
connection_idle_timeout,
connection_write_timeout,
}
}
}
pub trait SendWithoutResponse {
// Without response in this context means we will not listen for anything we might get back (not
// that we should get anything), including any possible io errors
fn send_without_response(&self, packet: MixPacket) -> io::Result<()>;
// that we should get anything), including any possible io errors.
// The packet carries the latency trace started upstream (at receive); the egress stages are
// stamped here and are a no-op for unsampled packets.
fn send_without_response(&self, packet: Traced<MixPacket>) -> io::Result<()>;
}
pub struct Client {
@@ -89,7 +103,7 @@ impl Deref for ActiveConnections {
}
pub struct ConnectionSender {
channel: mpsc::Sender<FramedNymPacket>,
channel: mpsc::Sender<Traced<FramedNymPacket>>,
current_reconnection_attempt: Arc<AtomicU32>,
// Identifies the `ManagedConnection` task currently owning this entry; used
// to ensure drop-time eviction only fires on the still-owning task.
@@ -97,7 +111,7 @@ pub struct ConnectionSender {
}
impl ConnectionSender {
fn new(channel: mpsc::Sender<FramedNymPacket>, handle_token: Arc<()>) -> Self {
fn new(channel: mpsc::Sender<Traced<FramedNymPacket>>, handle_token: Arc<()>) -> Self {
ConnectionSender {
channel,
current_reconnection_attempt: Arc::new(AtomicU32::new(0)),
@@ -109,8 +123,10 @@ impl ConnectionSender {
struct ManagedConnection {
address: SocketAddr,
noise_config: NoiseConfig,
message_receiver: ReceiverStream<FramedNymPacket>,
message_receiver: ReceiverStream<Traced<FramedNymPacket>>,
connection_timeout: Duration,
idle_timeout: Duration,
write_timeout: Duration,
current_reconnection: Arc<AtomicU32>,
active_connections: ActiveConnections,
handle_token: Arc<()>,
@@ -140,11 +156,14 @@ impl Drop for EvictOnDrop {
}
impl ManagedConnection {
#[allow(clippy::too_many_arguments)]
fn new(
address: SocketAddr,
noise_config: NoiseConfig,
message_receiver: mpsc::Receiver<FramedNymPacket>,
message_receiver: mpsc::Receiver<Traced<FramedNymPacket>>,
connection_timeout: Duration,
idle_timeout: Duration,
write_timeout: Duration,
current_reconnection: Arc<AtomicU32>,
active_connections: ActiveConnections,
handle_token: Arc<()>,
@@ -154,6 +173,8 @@ impl ManagedConnection {
noise_config,
message_receiver: ReceiverStream::new(message_receiver),
connection_timeout,
idle_timeout,
write_timeout,
current_reconnection,
active_connections,
handle_token,
@@ -162,6 +183,8 @@ impl ManagedConnection {
async fn run(self) {
let address = self.address;
let idle_timeout = self.idle_timeout;
let write_timeout = self.write_timeout;
let _evict_guard = EvictOnDrop {
active_connections: self.active_connections,
address,
@@ -218,6 +241,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,79 +274,236 @@ 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;
run_io_loop(
conn,
self.message_receiver,
address,
idle_timeout,
write_timeout,
)
.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;
/// Drive the read half solely to notice peer FIN/RST (the connection is send-only). Returns
/// `Break` when the peer closed the connection or the read errored, `Continue` otherwise.
fn handle_peer_read<P, E: std::fmt::Display>(
msg: Option<Result<P, E>>,
address: SocketAddr,
) -> ControlFlow<()> {
match msg {
None => {
debug!(
peer = %address,
exit_reason = "peer_closed",
"peer closed mixnet connection to {address}"
);
ControlFlow::Break(())
}
Some(Err(err)) => {
debug!(
event = "connection.read_error",
peer = %address,
error = %err,
exit_reason = "read_error",
"read error on mixnet connection to {address}: {err}"
);
ControlFlow::Break(())
}
Some(Ok(_)) => {
trace!(
peer = %address,
"unexpected inbound packet on mixnet connection to {address}; discarding"
);
ControlFlow::Continue(())
}
}
}
/// Number of consecutive flush timeouts to the same peer we tolerate before dropping the
/// connection. A single timeout is transient congestion (batch abandoned, connection retained to
/// avoid a re-handshake); this many in a row means the peer is persistently unable to keep up, so
/// we tear the connection down (it reconnects on the next packet).
const MAX_CONSECUTIVE_WRITE_TIMEOUTS: u32 = 3;
/// Outcome of attempting to flush one batch to the peer.
enum BatchOutcome {
/// the batch was flushed to the socket
Sent,
/// the flush exceeded the write timeout (peer congested): the un-fed tail of the batch is
/// dropped, but the already-encoded frames stay buffered for a later flush and the connection
/// is left intact - the noise transport stays nonce-consistent across the cancelled flush, so
/// resuming the write is sound
WriteTimedOut,
/// the sink errored: the connection is dead
Failed,
}
/// Feed a ready batch into the sink and flush it once (far fewer syscalls than per-packet), then
/// stamp the egress latency stages: `EgressQueue` before each feed, then `SocketWrite` + the
/// end-to-end total once the batch has hit the wire. The flush is bounded by `write_timeout`
/// (0 disables) so a congested peer can't block this connection's egress queue into the
/// multi-second range. The caller decides what a timeout means (see [`MAX_CONSECUTIVE_WRITE_TIMEOUTS`]).
async fn forward_batch<S>(
sink: &mut S,
batch: Vec<Traced<FramedNymPacket>>,
address: SocketAddr,
write_timeout: Duration,
) -> BatchOutcome
where
S: Sink<FramedNymPacket> + Unpin,
S::Error: std::fmt::Display,
{
let mut traces = Vec::with_capacity(batch.len());
let write = async {
for mut traced in batch {
// time spent waiting in this connection's egress buffer
traced.record(MixnetMetric::EgressQueue);
sink.feed(traced.inner).await?;
traces.push(traced.trace);
}
sink.flush().await
};
// bound how long we block on a slow/congested peer socket. On timeout the `write` future is
// cancelled, which is safe: every already-encoded frame is buffered (nonce-consistent), so a
// later flush resumes the byte stream in order.
let write_result = if write_timeout.is_zero() {
Ok(write.await)
} else {
tokio::time::timeout(write_timeout, write).await
};
// socket-write time + end-to-end total for whatever was fed (on a timeout, those frames are
// buffered and will hit the wire on a subsequent flush)
for mut trace in traces {
trace.record(MixnetMetric::SocketWrite);
trace.record_total();
}
match write_result {
Ok(Ok(())) => BatchOutcome::Sent,
Ok(Err(err)) => {
debug!(
event = "connection.forward_error",
peer = %address,
error = %err,
exit_reason = "forward_error",
"failed to forward packet batch to {address}: {err}"
);
BatchOutcome::Failed
}
Err(_elapsed) => BatchOutcome::WriteTimedOut,
}
}
/// Instant at which a connection idle since `last_activity` should be closed, or `None` if idle
/// reaping is disabled (`timeout` is zero).
fn idle_deadline(last_activity: Instant, timeout: Duration) -> Option<Instant> {
(!timeout.is_zero()).then(|| last_activity + timeout)
}
// The connection is unidirectional (send-only); we read from it solely to
// notice peer FIN/RST while idle so we can evict the cache entry before the
// next outbound send finds it stale.
async fn run_io_loop<T>(
conn: Framed<T, NymCodec>,
mut receiver: ReceiverStream<FramedNymPacket>,
receiver: ReceiverStream<Traced<FramedNymPacket>>,
address: SocketAddr,
idle_timeout: Duration,
write_timeout: Duration,
) 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);
// reset by every batch we send; drives the idle-connection reaping below
let mut last_send = tokio::time::Instant::now();
// consecutive flush timeouts; a run of them (a persistently congested peer) drops the connection
let mut consecutive_write_timeouts = 0u32;
loop {
tokio::select! {
msg = stream.next() => {
match msg {
None => {
debug!(
peer = %address,
exit_reason = "peer_closed",
"peer closed mixnet connection to {address}"
);
break;
}
Some(Err(err)) => {
debug!(
event = "connection.read_error",
peer = %address,
error = %err,
exit_reason = "read_error",
"read error on mixnet connection to {address}: {err}"
);
break;
}
Some(Ok(_)) => {
trace!(
peer = %address,
"unexpected inbound packet on mixnet connection to {address}; discarding"
);
}
if handle_peer_read(msg, address).is_break() {
break;
}
}
outgoing = receiver.next() => {
match outgoing {
None => {
debug!(
peer = %address,
exit_reason = "sender_dropped",
"connection manager to {address} finished"
);
break;
let Some(batch) = outgoing else {
debug!(
peer = %address,
exit_reason = "sender_dropped",
"connection manager to {address} finished"
);
break;
};
match forward_batch(&mut sink, batch, address, write_timeout).await {
BatchOutcome::Sent => {
consecutive_write_timeouts = 0;
last_send = Instant::now();
}
Some(packet) => {
if let Err(err) = sink.send(packet).await {
BatchOutcome::WriteTimedOut => {
consecutive_write_timeouts += 1;
warn!(
event = "connection.write_congested",
peer = %address,
write_ms = write_timeout.as_millis() as u64,
attempt = consecutive_write_timeouts,
max_attempts = MAX_CONSECUTIVE_WRITE_TIMEOUTS,
"egress flush to {address} timed out (peer congested); abandoned batch, retaining connection"
);
if consecutive_write_timeouts >= MAX_CONSECUTIVE_WRITE_TIMEOUTS {
debug!(
event = "connection.forward_error",
peer = %address,
error = %err,
exit_reason = "forward_error",
"Failed to forward packet to {address}: {err}"
exit_reason = "write_timeout",
"egress connection to {address} congested for {MAX_CONSECUTIVE_WRITE_TIMEOUTS} consecutive flushes; dropping it"
);
break;
}
// keep the connection: a single congestion spike shouldn't cost a
// re-handshake. `last_send` is deliberately not bumped, so a peer that goes
// congested-then-silent still idle-reaps on schedule.
}
BatchOutcome::Failed => break,
}
}
// close the connection (freeing the task/socket) if we haven't sent anything for too
// long; EvictOnDrop then clears the cache entry and the next packet reconnects
_ = async {
match idle_deadline(last_send, idle_timeout) {
Some(d) => tokio::time::sleep_until(d).await,
None => std::future::pending::<()>().await,
}
} => {
debug!(
peer = %address,
exit_reason = "idle_timeout",
idle_secs = idle_timeout.as_secs(),
"closing idle egress mixnet connection to {address}"
);
break;
}
}
}
}
@@ -358,7 +543,7 @@ impl Client {
}
}
fn make_connection(&self, address: SocketAddr, pending_packet: FramedNymPacket) {
fn make_connection(&self, address: SocketAddr, pending_packet: Traced<FramedNymPacket>) {
let (sender, receiver) = mpsc::channel(self.config.maximum_connection_buffer_size);
// this CAN'T fail because we just created the channel which has a non-zero capacity
@@ -387,8 +572,10 @@ impl Client {
let reconnection_attempt = current_reconnection_attempt.load(Ordering::Acquire);
let backoff = self.determine_backoff(reconnection_attempt);
// copy the value before moving into another task
// copy the values before moving into another task
let initial_connection_timeout = self.config.initial_connection_timeout;
let connection_idle_timeout = self.config.connection_idle_timeout;
let connection_write_timeout = self.config.connection_write_timeout;
let connections_count = self.connections_count.clone();
let noise_config = self.noise_config.clone();
@@ -406,6 +593,8 @@ impl Client {
noise_config,
receiver,
initial_connection_timeout,
connection_idle_timeout,
connection_write_timeout,
current_reconnection_attempt,
active_connections,
handle_token,
@@ -418,14 +607,17 @@ impl Client {
}
impl SendWithoutResponse for Client {
fn send_without_response(&self, packet: MixPacket) -> io::Result<()> {
let address = packet.next_hop_address();
fn send_without_response(&self, packet: Traced<MixPacket>) -> io::Result<()> {
let address = packet.inner.next_hop_address();
trace!("Sending packet to {address}");
// capture the sample state before the trace is moved into `queued`
let sampled = packet.trace.is_sampled();
// TODO: optimisation for the future: rather than constantly using legacy encoding,
// use the mix packet type / flags to pick encoding per packet
let framed_packet =
FramedNymPacket::from_mix_packet(packet, self.config.use_legacy_packet_encoding);
let legacy = self.config.use_legacy_packet_encoding;
let queued = packet.map(|p| FramedNymPacket::from_mix_packet(p, legacy));
let Some(sender) = self.active_connections.get_mut(&address) else {
// there was never a connection to begin with
@@ -435,7 +627,7 @@ impl SendWithoutResponse for Client {
result = "not_connected",
"establishing initial connection to {address}"
);
self.make_connection(address, framed_packet);
self.make_connection(address, queued);
return Err(io::Error::new(
io::ErrorKind::NotConnected,
"connection is in progress",
@@ -446,7 +638,12 @@ impl SendWithoutResponse for Client {
let channel_available = sender.channel.capacity();
let channel_used = channel_capacity - channel_available;
let sending_res = sender.channel.try_send(framed_packet);
// record how full this peer's egress buffer was (sampled packets only, to bound cost)
if sampled {
crate::metrics::observe_egress_buffer_fill(channel_used, channel_capacity);
}
let sending_res = sender.channel.try_send(queued);
drop(sender);
sending_res.map_err(|err| {
@@ -501,6 +698,8 @@ mod tests {
initial_connection_timeout: Duration::from_millis(1_500),
maximum_connection_buffer_size: 128,
use_legacy_packet_encoding: false,
connection_idle_timeout: Duration::from_secs(300),
connection_write_timeout: Duration::from_millis(500),
},
NoiseConfig::new(
Arc::new(x25519::KeyPair::new(&mut rng)),
@@ -547,7 +746,7 @@ mod tests {
active: &ActiveConnections,
addr: SocketAddr,
token: Arc<()>,
) -> mpsc::Receiver<FramedNymPacket> {
) -> mpsc::Receiver<Traced<FramedNymPacket>> {
let (tx, rx) = mpsc::channel(1);
active.insert(addr, ConnectionSender::new(tx, token));
rx
@@ -610,7 +809,14 @@ mod tests {
let conn = Framed::new(a, NymCodec);
let (_tx, rx) = mpsc::channel(1);
let task = tokio::spawn(run_io_loop(conn, ReceiverStream::new(rx), test_addr()));
// idle reaping disabled so only the peer-close path is exercised
let task = tokio::spawn(run_io_loop(
conn,
ReceiverStream::new(rx),
test_addr(),
Duration::ZERO,
Duration::ZERO,
));
// Simulate peer closing both directions of the connection.
drop(b);
@@ -627,7 +833,13 @@ mod tests {
let conn = Framed::new(a, NymCodec);
let (tx, rx) = mpsc::channel(1);
let task = tokio::spawn(run_io_loop(conn, ReceiverStream::new(rx), test_addr()));
let task = tokio::spawn(run_io_loop(
conn,
ReceiverStream::new(rx),
test_addr(),
Duration::ZERO,
Duration::ZERO,
));
drop(tx);
@@ -636,4 +848,32 @@ mod tests {
.expect("io_loop must exit when the upstream sender is dropped")
.expect("io_loop task must not panic");
}
#[tokio::test(start_paused = true)]
async fn io_loop_closes_idle_connection() {
// With no packets sent and the peer still connected, the idle timeout must eventually
// close the connection so the task/socket don't linger forever. The paused clock is
// virtual - it auto-advances to the next timer, so this completes instantly despite the
// durations below (no real waiting).
let (a, _b) = tokio::io::duplex(64);
let conn = Framed::new(a, NymCodec);
// keep the sender alive so the sender-dropped path can't fire instead
let (_tx, rx) = mpsc::channel(1);
let idle_timeout = Duration::from_millis(50);
let task = tokio::spawn(run_io_loop(
conn,
ReceiverStream::new(rx),
test_addr(),
idle_timeout,
Duration::ZERO,
));
// auto-advance fires the nearest timer (the 50ms idle deadline, sooner than this 500ms
// guard) once the task is otherwise idle, reaping the connection
tokio::time::timeout(Duration::from_millis(500), task)
.await
.expect("io_loop must close the connection after the idle timeout")
.expect("io_loop task must not panic");
}
}
@@ -1,6 +1,7 @@
// Copyright 2021 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use crate::metrics::PacketTrace;
use futures::channel::mpsc;
use futures::channel::mpsc::SendError;
use nym_sphinx::forwarding::packet::MixPacket;
@@ -43,6 +44,9 @@ pub struct PacketToForward {
pub packet: MixPacket,
pub forward_delay_target: Option<Instant>,
pub network_monitor_packet: bool,
/// Latency breadcrumb started at packet receive; stamped as the packet moves through the
/// forwarder and egress stages. `PacketTrace::Off` for untraced packets (e.g. acks).
pub trace: PacketTrace,
}
impl PacketToForward {
@@ -50,15 +54,17 @@ impl PacketToForward {
packet: MixPacket,
forward_delay_target: Option<Instant>,
network_monitor_packet: bool,
trace: PacketTrace,
) -> Self {
PacketToForward {
packet,
forward_delay_target,
network_monitor_packet,
trace,
}
}
pub fn client_packet_without_delay(packet: MixPacket) -> Self {
Self::new(packet, None, false)
Self::new(packet, None, false, PacketTrace::Off)
}
}
@@ -4,6 +4,7 @@
#[cfg(feature = "client")]
pub mod client;
pub mod forwarder;
pub mod metrics;
#[cfg(feature = "client")]
pub use client::{Client, Config, SendWithoutResponse};
@@ -0,0 +1,311 @@
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use strum::{AsRefStr, EnumIter, EnumProperty, IntoEnumIterator};
use tokio::time::Instant;
/// Histogram buckets (seconds) for per-stage and total packet latency: exponential, ~100us .. ~6.5s.
/// Shared by every latency stage so the waterfall is directly comparable; the top finite bucket is
/// intentionally high so a rare multi-second processing spike is measured with magnitude rather than
/// being clipped into the `+Inf` overflow.
const STAGE_LATENCY_BUCKETS: [f64; 17] = [
0.0001, 0.0002, 0.0004, 0.0008, 0.0016, 0.0032, 0.0064, 0.0128, 0.0256, 0.0512, 0.1024, 0.2048,
0.4096, 0.8192, 1.6384, 3.2768, 6.5536,
];
/// Count buckets (1 .. MAX_DRAIN_BATCH) for the forwarder drain-batch-size histogram.
const DRAIN_BATCH_BUCKETS: [f64; 9] = [1.0, 2.0, 4.0, 8.0, 16.0, 32.0, 64.0, 128.0, 256.0];
/// Fill-ratio buckets (used/capacity) for the per-connection egress buffer. A ratio near 1.0 means
/// the buffer is close to full and packets to that peer are about to be dropped.
const EGRESS_FILL_BUCKETS: [f64; 9] = [0.05, 0.1, 0.25, 0.5, 0.75, 0.9, 0.95, 0.99, 1.0];
/// Every histogram this crate emits, defined in one place. `AsRefStr` (`#[strum(to_string=...)]`)
/// gives the prometheus metric name - the bare `mixnet_packet_*` family, with no per-crate prefix
/// since this is a shared library writing straight to the process-global registry. The `help` prop
/// gives the description and [`MixnetMetric::buckets`] gives the bucket layout.
///
/// Register the whole family at boot with [`register_all`]. Latency-stage variants are observed via
/// the [`PacketTrace`] stopwatch; the auxiliary variants via the `observe_*` helpers. (Passing an
/// auxiliary variant to `PacketTrace::record` is meaningless but harmless.)
#[derive(Clone, Copy, EnumIter, AsRefStr, EnumProperty)]
pub enum MixnetMetric {
// ----- latency stages: the per-packet waterfall, recorded via `PacketTrace` -----
/// receive -> sphinx unwrap (partial: shared secret + header MAC)
#[strum(to_string = "mixnet_packet_stage_unwrap_seconds")]
#[strum(props(help = "Seconds spent unwrapping a received sphinx packet"))]
Unwrap,
/// unwrap -> replay-check + finalise (includes the deferral wait)
#[strum(to_string = "mixnet_packet_stage_replay_check_seconds")]
#[strum(props(
help = "Seconds from partial-unwrap to replay-check + finalise (includes the deferral wait)"
))]
ReplayCheck,
/// wait in the ingress -> forwarder channel
#[strum(to_string = "mixnet_packet_stage_forwarder_queue_seconds")]
#[strum(props(
help = "Seconds a forwarded packet waited in the ingress-to-forwarder channel"
))]
ForwarderQueue,
/// the (intended) mix delay
#[strum(to_string = "mixnet_packet_stage_delay_queue_seconds")]
#[strum(props(help = "Seconds a forwarded packet spent in the (intended) mix delay queue"))]
DelayQueue,
/// diagnostic overlay on `DelayQueue`: how late beyond the target release the packet was
/// actually forwarded (delay-queue scheduling/retrieval overhead, measured vs the deadline)
#[strum(to_string = "mixnet_packet_stage_delay_queue_overrun_seconds")]
#[strum(props(
help = "Seconds a delayed packet was forwarded beyond its target release time (delay-queue scheduling/retrieval overhead)"
))]
DelayQueueOverrun,
/// wait in the per-connection egress buffer
#[strum(to_string = "mixnet_packet_stage_egress_queue_seconds")]
#[strum(props(
help = "Seconds a forwarded packet waited in the per-connection egress buffer"
))]
EgressQueue,
/// flushing the packet batch to the socket
#[strum(to_string = "mixnet_packet_stage_socket_write_seconds")]
#[strum(props(help = "Seconds spent flushing a forwarded packet batch to the socket"))]
SocketWrite,
/// end-to-end: receive -> socket write
#[strum(to_string = "mixnet_packet_total_latency_seconds")]
#[strum(props(help = "Total in-node latency of a forwarded packet, receive to socket write"))]
Total,
// ----- auxiliary histograms: observed directly, not part of the latency waterfall -----
/// number of packets the forwarder drained from the ingress channel per wakeup
#[strum(to_string = "mixnet_packet_forwarder_drain_batch_size")]
#[strum(props(
help = "Number of ingress packets the forwarder drained per select! wakeup (batch size)"
))]
ForwarderDrainBatchSize,
/// number of expired packets the forwarder drained from the delay queue per wakeup
#[strum(to_string = "mixnet_packet_forwarder_delay_drain_batch_size")]
#[strum(props(
help = "Number of expired delay-queue packets the forwarder drained per select! wakeup (batch size)"
))]
ForwarderDelayDrainBatchSize,
/// per-connection egress buffer occupancy (used/capacity) at send time
#[strum(to_string = "mixnet_packet_egress_buffer_fill_ratio")]
#[strum(props(
help = "Per-connection egress buffer fill ratio (used/capacity) sampled at packet send time"
))]
EgressBufferFillRatio,
}
impl MixnetMetric {
/// Histogram bucket layout for this metric.
fn buckets(&self) -> &'static [f64] {
match self {
MixnetMetric::ForwarderDrainBatchSize | MixnetMetric::ForwarderDelayDrainBatchSize => {
&DRAIN_BATCH_BUCKETS
}
MixnetMetric::EgressBufferFillRatio => &EGRESS_FILL_BUCKETS,
// every latency stage shares the seconds buckets
_ => &STAGE_LATENCY_BUCKETS,
}
}
}
/// Pre-register every histogram (at zero) into the global metrics registry so the whole
/// `mixnet_packet_*` family is present on the prometheus endpoint from boot, before anything has
/// been observed. Idempotent.
pub fn register_all() {
let registry = nym_metrics::metrics_registry();
for metric in MixnetMetric::iter() {
registry.register_histogram(
metric.as_ref(),
metric.get_str("help"),
Some(metric.buckets()),
);
}
}
/// Observe a value into a metric's histogram in the process-global registry.
fn observe(metric: MixnetMetric, value: f64) {
nym_metrics::metrics_registry().maybe_register_and_add_to_histogram(
metric.as_ref(),
value,
Some(metric.buckets()),
metric.get_str("help"),
);
}
/// Observe how many ingress-channel packets the forwarder drained in a single wakeup.
pub fn observe_drain_batch_size(batch_size: usize) {
observe(MixnetMetric::ForwarderDrainBatchSize, batch_size as f64);
}
/// Observe how many expired delay-queue packets the forwarder drained in a single wakeup.
pub fn observe_delay_drain_batch_size(batch_size: usize) {
observe(
MixnetMetric::ForwarderDelayDrainBatchSize,
batch_size as f64,
);
}
/// Observe how full a per-connection egress buffer was when a packet was queued for it.
pub fn observe_egress_buffer_fill(used: usize, capacity: usize) {
if capacity == 0 {
return;
}
observe(
MixnetMetric::EgressBufferFillRatio,
used as f64 / capacity as f64,
);
}
/// A lightweight per-packet stopwatch for attributing forwarding latency to pipeline
/// stages. Unsampled packets carry the `Off` variant and do zero clock reads, so the only
/// cost on the hot path is moving a small `Copy` value and a branch.
#[derive(Clone, Copy)]
pub enum PacketTrace {
Off,
On {
received_at: Instant,
stage_at: Instant,
},
}
impl PacketTrace {
/// Begin tracing. Reads the clock only for sampled packets.
pub fn start(sampled: bool) -> Self {
if sampled {
let now = Instant::now();
PacketTrace::On {
received_at: now,
stage_at: now,
}
} else {
PacketTrace::Off
}
}
/// Whether this packet is being traced (sampled).
pub fn is_sampled(&self) -> bool {
matches!(self, PacketTrace::On { .. })
}
/// Seconds spent in the stage just completed, advancing the cursor to now.
/// Returns `None` for unsampled packets.
fn lap(&mut self) -> Option<f64> {
match self {
PacketTrace::Off => None,
PacketTrace::On { stage_at, .. } => {
let now = Instant::now();
let secs = now.duration_since(*stage_at).as_secs_f64();
*stage_at = now;
Some(secs)
}
}
}
/// Seconds since tracing began (i.e. since the packet was received), or `None` if unsampled.
fn total(&self) -> Option<f64> {
match self {
PacketTrace::Off => None,
PacketTrace::On { received_at, .. } => {
Some(Instant::now().duration_since(*received_at).as_secs_f64())
}
}
}
/// Close out the stage just completed: lap the timer and, only if the packet is sampled,
/// observe `stage`'s latency histogram.
pub fn record(&mut self, stage: MixnetMetric) {
if let Some(secs) = self.lap() {
observe(stage, secs);
}
}
/// Observe the end-to-end [`MixnetMetric::Total`] latency (since receive) if sampled. Unlike
/// [`PacketTrace::record`] this does not lap, so it can be called at the very end.
pub fn record_total(&self) {
if let Some(secs) = self.total() {
observe(MixnetMetric::Total, secs);
}
}
/// Observe an explicit `secs` value for `stage` if the packet is sampled, without lapping the
/// stage cursor. For diagnostics that don't fit the sequential waterfall (e.g. delay-queue
/// overrun, measured against the target deadline rather than the previous stage).
pub fn record_value(&self, stage: MixnetMetric, secs: f64) {
if matches!(self, PacketTrace::On { .. }) {
observe(stage, secs);
}
}
}
/// A value paired with its in-flight latency trace, so the trace rides along as the value is
/// moved between pipeline stages (and transformed via [`Traced::map`]). Used wherever a packet
/// crosses a queue/channel: replay batch, delay queue, egress channel.
pub struct Traced<T> {
pub inner: T,
pub trace: PacketTrace,
}
impl<T> Traced<T> {
pub fn new(inner: T, trace: PacketTrace) -> Self {
Traced { inner, trace }
}
/// Transform the carried value, keeping the same trace.
pub fn map<U>(self, f: impl FnOnce(T) -> U) -> Traced<U> {
Traced {
inner: f(self.inner),
trace: self.trace,
}
}
/// Record the stage just completed for the carried trace (see [`PacketTrace::record`]).
pub fn record(&mut self, stage: MixnetMetric) {
self.trace.record(stage)
}
/// Observe an explicit value for the carried trace (see [`PacketTrace::record_value`]).
pub fn record_value(&self, stage: MixnetMetric, secs: f64) {
self.trace.record_value(stage, secs)
}
}
#[cfg(test)]
mod tests {
use super::*;
// guards that AsRefStr honours `#[strum(to_string = ...)]` (rather than falling back to the
// variant name), that every metric is in the `mixnet_packet_*` family, and carries a help
// string, and that each metric resolves to a bucket layout.
#[test]
fn every_metric_has_a_mixnet_packet_name_help_and_buckets() {
for metric in MixnetMetric::iter() {
assert!(
metric.as_ref().starts_with("mixnet_packet_"),
"unexpected metric name: {}",
metric.as_ref()
);
assert!(
metric.get_str("help").is_some(),
"missing help for {}",
metric.as_ref()
);
assert!(
!metric.buckets().is_empty(),
"missing buckets for {}",
metric.as_ref()
);
}
assert_eq!(
MixnetMetric::Unwrap.as_ref(),
"mixnet_packet_stage_unwrap_seconds"
);
assert_eq!(
MixnetMetric::Total.as_ref(),
"mixnet_packet_total_latency_seconds"
);
assert_eq!(
MixnetMetric::ForwarderDrainBatchSize.as_ref(),
"mixnet_packet_forwarder_drain_batch_size"
);
}
}
@@ -20,7 +20,7 @@ use nym_api_requests::models::network_monitor::{
};
use nym_api_requests::models::node_families::NodeFamily;
use nym_api_requests::models::{
AnnotationResponseV1, ApiHealthResponse, BinaryBuildInformationOwned,
AnnotationResponseV1, AnnotationResponseV2, ApiHealthResponse, BinaryBuildInformationOwned,
ChainBlocksStatusResponse, ChainStatusResponse, KeyRotationInfoResponse,
NodePerformanceResponse, NodeRefreshBody, NymNodeDescriptionV1, NymNodeDescriptionV2,
PerformanceHistoryResponse, RewardedSetResponse, SignerInformationResponse,
@@ -1033,6 +1033,22 @@ pub trait NymApiClientExt: ApiClient {
.await
}
async fn get_node_annotation_v2(
&self,
node_id: NodeId,
) -> Result<AnnotationResponseV2, NymAPIError> {
self.get_json(
&[
routes::V2_API_VERSION,
routes::NYM_NODES_ROUTES,
routes::NYM_NODES_ANNOTATION,
&node_id.to_string(),
],
NO_PARAMS,
)
.await
}
#[deprecated]
async fn get_mixnode_avg_uptime(&self, mix_id: NodeId) -> Result<UptimeResponse, NymAPIError> {
self.get_json(
@@ -7,12 +7,12 @@ use crate::nyxd::error::NyxdError;
use crate::nyxd::CosmWasmClient;
use async_trait::async_trait;
use cosmrs::AccountId;
use nym_mixnet_contract_common::NodeId;
use serde::Deserialize;
use nym_mixnet_contract_common::NodeId;
pub use nym_node_families_contract_common::{
msg::QueryMsg as NodeFamiliesQueryMsg, AllFamilyMembersPagedResponse,
AllPastFamilyInvitationsPagedResponse, FamiliesPagedResponse, FamilyMemberRecord,
AllPastFamilyInvitationsPagedResponse, Config, FamiliesPagedResponse, FamilyMemberRecord,
FamilyMembersPagedResponse, GlobalPastFamilyInvitationCursor, NodeFamily,
NodeFamilyByNameResponse, NodeFamilyByOwnerResponse, NodeFamilyId,
NodeFamilyMembershipResponse, NodeFamilyResponse, PastFamilyInvitation,
@@ -35,6 +35,11 @@ pub trait NodeFamiliesQueryClient {
where
for<'a> T: Deserialize<'a>;
async fn get_config(&self) -> Result<Config, NyxdError> {
self.query_node_families_contract(NodeFamiliesQueryMsg::GetConfig {})
.await
}
async fn get_family_by_id(
&self,
family_id: NodeFamilyId,
@@ -360,6 +365,7 @@ mod tests {
msg: NodeFamiliesQueryMsg,
) {
match msg {
NodeFamiliesQueryMsg::GetConfig {} => client.get_config().ignore(),
NodeFamiliesQueryMsg::GetFamilyById { family_id } => {
client.get_family_by_id(family_id).ignore()
}
@@ -96,6 +96,10 @@ pub enum ExecuteMsg {
#[cw_serde]
#[cfg_attr(feature = "schema", derive(cosmwasm_schema::QueryResponses))]
pub enum QueryMsg {
/// Retrieve current contract configuration values
#[cfg_attr(feature = "schema", returns(Config))]
GetConfig {},
/// Look up a single family by its id.
#[cfg_attr(feature = "schema", returns(NodeFamilyResponse))]
GetFamilyById { family_id: NodeFamilyId },
@@ -61,9 +61,12 @@ pub struct NodeFamily {
/// A pending invitation for a node to join a particular family.
///
/// Invitations are stored until they are accepted, rejected, revoked, or until the
/// chain advances past `expires_at` (in which case they remain in storage but are
/// treated as inert — there is no background process clearing expired invitations).
/// Invitations are stored until they are accepted, rejected, or revoked. Once the
/// chain advances past `expires_at` an invitation becomes inert but stays in storage
/// — there is no background process clearing expired invitations. A timed-out
/// invitation is cleared either when explicitly revoked/rejected, or when the family
/// issues a fresh invitation for the same node, which archives the stale one as
/// `Expired` and supersedes it.
#[cw_serde]
pub struct FamilyInvitation {
/// The family that issued the invitation.
@@ -107,8 +110,10 @@ pub struct PastFamilyMember {
/// Terminal status for an invitation that has been moved out of the pending set.
///
/// Note: timed-out invitations are not represented here — they are simply left in
/// the pending set (see `FamilyInvitation::expires_at`).
/// Note: an invitation that merely times out is **not** archived here on its own —
/// it is left inert in the pending set (see `FamilyInvitation::expires_at`). It only
/// reaches `Expired` if the family issues a fresh invitation for the same node, which
/// supersedes and archives the stale one.
#[cw_serde]
pub enum FamilyInvitationStatus {
/// Still awaiting a response. Recorded with a timestamp for completeness even
@@ -121,11 +126,16 @@ pub enum FamilyInvitationStatus {
/// The family revoked the invitation at the given timestamp before it could
/// be accepted or rejected.
Revoked { at: u64 },
/// The invitation had already expired and was superseded by a fresh invitation
/// for the same node from the same family, issued at the given timestamp. This is
/// the only path that archives a timed-out invitation.
Expired { at: u64 },
}
/// Historical record of an invitation that has reached a terminal state
/// (`Accepted`, `Rejected`, or `Revoked`). Timed-out invitations are **not**
/// archived here — they remain in the pending map until explicitly cleared.
/// (`Accepted`, `Rejected`, `Revoked`, or `Expired`). A timed-out invitation is
/// archived here only when a fresh invitation for the same node supersedes it
/// (status `Expired`); otherwise it stays in the pending map until explicitly cleared.
#[cw_serde]
pub struct PastFamilyInvitation {
/// The original invitation as it was issued.
+2 -4
View File
@@ -24,10 +24,8 @@ pub const PERFORMANCE_CONTRACT_ADDRESS: &str = "";
pub const NETWORK_MONITORS_CONTRACT_ADDRESS: &str =
"n1m3a2ltkjqud8mkmrpqvgllrtv2p4r6js6qwl7p8cqkzrq8jg6e2qwqgl8z";
// \/ TODO: this has to be updated once the contract is deployed
pub const NODE_FAMILIES_CONTRACT_ADDRESS: &str = "";
// /\ TODO: this has to be updated once the contract is deployed
pub const NODE_FAMILIES_CONTRACT_ADDRESS: &str =
"n1na0vys0z077hq3zrz6pfea85zgv8ks3t5zysdt6y38c87q045hnsyf2g5x";
pub const ECASH_CONTRACT_ADDRESS: &str =
"n1r7s6aksyc6pqardx88k3rkgfagwvj4z4zum9mmz2sfk3zm2mha0sd4dnun";
pub const GROUP_CONTRACT_ADDRESS: &str =
@@ -73,6 +73,27 @@ impl<T> NonExhaustiveDelayQueue<T> {
pub fn is_empty(&self) -> bool {
self.inner.is_empty()
}
/// Pop the next *already-expired* item without awaiting, or `None` if nothing is ready right
/// now (the queue is empty, or its earliest item has not reached its deadline yet). Lets a
/// caller drain a burst of simultaneously-expired items in a tight loop without yielding.
///
/// It polls the inner queue with a **no-op waker**, so a not-yet-due (`None`) result registers
/// no real wakeup. This is therefore sound ONLY when the caller subsequently polls the
/// [`Stream`] impl (`.next().await`) before parking the task - that re-arms the timer against
/// the task's real waker, superseding the no-op one. The intended use is "drain the extra ready
/// items right after `.next()` yielded one, in a loop that returns to `.next().await`". Calling
/// it as the last thing before suspending would drop the wakeup (same caveat as
/// `futures::FutureExt::now_or_never`).
pub fn try_next_expired(&mut self) -> Option<Expired<T>> {
let mut cx = Context::from_waker(Waker::noop());
match Pin::new(&mut self.inner).poll_expired(&mut cx) {
// a ready-expired item, or `None` because the queue is empty
Poll::Ready(maybe_item) => maybe_item,
// queue is non-empty but nothing is due yet
Poll::Pending => None,
}
}
}
impl<T> Default for NonExhaustiveDelayQueue<T> {
+1 -1
View File
@@ -1,7 +1,7 @@
[package]
name = "nym-kkt"
description = "Key transport protocol for the Nym network"
version = "1.21.0"
version = "1.21.1"
authors = ["Georgio Nicolas <georgio@nymtech.net>"]
edition = { workspace = true }
license.workspace = true
+70 -6
View File
@@ -10,6 +10,7 @@ use crate::{Any, MessageRegistry, ParsedTransactionDetails, default_message_regi
use futures::StreamExt;
use futures::future::join3;
use std::collections::BTreeMap;
use std::future::Future;
use std::sync::Arc;
use tendermint::{Block, Hash};
use tendermint_rpc::endpoint::{block, block_results, tx, validators};
@@ -18,6 +19,38 @@ use tokio::sync::Mutex;
use tracing::{debug, instrument, warn};
use url::Url;
const MAX_QUERY_ATTEMPTS: usize = 3;
/// Runs `op` up to `max_attempts` times (at least once), returning the first success or, on full
/// exhaustion, the last error encountered.
async fn query_with_retries<F, Fut, T>(mut max_attempts: usize, op: F) -> Result<T, ScraperError>
where
F: Fn() -> Fut,
Fut: Future<Output = Result<T, ScraperError>>,
{
if max_attempts == 0 {
max_attempts = 1;
}
let mut last_err = None;
for i in 0..max_attempts {
match op().await {
Ok(result) => return Ok(result),
Err(err) => {
debug!("query failed, retrying {}/{max_attempts} - {err}", i + 1);
last_err = Some(err);
}
}
tokio::time::sleep(std::time::Duration::from_millis(300 * (i as u64 + 1))).await;
}
// SAFETY: max_attempts >= 1, so we only reach here after at least one recorded failure
#[allow(clippy::unwrap_used)]
Err(last_err.unwrap())
}
#[derive(Debug, Clone, Copy)]
pub struct RetrievalConfig {
pub get_validators: bool,
@@ -173,13 +206,24 @@ impl RpcClient {
})
}
#[instrument(skip(self), err(Display))]
async fn get_block_results_with_retries(
&self,
height: u32,
max_attempts: usize,
) -> Result<block_results::Response, ScraperError> {
query_with_retries(max_attempts, || self.get_block_results(height)).await
}
async fn maybe_get_block_results(
&self,
height: u32,
retrieve: bool,
) -> Result<Option<block_results::Response>, ScraperError> {
if retrieve {
self.get_block_results(height).await.map(Some)
self.get_block_results_with_retries(height, MAX_QUERY_ATTEMPTS)
.await
.map(Some)
} else {
Ok(None)
}
@@ -219,8 +263,6 @@ impl RpcClient {
// "Data is just a wrapper for a list of transactions, where transactions are arbitrary byte arrays"
// source: https://github.com/tendermint/spec/blob/d46cd7f573a2c6a2399fcab2cde981330aa63f37/spec/core/data_structures.md#data
//
// I hate that zip as much as you, dear reader, but for some reason the compiler didn't let me remove the `move`
futures::stream::iter(
raw.iter()
.map(tx_hash)
@@ -228,12 +270,14 @@ impl RpcClient {
.zip(std::iter::repeat(ordered_results.clone())),
)
.for_each_concurrent(4, |((id, tx_hash), ordered_results)| async move {
let res = self.get_transaction_result(tx_hash).await;
let res = self
.get_transaction_result_with_retries(tx_hash, MAX_QUERY_ATTEMPTS)
.await;
ordered_results.lock().await.insert(id, res);
})
.await;
// safety the futures have completed so we MUST have the only arc reference
// safety: the futures have completed so we MUST have the only arc reference
#[allow(clippy::unwrap_used)]
let inner = Arc::into_inner(ordered_results).unwrap().into_inner();
@@ -266,6 +310,15 @@ impl RpcClient {
})
}
#[instrument(skip(self, tx_hash), fields(tx_hash = %tx_hash), err(Display))]
async fn get_transaction_result_with_retries(
&self,
tx_hash: Hash,
max_attempts: usize,
) -> Result<tx::Response, ScraperError> {
query_with_retries(max_attempts, || self.get_transaction_result(tx_hash)).await
}
#[instrument(skip(self))]
pub async fn get_validators_details(
&self,
@@ -282,13 +335,24 @@ impl RpcClient {
})
}
#[instrument(skip(self), err(Display))]
async fn get_validators_details_with_retries(
&self,
height: u32,
max_attempts: usize,
) -> Result<validators::Response, ScraperError> {
query_with_retries(max_attempts, || self.get_validators_details(height)).await
}
async fn maybe_get_validators_details(
&self,
height: u32,
retrieve: bool,
) -> Result<Option<validators::Response>, ScraperError> {
if retrieve {
self.get_validators_details(height).await.map(Some)
self.get_validators_details_with_retries(height, MAX_QUERY_ATTEMPTS)
.await
.map(Some)
} else {
Ok(None)
}
+1 -1
View File
@@ -19,7 +19,7 @@ use tracing::{debug, trace, warn};
pub use crate::node::{EntryDetails, RoutingNode, SupportedRoles};
pub use error::NymTopologyError;
pub use nym_mixnet_contract_common::nym_node::Role;
pub use nym_mixnet_contract_common::{EpochRewardedSet, NodeId};
pub use nym_mixnet_contract_common::{EpochRewardedSet, NodeId, RewardedSet};
pub use rewarded_set::CachedEpochRewardedSet;
pub mod error;
+15 -1
View File
@@ -8,6 +8,7 @@ use nym_mixnet_contract_common::NodeId;
use nym_sphinx_addressing::nodes::NymNodeRoutingAddress;
use nym_sphinx_types::Node as SphinxNode;
use serde::{Deserialize, Serialize};
use std::fmt::Debug;
use std::net::{IpAddr, SocketAddr};
use thiserror::Error;
@@ -45,7 +46,7 @@ impl From<DeclaredRolesV1> for SupportedRoles {
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[derive(Clone, Serialize, Deserialize)]
pub struct RoutingNode {
pub node_id: NodeId,
@@ -58,6 +59,19 @@ pub struct RoutingNode {
pub supported_roles: SupportedRoles,
}
impl Debug for RoutingNode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RoutingNode")
.field("node_id", &self.node_id)
.field("mix_host", &self.mix_host)
.field("entry", &self.entry)
.field("identity_key", &self.identity_key.to_base58_string())
.field("sphinx_key", &self.sphinx_key.to_base58_string())
.field("supported_roles", &self.supported_roles)
.finish()
}
}
impl RoutingNode {
pub fn ws_entry_address_tls(&self) -> Option<String> {
let entry = self.entry.as_ref()?;
@@ -3,4 +3,8 @@ import type { DecCoin } from "./DecCoin";
import type { DelegationEvent } from "./DelegationEvent";
import type { NodeCostParams } from "./MixNodeCostParams";
export type DelegationWithEverything = { owner: string, mix_id: number, node_identity: string, amount: DecCoin, accumulated_by_delegates: DecCoin | null, accumulated_by_operator: DecCoin | null, block_height: bigint, delegated_on_iso_datetime: string | null, cost_params: NodeCostParams | null, avg_uptime_percent: number | null, stake_saturation: string | null, uses_vesting_contract_tokens: boolean, unclaimed_rewards: DecCoin | null, errors: string | null, pending_events: Array<DelegationEvent>, mixnode_is_unbonding: boolean | null, };
export type DelegationWithEverything = { owner: string, mix_id: number, node_identity: string,
/**
* Prior node identity when `node_identity` is synthetic (registry miss after unbond).
*/
historical_node_identity: string | null, amount: DecCoin, accumulated_by_delegates: DecCoin | null, accumulated_by_operator: DecCoin | null, block_height: bigint, delegated_on_iso_datetime: string | null, cost_params: NodeCostParams | null, avg_uptime_percent: number | null, stake_saturation: string | null, uses_vesting_contract_tokens: boolean, unclaimed_rewards: DecCoin | null, errors: string | null, pending_events: Array<DelegationEvent>, mixnode_is_unbonding: boolean | null, };
+2
View File
@@ -49,6 +49,8 @@ pub struct DelegationWithEverything {
pub owner: String,
pub mix_id: NodeId,
pub node_identity: String,
/// Prior node identity when `node_identity` is synthetic (registry miss after unbond).
pub historical_node_identity: Option<String>,
pub amount: DecCoin,
pub accumulated_by_delegates: Option<DecCoin>,
pub accumulated_by_operator: Option<DecCoin>,
@@ -14,6 +14,7 @@ use tokio::sync::mpsc::Receiver;
#[derive(Hash, PartialOrd, PartialEq, Clone, Debug, Eq, Copy)]
pub enum PeerControlRequestTypeV2 {
AddPeer,
UpdatePeerPsk,
RemovePeer,
QueryPeer,
GetClientBandwidthByKey,
@@ -26,6 +27,7 @@ impl From<&PeerControlRequest> for PeerControlRequestTypeV2 {
fn from(req: &PeerControlRequest) -> Self {
match req {
PeerControlRequest::AddPeer { .. } => PeerControlRequestTypeV2::AddPeer,
PeerControlRequest::UpdatePeerPsk { .. } => PeerControlRequestTypeV2::UpdatePeerPsk,
PeerControlRequest::PreAllocateIpPair { .. } => PeerControlRequestTypeV2::AddPeer,
PeerControlRequest::RemovePeer { .. } => PeerControlRequestTypeV2::RemovePeer,
PeerControlRequest::QueryPeer { .. } => PeerControlRequestTypeV2::QueryPeer,
@@ -115,6 +117,15 @@ impl MockPeerControllerV2 {
)
.unwrap();
}
PeerControlRequest::UpdatePeerPsk { response_tx, .. } => {
response_tx
.send(
*response
.downcast()
.expect("registered response has mismatched type"),
)
.unwrap();
}
PeerControlRequest::PreAllocateIpPair { response_tx, .. } => {
response_tx
.send(
@@ -71,6 +71,7 @@ impl From<&Key> for KeyWrapper {
#[derive(Hash, PartialOrd, PartialEq, Clone, Debug, Eq)]
pub enum PeerControlRequestType {
AddPeer { public_key: KeyWrapper },
UpdatePeerPsk { peer_key: KeyWrapper },
AllocatePeerIpPair {},
ReleaseIpPair { ip_pair: IpPair },
RemovePeer { key: KeyWrapper },
@@ -86,6 +87,7 @@ impl PeerControlRequestType {
pub fn peer_key(&self) -> Option<KeyWrapper> {
match self {
PeerControlRequestType::AddPeer { public_key } => Some(public_key.clone()),
PeerControlRequestType::UpdatePeerPsk { peer_key } => Some(peer_key.clone()),
PeerControlRequestType::AllocatePeerIpPair {} => None,
PeerControlRequestType::ReleaseIpPair { .. } => None,
PeerControlRequestType::RemovePeer { key } => Some(key.clone()),
@@ -109,6 +111,11 @@ impl From<&PeerControlRequest> for PeerControlRequestType {
PeerControlRequest::AddPeer { peer, .. } => PeerControlRequestType::AddPeer {
public_key: (&peer.public_key).into(),
},
PeerControlRequest::UpdatePeerPsk { peer_key, .. } => {
PeerControlRequestType::UpdatePeerPsk {
peer_key: peer_key.into(),
}
}
PeerControlRequest::PreAllocateIpPair { .. } => {
PeerControlRequestType::AllocatePeerIpPair {}
}
@@ -271,6 +278,9 @@ impl MockPeerController {
}
response_tx.send_downcasted(response.content)
}
PeerControlRequest::UpdatePeerPsk { response_tx, .. } => {
response_tx.send_downcasted(response.content)
}
PeerControlRequest::PreAllocateIpPair { response_tx, .. } => {
response_tx.send_downcasted(response.content)
}
@@ -76,6 +76,12 @@ pub enum PeerControlRequest {
peer: Peer,
response_tx: oneshot::Sender<AddPeerControlResponse>,
},
/// Update PSK for an existing peer, without changing its IP allocation
UpdatePeerPsk {
peer_key: Key,
psk: Key,
response_tx: oneshot::Sender<UpdatePeerPskControlResponse>,
},
/// Attempt to allocate an IP pair from the pool
PreAllocateIpPair {
response_tx: oneshot::Sender<AllocatePeerControlResponse>,
@@ -118,6 +124,7 @@ pub enum PeerControlRequest {
}
pub type AddPeerControlResponse = Result<()>;
pub type UpdatePeerPskControlResponse = Result<()>;
pub type AllocatePeerControlResponse = Result<IpPair>;
pub type ReleaseIpPairControlResponse = Result<()>;
pub type RemovePeerControlResponse = Result<()>;
@@ -317,6 +324,50 @@ impl PeerController {
Ok(())
}
async fn handle_update_peer_psk_request(&mut self, peer_key: &Key, psk: Key) -> Result<()> {
// observation will get automatically added once dropped
let _metric_timer =
PROMETHEUS_METRICS.start_timer(PrometheusMetric::WireguardDefguardPeerPskUpdate);
nym_metrics::inc!("wg_peer_update_psk_attempts");
let Ok(Some(mut peer)) = self.handle_query_peer_by_key(peer_key).await else {
return Ok(());
};
let encoded_psk = psk.to_lower_hex();
peer.preshared_key = Some(psk);
// Account for bandwidth used so far *before* reconfiguring: `configure_peer`
// isn't guaranteed to preserve the kernel rx/tx counters, so fold the
// accrued bytes into the metrics first to avoid losing them on a reset.
if let Ok(host) = self.wg_api.read_interface_data() {
self.update_metrics(&host).await;
*self.host_information.write().await = host;
}
// Try to update WireGuard peer
if let Err(e) = self.wg_api.configure_peer(&peer) {
nym_metrics::inc!("wg_peer_update_psk_failed");
nym_metrics::inc!("wg_config_errors_total");
return Err(e.into());
};
// Persist the new PSK to disk so it survives a restart. Kernel-first: a
// failure here leaves the live session working, only risking drift on restart.
self.ecash_verifier
.storage()
.update_peer_psk(&peer_key.to_string(), Some(&encoded_psk))
.await?;
// Refresh again so the cached host information reflects the post-update state
if let Ok(host) = self.wg_api.read_interface_data() {
*self.host_information.write().await = host;
}
nym_metrics::inc!("wg_peer_update_psk_success");
Ok(())
}
/// Allocate IP pair from pool for a new peer registration
///
/// This only allocates IPs - the caller must handle database storage and
@@ -513,6 +564,15 @@ impl PeerController {
PeerControlRequest::AddPeer { peer, response_tx } => {
response_tx.send(self.handle_add_request(&peer).await).ok();
}
PeerControlRequest::UpdatePeerPsk {
peer_key,
psk,
response_tx,
} => {
response_tx
.send(self.handle_update_peer_psk_request(&peer_key, psk).await)
.ok();
}
PeerControlRequest::PreAllocateIpPair { response_tx } => {
response_tx.send(self.handle_ip_allocation_request()).ok();
}
+279 -267
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -87,7 +87,7 @@ cw3-flex-multisig = { version = "2.0.0", path = "multisig/cw3-flex-multisig" }
cw4-group = { version = "2.0.0", path = "multisig/cw4-group" }
nym-mixnet-contract = { version = "1.5.1", path = "mixnet" }
nym-vesting-contract = { version = "1.4.1", path = "vesting" }
node-families = { version = "0.1.0", path = "node-families" }
node-families = { version = "0.1.1", path = "node-families" }
[workspace.lints.clippy]
unwrap_used = "deny"
+1 -2
View File
@@ -1,7 +1,7 @@
[package]
name = "node-families"
description = "Nym Node Families contract"
version = "0.1.0"
version = "0.1.1"
authors.workspace = true
edition.workspace = true
license.workspace = true
@@ -25,7 +25,6 @@ cosmwasm-std = { workspace = true }
cw2 = { workspace = true }
cw-storage-plus = { workspace = true }
cw-controllers = { workspace = true }
serde = { workspace = true }
cosmwasm-schema = { workspace = true, optional = true }
cw-utils = { workspace = true }
@@ -1188,7 +1188,7 @@
"additionalProperties": false,
"definitions": {
"FamilyInvitation": {
"description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, revoked, or until the chain advances past `expires_at` (in which case they remain in storage but are treated as inert — there is no background process clearing expired invitations).",
"description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, or revoked. Once the chain advances past `expires_at` an invitation becomes inert but stays in storage — there is no background process clearing expired invitations. A timed-out invitation is cleared either when explicitly revoked/rejected, or when the family issues a fresh invitation for the same node, which archives the stale one as `Expired` and supersedes it.",
"type": "object",
"required": [
"expires_at",
@@ -1218,7 +1218,7 @@
"additionalProperties": false
},
"FamilyInvitationStatus": {
"description": "Terminal status for an invitation that has been moved out of the pending set.\n\nNote: timed-out invitations are not represented here — they are simply left in the pending set (see `FamilyInvitation::expires_at`).",
"description": "Terminal status for an invitation that has been moved out of the pending set.\n\nNote: an invitation that merely times out is **not** archived here on its own — it is left inert in the pending set (see `FamilyInvitation::expires_at`). It only reaches `Expired` if the family issues a fresh invitation for the same node, which supersedes and archives the stale one.",
"oneOf": [
{
"description": "Still awaiting a response. Recorded with a timestamp for completeness even though pending invitations live in a separate map.",
@@ -1315,11 +1315,35 @@
}
},
"additionalProperties": false
},
{
"description": "The invitation had already expired and was superseded by a fresh invitation for the same node from the same family, issued at the given timestamp. This is the only path that archives a timed-out invitation.",
"type": "object",
"required": [
"expired"
],
"properties": {
"expired": {
"type": "object",
"required": [
"at"
],
"properties": {
"at": {
"type": "integer",
"format": "uint64",
"minimum": 0.0
}
},
"additionalProperties": false
}
},
"additionalProperties": false
}
]
},
"PastFamilyInvitation": {
"description": "Historical record of an invitation that has reached a terminal state (`Accepted`, `Rejected`, or `Revoked`). Timed-out invitations are **not** archived here — they remain in the pending map until explicitly cleared.",
"description": "Historical record of an invitation that has reached a terminal state (`Accepted`, `Rejected`, `Revoked`, or `Expired`). A timed-out invitation is archived here only when a fresh invitation for the same node supersedes it (status `Expired`); otherwise it stays in the pending map until explicitly cleared.",
"type": "object",
"required": [
"invitation",
@@ -1388,7 +1412,7 @@
"additionalProperties": false,
"definitions": {
"FamilyInvitation": {
"description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, revoked, or until the chain advances past `expires_at` (in which case they remain in storage but are treated as inert — there is no background process clearing expired invitations).",
"description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, or revoked. Once the chain advances past `expires_at` an invitation becomes inert but stays in storage — there is no background process clearing expired invitations. A timed-out invitation is cleared either when explicitly revoked/rejected, or when the family issues a fresh invitation for the same node, which archives the stale one as `Expired` and supersedes it.",
"type": "object",
"required": [
"expires_at",
@@ -2073,7 +2097,7 @@
"additionalProperties": false,
"definitions": {
"FamilyInvitation": {
"description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, revoked, or until the chain advances past `expires_at` (in which case they remain in storage but are treated as inert — there is no background process clearing expired invitations).",
"description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, or revoked. Once the chain advances past `expires_at` an invitation becomes inert but stays in storage — there is no background process clearing expired invitations. A timed-out invitation is cleared either when explicitly revoked/rejected, or when the family issues a fresh invitation for the same node, which archives the stale one as `Expired` and supersedes it.",
"type": "object",
"required": [
"expires_at",
@@ -2103,7 +2127,7 @@
"additionalProperties": false
},
"FamilyInvitationStatus": {
"description": "Terminal status for an invitation that has been moved out of the pending set.\n\nNote: timed-out invitations are not represented here — they are simply left in the pending set (see `FamilyInvitation::expires_at`).",
"description": "Terminal status for an invitation that has been moved out of the pending set.\n\nNote: an invitation that merely times out is **not** archived here on its own — it is left inert in the pending set (see `FamilyInvitation::expires_at`). It only reaches `Expired` if the family issues a fresh invitation for the same node, which supersedes and archives the stale one.",
"oneOf": [
{
"description": "Still awaiting a response. Recorded with a timestamp for completeness even though pending invitations live in a separate map.",
@@ -2200,11 +2224,35 @@
}
},
"additionalProperties": false
},
{
"description": "The invitation had already expired and was superseded by a fresh invitation for the same node from the same family, issued at the given timestamp. This is the only path that archives a timed-out invitation.",
"type": "object",
"required": [
"expired"
],
"properties": {
"expired": {
"type": "object",
"required": [
"at"
],
"properties": {
"at": {
"type": "integer",
"format": "uint64",
"minimum": 0.0
}
},
"additionalProperties": false
}
},
"additionalProperties": false
}
]
},
"PastFamilyInvitation": {
"description": "Historical record of an invitation that has reached a terminal state (`Accepted`, `Rejected`, or `Revoked`). Timed-out invitations are **not** archived here — they remain in the pending map until explicitly cleared.",
"description": "Historical record of an invitation that has reached a terminal state (`Accepted`, `Rejected`, `Revoked`, or `Expired`). A timed-out invitation is archived here only when a fresh invitation for the same node supersedes it (status `Expired`); otherwise it stays in the pending map until explicitly cleared.",
"type": "object",
"required": [
"invitation",
@@ -2280,7 +2328,7 @@
"additionalProperties": false,
"definitions": {
"FamilyInvitation": {
"description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, revoked, or until the chain advances past `expires_at` (in which case they remain in storage but are treated as inert — there is no background process clearing expired invitations).",
"description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, or revoked. Once the chain advances past `expires_at` an invitation becomes inert but stays in storage — there is no background process clearing expired invitations. A timed-out invitation is cleared either when explicitly revoked/rejected, or when the family issues a fresh invitation for the same node, which archives the stale one as `Expired` and supersedes it.",
"type": "object",
"required": [
"expires_at",
@@ -2310,7 +2358,7 @@
"additionalProperties": false
},
"FamilyInvitationStatus": {
"description": "Terminal status for an invitation that has been moved out of the pending set.\n\nNote: timed-out invitations are not represented here — they are simply left in the pending set (see `FamilyInvitation::expires_at`).",
"description": "Terminal status for an invitation that has been moved out of the pending set.\n\nNote: an invitation that merely times out is **not** archived here on its own — it is left inert in the pending set (see `FamilyInvitation::expires_at`). It only reaches `Expired` if the family issues a fresh invitation for the same node, which supersedes and archives the stale one.",
"oneOf": [
{
"description": "Still awaiting a response. Recorded with a timestamp for completeness even though pending invitations live in a separate map.",
@@ -2407,11 +2455,35 @@
}
},
"additionalProperties": false
},
{
"description": "The invitation had already expired and was superseded by a fresh invitation for the same node from the same family, issued at the given timestamp. This is the only path that archives a timed-out invitation.",
"type": "object",
"required": [
"expired"
],
"properties": {
"expired": {
"type": "object",
"required": [
"at"
],
"properties": {
"at": {
"type": "integer",
"format": "uint64",
"minimum": 0.0
}
},
"additionalProperties": false
}
},
"additionalProperties": false
}
]
},
"PastFamilyInvitation": {
"description": "Historical record of an invitation that has reached a terminal state (`Accepted`, `Rejected`, or `Revoked`). Timed-out invitations are **not** archived here — they remain in the pending map until explicitly cleared.",
"description": "Historical record of an invitation that has reached a terminal state (`Accepted`, `Rejected`, `Revoked`, or `Expired`). A timed-out invitation is archived here only when a fresh invitation for the same node supersedes it (status `Expired`); otherwise it stays in the pending map until explicitly cleared.",
"type": "object",
"required": [
"invitation",
@@ -2634,7 +2706,7 @@
"additionalProperties": false,
"definitions": {
"FamilyInvitation": {
"description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, revoked, or until the chain advances past `expires_at` (in which case they remain in storage but are treated as inert — there is no background process clearing expired invitations).",
"description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, or revoked. Once the chain advances past `expires_at` an invitation becomes inert but stays in storage — there is no background process clearing expired invitations. A timed-out invitation is cleared either when explicitly revoked/rejected, or when the family issues a fresh invitation for the same node, which archives the stale one as `Expired` and supersedes it.",
"type": "object",
"required": [
"expires_at",
@@ -2724,7 +2796,7 @@
"additionalProperties": false,
"definitions": {
"FamilyInvitation": {
"description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, revoked, or until the chain advances past `expires_at` (in which case they remain in storage but are treated as inert — there is no background process clearing expired invitations).",
"description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, or revoked. Once the chain advances past `expires_at` an invitation becomes inert but stays in storage — there is no background process clearing expired invitations. A timed-out invitation is cleared either when explicitly revoked/rejected, or when the family issues a fresh invitation for the same node, which archives the stale one as `Expired` and supersedes it.",
"type": "object",
"required": [
"expires_at",
@@ -2814,7 +2886,7 @@
"additionalProperties": false,
"definitions": {
"FamilyInvitation": {
"description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, revoked, or until the chain advances past `expires_at` (in which case they remain in storage but are treated as inert — there is no background process clearing expired invitations).",
"description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, or revoked. Once the chain advances past `expires_at` an invitation becomes inert but stays in storage — there is no background process clearing expired invitations. A timed-out invitation is cleared either when explicitly revoked/rejected, or when the family issues a fresh invitation for the same node, which archives the stale one as `Expired` and supersedes it.",
"type": "object",
"required": [
"expires_at",
@@ -51,7 +51,7 @@
"additionalProperties": false,
"definitions": {
"FamilyInvitation": {
"description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, revoked, or until the chain advances past `expires_at` (in which case they remain in storage but are treated as inert — there is no background process clearing expired invitations).",
"description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, or revoked. Once the chain advances past `expires_at` an invitation becomes inert but stays in storage — there is no background process clearing expired invitations. A timed-out invitation is cleared either when explicitly revoked/rejected, or when the family issues a fresh invitation for the same node, which archives the stale one as `Expired` and supersedes it.",
"type": "object",
"required": [
"expires_at",
@@ -81,7 +81,7 @@
"additionalProperties": false
},
"FamilyInvitationStatus": {
"description": "Terminal status for an invitation that has been moved out of the pending set.\n\nNote: timed-out invitations are not represented here — they are simply left in the pending set (see `FamilyInvitation::expires_at`).",
"description": "Terminal status for an invitation that has been moved out of the pending set.\n\nNote: an invitation that merely times out is **not** archived here on its own — it is left inert in the pending set (see `FamilyInvitation::expires_at`). It only reaches `Expired` if the family issues a fresh invitation for the same node, which supersedes and archives the stale one.",
"oneOf": [
{
"description": "Still awaiting a response. Recorded with a timestamp for completeness even though pending invitations live in a separate map.",
@@ -178,11 +178,35 @@
}
},
"additionalProperties": false
},
{
"description": "The invitation had already expired and was superseded by a fresh invitation for the same node from the same family, issued at the given timestamp. This is the only path that archives a timed-out invitation.",
"type": "object",
"required": [
"expired"
],
"properties": {
"expired": {
"type": "object",
"required": [
"at"
],
"properties": {
"at": {
"type": "integer",
"format": "uint64",
"minimum": 0.0
}
},
"additionalProperties": false
}
},
"additionalProperties": false
}
]
},
"PastFamilyInvitation": {
"description": "Historical record of an invitation that has reached a terminal state (`Accepted`, `Rejected`, or `Revoked`). Timed-out invitations are **not** archived here — they remain in the pending map until explicitly cleared.",
"description": "Historical record of an invitation that has reached a terminal state (`Accepted`, `Rejected`, `Revoked`, or `Expired`). A timed-out invitation is archived here only when a fresh invitation for the same node supersedes it (status `Expired`); otherwise it stays in the pending map until explicitly cleared.",
"type": "object",
"required": [
"invitation",
@@ -39,7 +39,7 @@
"additionalProperties": false,
"definitions": {
"FamilyInvitation": {
"description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, revoked, or until the chain advances past `expires_at` (in which case they remain in storage but are treated as inert — there is no background process clearing expired invitations).",
"description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, or revoked. Once the chain advances past `expires_at` an invitation becomes inert but stays in storage — there is no background process clearing expired invitations. A timed-out invitation is cleared either when explicitly revoked/rejected, or when the family issues a fresh invitation for the same node, which archives the stale one as `Expired` and supersedes it.",
"type": "object",
"required": [
"expires_at",
@@ -46,7 +46,7 @@
"additionalProperties": false,
"definitions": {
"FamilyInvitation": {
"description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, revoked, or until the chain advances past `expires_at` (in which case they remain in storage but are treated as inert — there is no background process clearing expired invitations).",
"description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, or revoked. Once the chain advances past `expires_at` an invitation becomes inert but stays in storage — there is no background process clearing expired invitations. A timed-out invitation is cleared either when explicitly revoked/rejected, or when the family issues a fresh invitation for the same node, which archives the stale one as `Expired` and supersedes it.",
"type": "object",
"required": [
"expires_at",
@@ -76,7 +76,7 @@
"additionalProperties": false
},
"FamilyInvitationStatus": {
"description": "Terminal status for an invitation that has been moved out of the pending set.\n\nNote: timed-out invitations are not represented here — they are simply left in the pending set (see `FamilyInvitation::expires_at`).",
"description": "Terminal status for an invitation that has been moved out of the pending set.\n\nNote: an invitation that merely times out is **not** archived here on its own — it is left inert in the pending set (see `FamilyInvitation::expires_at`). It only reaches `Expired` if the family issues a fresh invitation for the same node, which supersedes and archives the stale one.",
"oneOf": [
{
"description": "Still awaiting a response. Recorded with a timestamp for completeness even though pending invitations live in a separate map.",
@@ -173,11 +173,35 @@
}
},
"additionalProperties": false
},
{
"description": "The invitation had already expired and was superseded by a fresh invitation for the same node from the same family, issued at the given timestamp. This is the only path that archives a timed-out invitation.",
"type": "object",
"required": [
"expired"
],
"properties": {
"expired": {
"type": "object",
"required": [
"at"
],
"properties": {
"at": {
"type": "integer",
"format": "uint64",
"minimum": 0.0
}
},
"additionalProperties": false
}
},
"additionalProperties": false
}
]
},
"PastFamilyInvitation": {
"description": "Historical record of an invitation that has reached a terminal state (`Accepted`, `Rejected`, or `Revoked`). Timed-out invitations are **not** archived here — they remain in the pending map until explicitly cleared.",
"description": "Historical record of an invitation that has reached a terminal state (`Accepted`, `Rejected`, `Revoked`, or `Expired`). A timed-out invitation is archived here only when a fresh invitation for the same node supersedes it (status `Expired`); otherwise it stays in the pending map until explicitly cleared.",
"type": "object",
"required": [
"invitation",
@@ -46,7 +46,7 @@
"additionalProperties": false,
"definitions": {
"FamilyInvitation": {
"description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, revoked, or until the chain advances past `expires_at` (in which case they remain in storage but are treated as inert — there is no background process clearing expired invitations).",
"description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, or revoked. Once the chain advances past `expires_at` an invitation becomes inert but stays in storage — there is no background process clearing expired invitations. A timed-out invitation is cleared either when explicitly revoked/rejected, or when the family issues a fresh invitation for the same node, which archives the stale one as `Expired` and supersedes it.",
"type": "object",
"required": [
"expires_at",
@@ -76,7 +76,7 @@
"additionalProperties": false
},
"FamilyInvitationStatus": {
"description": "Terminal status for an invitation that has been moved out of the pending set.\n\nNote: timed-out invitations are not represented here — they are simply left in the pending set (see `FamilyInvitation::expires_at`).",
"description": "Terminal status for an invitation that has been moved out of the pending set.\n\nNote: an invitation that merely times out is **not** archived here on its own — it is left inert in the pending set (see `FamilyInvitation::expires_at`). It only reaches `Expired` if the family issues a fresh invitation for the same node, which supersedes and archives the stale one.",
"oneOf": [
{
"description": "Still awaiting a response. Recorded with a timestamp for completeness even though pending invitations live in a separate map.",
@@ -173,11 +173,35 @@
}
},
"additionalProperties": false
},
{
"description": "The invitation had already expired and was superseded by a fresh invitation for the same node from the same family, issued at the given timestamp. This is the only path that archives a timed-out invitation.",
"type": "object",
"required": [
"expired"
],
"properties": {
"expired": {
"type": "object",
"required": [
"at"
],
"properties": {
"at": {
"type": "integer",
"format": "uint64",
"minimum": 0.0
}
},
"additionalProperties": false
}
},
"additionalProperties": false
}
]
},
"PastFamilyInvitation": {
"description": "Historical record of an invitation that has reached a terminal state (`Accepted`, `Rejected`, or `Revoked`). Timed-out invitations are **not** archived here — they remain in the pending map until explicitly cleared.",
"description": "Historical record of an invitation that has reached a terminal state (`Accepted`, `Rejected`, `Revoked`, or `Expired`). A timed-out invitation is archived here only when a fresh invitation for the same node supersedes it (status `Expired`); otherwise it stays in the pending map until explicitly cleared.",
"type": "object",
"required": [
"invitation",
@@ -35,7 +35,7 @@
"additionalProperties": false,
"definitions": {
"FamilyInvitation": {
"description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, revoked, or until the chain advances past `expires_at` (in which case they remain in storage but are treated as inert — there is no background process clearing expired invitations).",
"description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, or revoked. Once the chain advances past `expires_at` an invitation becomes inert but stays in storage — there is no background process clearing expired invitations. A timed-out invitation is cleared either when explicitly revoked/rejected, or when the family issues a fresh invitation for the same node, which archives the stale one as `Expired` and supersedes it.",
"type": "object",
"required": [
"expires_at",
@@ -34,7 +34,7 @@
"additionalProperties": false,
"definitions": {
"FamilyInvitation": {
"description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, revoked, or until the chain advances past `expires_at` (in which case they remain in storage but are treated as inert — there is no background process clearing expired invitations).",
"description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, or revoked. Once the chain advances past `expires_at` an invitation becomes inert but stays in storage — there is no background process clearing expired invitations. A timed-out invitation is cleared either when explicitly revoked/rejected, or when the family issues a fresh invitation for the same node, which archives the stale one as `Expired` and supersedes it.",
"type": "object",
"required": [
"expires_at",
@@ -34,7 +34,7 @@
"additionalProperties": false,
"definitions": {
"FamilyInvitation": {
"description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, revoked, or until the chain advances past `expires_at` (in which case they remain in storage but are treated as inert — there is no background process clearing expired invitations).",
"description": "A pending invitation for a node to join a particular family.\n\nInvitations are stored until they are accepted, rejected, or revoked. Once the chain advances past `expires_at` an invitation becomes inert but stays in storage — there is no background process clearing expired invitations. A timed-out invitation is cleared either when explicitly revoked/rejected, or when the family issues a fresh invitation for the same node, which archives the stale one as `Expired` and supersedes it.",
"type": "object",
"required": [
"expires_at",
+2 -1
View File
@@ -5,7 +5,7 @@
use crate::queries::{
query_all_family_members_paged, query_all_past_invitations_paged,
query_all_pending_invitations_paged, query_families_paged, query_family_by_id,
query_all_pending_invitations_paged, query_config, query_families_paged, query_family_by_id,
query_family_by_name, query_family_by_owner, query_family_members_paged,
query_family_membership, query_past_invitations_for_family_paged,
query_past_invitations_for_node_paged, query_past_members_for_family_paged,
@@ -100,6 +100,7 @@ pub fn execute(
#[entry_point]
pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> Result<Binary, NodeFamiliesContractError> {
match msg {
QueryMsg::GetConfig {} => Ok(to_json_binary(&query_config(deps)?)?),
QueryMsg::GetFamilyById { family_id } => {
Ok(to_json_binary(&query_family_by_id(deps, family_id)?)?)
}
+15 -10
View File
@@ -7,18 +7,23 @@ use cosmwasm_std::{Deps, Env, Order, StdResult};
use cw_storage_plus::Bound;
use nym_mixnet_contract_common::NodeId;
use nym_node_families_contract_common::{
AllFamilyMembersPagedResponse, AllPastFamilyInvitationsPagedResponse, FamiliesPagedResponse,
FamilyMemberRecord, FamilyMembersPagedResponse, GlobalPastFamilyInvitationCursor,
NodeFamiliesContractError, NodeFamilyByNameResponse, NodeFamilyByOwnerResponse, NodeFamilyId,
NodeFamilyMembershipResponse, NodeFamilyResponse, PastFamilyInvitationCursor,
PastFamilyInvitationForNodeCursor, PastFamilyInvitationsForNodePagedResponse,
PastFamilyInvitationsPagedResponse, PastFamilyMemberCursor, PastFamilyMemberForNodeCursor,
PastFamilyMembersForNodePagedResponse, PastFamilyMembersPagedResponse,
PendingFamilyInvitationDetails, PendingFamilyInvitationResponse,
PendingFamilyInvitationsPagedResponse, PendingInvitationsForNodePagedResponse,
PendingInvitationsPagedResponse,
AllFamilyMembersPagedResponse, AllPastFamilyInvitationsPagedResponse, Config,
FamiliesPagedResponse, FamilyMemberRecord, FamilyMembersPagedResponse,
GlobalPastFamilyInvitationCursor, NodeFamiliesContractError, NodeFamilyByNameResponse,
NodeFamilyByOwnerResponse, NodeFamilyId, NodeFamilyMembershipResponse, NodeFamilyResponse,
PastFamilyInvitationCursor, PastFamilyInvitationForNodeCursor,
PastFamilyInvitationsForNodePagedResponse, PastFamilyInvitationsPagedResponse,
PastFamilyMemberCursor, PastFamilyMemberForNodeCursor, PastFamilyMembersForNodePagedResponse,
PastFamilyMembersPagedResponse, PendingFamilyInvitationDetails,
PendingFamilyInvitationResponse, PendingFamilyInvitationsPagedResponse,
PendingInvitationsForNodePagedResponse, PendingInvitationsPagedResponse,
};
/// Retrieve current contract configuration values
pub fn query_config(deps: Deps) -> Result<Config, NodeFamiliesContractError> {
Ok(NodeFamiliesStorage::new().config.load(deps.storage)?)
}
/// Resolve a single family by its id. Returns `family: None` if no family
/// with that id exists.
pub fn query_family_by_id(
+80 -20
View File
@@ -292,11 +292,17 @@ impl NodeFamiliesStorage<'_> {
/// - ensuring `expires_at` is strictly in the future.
///
/// As defence-in-depth, this method errors with [`FamilyNotFound`] if
/// `family_id` is unknown and with [`PendingInvitationAlreadyExists`] if
/// a pending invitation for the same `(family, node)` pair is already
/// stored — the underlying `IndexedMap` would otherwise silently
/// `family_id` is unknown and with [`PendingInvitationAlreadyExists`] if a
/// *still-valid* pending invitation for the same `(family, node)` pair is
/// already stored — the underlying `IndexedMap` would otherwise silently
/// overwrite it.
///
/// If a pending invitation for the pair exists but has already expired
/// (`now >= expires_at`), it is archived in [`Self::past_family_invitations`]
/// with status [`FamilyInvitationStatus::Expired`] and the fresh invitation
/// supersedes it. Together with an explicit revoke/reject, this is the only
/// path that clears a timed-out invitation out of the pending map.
///
/// Returns the freshly persisted [`FamilyInvitation`].
///
/// [`FamilyNotFound`]: NodeFamiliesContractError::FamilyNotFound
@@ -304,25 +310,37 @@ impl NodeFamiliesStorage<'_> {
pub(crate) fn add_pending_invitation(
&self,
store: &mut dyn Storage,
env: &Env,
family_id: NodeFamilyId,
node_id: NodeId,
expires_at: u64,
) -> Result<FamilyInvitation, NodeFamiliesContractError> {
let now = env.block.time.seconds();
let key: FamilyMember = (family_id, node_id);
if !self.families.has(store, family_id) {
return Err(NodeFamiliesContractError::FamilyNotFound { family_id });
}
if self
.pending_family_invitations
.may_load(store, key)?
.is_some()
{
return Err(NodeFamiliesContractError::PendingInvitationAlreadyExists {
family_id,
node_id,
});
if let Some(existing) = self.pending_family_invitations.may_load(store, key)? {
// a still-valid invitation blocks a duplicate; an expired one is
// archived and superseded by the fresh invitation below.
if now < existing.expires_at {
return Err(NodeFamiliesContractError::PendingInvitationAlreadyExists {
family_id,
node_id,
});
}
let counter = self.next_past_invitation_counter(store, key)?;
self.past_family_invitations.save(
store,
(key, counter),
&PastFamilyInvitation {
invitation: existing,
status: FamilyInvitationStatus::Expired { at: now },
},
)?;
}
let invitation = FamilyInvitation {
@@ -914,10 +932,11 @@ mod tests {
let s = NodeFamiliesStorage::new();
let alice = tester.addr_make("alice");
let f = tester.make_family(&alice);
let expires_at = tester.env().block.time.seconds() + 100;
let env = tester.env();
let expires_at = env.block.time.seconds() + 100;
let inv = s
.add_pending_invitation(tester.storage_mut(), f.id, 42, expires_at)
.add_pending_invitation(tester.storage_mut(), &env, f.id, 42, expires_at)
.unwrap();
assert_eq!(inv.family_id, f.id);
@@ -937,7 +956,7 @@ mod tests {
let env = tester.env();
let expires_at = env.block.time.seconds() + 100;
let res = s.add_pending_invitation(tester.storage_mut(), 99, 42, expires_at);
let res = s.add_pending_invitation(tester.storage_mut(), &env, 99, 42, expires_at);
assert_eq!(
res.unwrap_err(),
NodeFamiliesContractError::FamilyNotFound { family_id: 99 }
@@ -955,7 +974,7 @@ mod tests {
tester.invite_to_family(f.id, 42);
let expires_at = env.block.time.seconds() + 200;
let res = s.add_pending_invitation(tester.storage_mut(), f.id, 42, expires_at);
let res = s.add_pending_invitation(tester.storage_mut(), &env, f.id, 42, expires_at);
assert_eq!(
res.unwrap_err(),
NodeFamiliesContractError::PendingInvitationAlreadyExists {
@@ -965,6 +984,47 @@ mod tests {
);
}
#[test]
fn add_pending_invitation_supersedes_expired() {
let mut tester = init_contract_tester();
let s = NodeFamiliesStorage::new();
let env = tester.env();
let alice = tester.addr_make("alice");
let f = tester.make_family(&alice);
// first invitation expires at exactly `now`, so it is immediately stale
let stale_exp = env.block.time.seconds();
s.add_pending_invitation(tester.storage_mut(), &env, f.id, 42, stale_exp)
.unwrap();
// re-inviting the same node supersedes the expired invitation
let fresh_exp = env.block.time.seconds() + 100;
let fresh = s
.add_pending_invitation(tester.storage_mut(), &env, f.id, 42, fresh_exp)
.unwrap();
assert_eq!(fresh.expires_at, fresh_exp);
// the fresh invitation is the one left pending
let pending = s
.pending_family_invitations
.load(tester.storage(), (f.id, 42))
.unwrap();
assert_eq!(pending.expires_at, fresh_exp);
// the stale one is archived as Expired, stamped at `now`
let past = s
.past_family_invitations
.load(tester.storage(), ((f.id, 42), 0))
.unwrap();
assert_eq!(
past.status,
FamilyInvitationStatus::Expired {
at: env.block.time.seconds()
}
);
assert_eq!(past.invitation.expires_at, stale_exp);
}
// ---- accept_invitation ----
#[test]
@@ -975,7 +1035,7 @@ mod tests {
let alice = tester.addr_make("alice");
let f = tester.make_family(&alice);
let expires_at = env.block.time.seconds() + 100;
s.add_pending_invitation(tester.storage_mut(), f.id, 42, expires_at)
s.add_pending_invitation(tester.storage_mut(), &env, f.id, 42, expires_at)
.unwrap();
let updated = s
@@ -1032,7 +1092,7 @@ mod tests {
let f = tester.make_family(&alice);
// expires at exactly `now` — `now >= expires_at` triggers
let expires_at = tester.env().block.time.seconds();
s.add_pending_invitation(tester.storage_mut(), f.id, 42, expires_at)
s.add_pending_invitation(tester.storage_mut(), &env, f.id, 42, expires_at)
.unwrap();
let res = s.accept_invitation(tester.storage_mut(), &env, f.id, 42);
@@ -1087,7 +1147,7 @@ mod tests {
let alice = tester.addr_make("alice");
let f = tester.make_family(&alice);
let expires_at = env.block.time.seconds();
s.add_pending_invitation(tester.storage_mut(), f.id, 42, expires_at)
s.add_pending_invitation(tester.storage_mut(), &env, f.id, 42, expires_at)
.unwrap();
s.reject_pending_invitation(tester.storage_mut(), &env, f.id, 42)
@@ -1205,7 +1265,7 @@ mod tests {
let expires_at = env.block.time.seconds() + 100;
for _ in 0..2 {
s.add_pending_invitation(tester.storage_mut(), f.id, 42, expires_at)
s.add_pending_invitation(tester.storage_mut(), &env, f.id, 42, expires_at)
.unwrap();
s.accept_invitation(tester.storage_mut(), &env, f.id, 42)
.unwrap();
+2 -1
View File
@@ -168,8 +168,9 @@ pub trait NodeFamiliesContractTesterExt:
node: NodeId,
expiration: u64,
) -> FamilyInvitation {
let env = self.env();
NodeFamiliesStorage::new()
.add_pending_invitation(self, family, node, expiration)
.add_pending_invitation(self, &env, family, node, expiration)
.unwrap()
}
+96 -1
View File
@@ -254,7 +254,8 @@ pub(crate) fn try_invite_to_family(
ensure_node_not_in_family(&storage, deps.as_ref(), node_id)?;
let expires_at = env.block.time.seconds() + validity;
let invitation = storage.add_pending_invitation(deps.storage, owned.id, node_id, expires_at)?;
let invitation =
storage.add_pending_invitation(deps.storage, &env, owned.id, node_id, expires_at)?;
Ok(Response::new().add_event(
Event::new(events::FAMILY_INVITATION_EVENT_NAME)
@@ -1311,6 +1312,8 @@ mod tests {
use super::*;
use crate::testing::NodeFamiliesContractTesterExt;
use mixnet_contract::testable_mixnet_contract::EmbeddedMixnetContractExt;
use nym_contracts_common_testing::ChainOpts;
use nym_node_families_contract_common::FamilyInvitationStatus;
#[test]
fn happy_path_persists_pending_invitation() -> anyhow::Result<()> {
@@ -1469,6 +1472,98 @@ mod tests {
);
Ok(())
}
#[test]
fn allows_reinvite_once_previous_invitation_has_expired() -> anyhow::Result<()> {
let mut tester = init_contract_tester();
let alice = tester.addr_make("alice");
let family = tester.make_family(&alice);
let node_id = tester.bond_dummy_nymnode()?;
// first invitation with a short, explicit validity
let first_env = tester.env();
try_invite_to_family(
tester.deps_mut(),
first_env.clone(),
message_info(&alice, &[]),
node_id,
Some(5),
)?;
let first_expires_at = first_env.block.time.seconds() + 5;
// let it lapse
tester.advance_time_by(10);
// re-inviting the same node now succeeds and refreshes the expiry
let second_env = tester.env();
try_invite_to_family(
tester.deps_mut(),
second_env.clone(),
message_info(&alice, &[]),
node_id,
Some(5),
)?;
let storage = NodeFamiliesStorage::new();
let pending = storage
.pending_family_invitations
.load(tester.deps().storage, (family.id, node_id))?;
assert_eq!(pending.expires_at, second_env.block.time.seconds() + 5);
// the lapsed invitation was archived as Expired at the re-invite time
let archived = storage
.past_family_invitations
.load(tester.deps().storage, ((family.id, node_id), 0))?;
assert!(matches!(
archived.status,
FamilyInvitationStatus::Expired { at } if at == second_env.block.time.seconds()
));
assert_eq!(archived.invitation.expires_at, first_expires_at);
Ok(())
}
#[test]
fn rejects_reinvite_while_previous_invitation_is_still_valid() -> anyhow::Result<()> {
let mut tester = init_contract_tester();
let alice = tester.addr_make("alice");
let family = tester.make_family(&alice);
let node_id = tester.bond_dummy_nymnode()?;
let env = tester.env();
try_invite_to_family(
tester.deps_mut(),
env,
message_info(&alice, &[]),
node_id,
Some(100),
)?;
// some time passes, but the invitation has not yet expired
tester.advance_time_by(10);
let env = tester.env();
let err = try_invite_to_family(
tester.deps_mut(),
env,
message_info(&alice, &[]),
node_id,
Some(100),
)
.unwrap_err();
assert_eq!(
err,
NodeFamiliesContractError::PendingInvitationAlreadyExists {
family_id: family.id,
node_id,
}
);
// nothing was archived — the still-valid invitation stays pending
assert!(NodeFamiliesStorage::new()
.past_family_invitations
.may_load(tester.deps().storage, ((family.id, node_id), 0))?
.is_none());
Ok(())
}
}
mod revoke_family_invitation {
@@ -310,6 +310,24 @@ const sdks = [
"fetch()-compatible API that routes HTTP(S) requests through the Mixnet. Browsers and Node.js.",
href: "/developers/mix-fetch",
},
{
name: "mix-tunnel",
description:
"Owns the shared Mixnet tunnel that mix-fetch, mix-dns, and mix-websocket ride on. One IPR connection and userspace TCP/IP stack for all three.",
href: "/developers/mix-tunnel",
},
{
name: "mix-dns",
description:
"Resolves hostnames to IPs through the Mixnet. UDP DNS via an IPR exit, no TCP or TLS.",
href: "/developers/mix-dns",
},
{
name: "mix-websocket",
description:
"WebSocket-compatible class for ws and wss traffic routed through the Mixnet via an IPR exit.",
href: "/developers/mix-websocket",
},
];
export const LandingPage = () => {
-282
View File
@@ -1,282 +0,0 @@
import React, { useState, useRef, useEffect } from "react";
import CircularProgress from "@mui/material/CircularProgress";
import Button from "@mui/material/Button";
import TextField from "@mui/material/TextField";
import Typography from "@mui/material/Typography";
import Box from "@mui/material/Box";
import { mixFetch, createMixFetch } from "@nymproject/mix-fetch-full-fat";
import Stack from "@mui/material/Stack";
import Paper from "@mui/material/Paper";
import type { SetupMixFetchOps } from "@nymproject/mix-fetch-full-fat";
const defaultUrl =
"https://nymtech.net/.wellknown/network-requester/exit-policy.txt";
const args = { mode: "unsafe-ignore-cors" };
const mixFetchOptions: SetupMixFetchOps = {
clientId: "docs-mixfetch-demo", // explicit ID
preferredGateway: "q2A2cbooyC16YJzvdYaSMH9X3cSiieZNtfBr8cE8Fi1",
mixFetchOverride: {
requestTimeoutMs: 60_000,
},
forceTls: true, // force WSS
};
// Log entry type for the visible log panel
type LogLevel = "info" | "error" | "send" | "receive";
type LogEntry = { timestamp: string; message: string; level: LogLevel };
// Color map for log levels
const logColors: Record<LogLevel, string> = {
info: "gray",
error: "red",
send: "blue",
receive: "green",
};
// Label map for log levels
const logLabels: Record<LogLevel, string> = {
info: "INFO",
error: "ERROR",
send: "SEND",
receive: "RECV",
};
export const MixFetch = () => {
// MixFetch initialization state
const [status, setStatus] = useState<"idle" | "starting" | "ready" | "error">(
"idle"
);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
// Log panel state
const [logs, setLogs] = useState<LogEntry[]>([]);
// Single fetch state
const [url, setUrl] = useState<string>(defaultUrl);
const [html, setHtml] = useState<string>();
const [busy, setBusy] = useState<boolean>(false);
// Concurrent fetch state
const [concurrentResults, setConcurrentResults] = useState<string[]>([]);
const [concurrentBusy, setConcurrentBusy] = useState<boolean>(false);
// Auto-scroll within the log panel when new entries are added (without scrolling the page)
const logContainerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (logContainerRef.current) {
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
}
}, [logs]);
// Helper to add a timestamped log entry
const addLog = (message: string, level: LogLevel) => {
const timestamp = new Date().toISOString().substring(11, 23); // HH:MM:SS.mmm
setLogs((prev) => [...prev, { timestamp, message, level }]);
};
// Initialize MixFetch explicitly via createMixFetch
const handleStart = async () => {
try {
setStatus("starting");
setErrorMsg(null);
addLog("Starting MixFetch...", "info");
await createMixFetch(mixFetchOptions);
setStatus("ready");
addLog("MixFetch is ready!", "info");
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
setStatus("error");
setErrorMsg(msg);
addLog(`Error: ${msg}`, "error");
}
};
// Single URL fetch — mixFetch reuses the existing singleton
const handleFetch = async () => {
try {
setBusy(true);
setHtml(undefined);
addLog(`Sending request to ${url}...`, "send");
const response = await mixFetch(url, args, mixFetchOptions);
const resHtml = await response.text();
setHtml(resHtml);
addLog(`Response received (${resHtml.length} bytes)`, "receive");
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
addLog(`Fetch error: ${msg}`, "error");
} finally {
setBusy(false);
}
};
// Send 5 concurrent requests to different URLs on the same domain
const handleConcurrentFetch = async () => {
const baseUrl = "https://jsonplaceholder.typicode.com/posts/";
const count = 5;
try {
setConcurrentBusy(true);
setConcurrentResults([]);
addLog(
`Starting ${count} concurrent requests to ${baseUrl}1-${count}...`,
"send"
);
// Fire off all requests concurrently using Promise.all
const requests = Array.from({ length: count }, (_, i) => {
const targetUrl = `${baseUrl}${i + 1}`;
return mixFetch(targetUrl, args, mixFetchOptions)
.then((res) => res.json())
.then((json: { id: number; title: string }) => {
const entry = `[${json.id}] ${json.title}`;
addLog(entry, "receive");
return entry;
});
});
const results = await Promise.all(requests);
setConcurrentResults(results);
addLog(`All ${count} concurrent requests completed!`, "info");
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
addLog(`Concurrent fetch error: ${msg}`, "error");
} finally {
setConcurrentBusy(false);
}
};
// Are fetch controls enabled?
const isReady = status === "ready";
// Status text + color for the startup indicator
const statusText: Record<typeof status, string> = {
idle: "Not started",
starting: "Starting...",
ready: "Ready",
error: `Error: ${errorMsg}`,
};
const statusColor: Record<typeof status, string> = {
idle: "#9e9e9e",
starting: "orange",
ready: "#85E89D",
error: "#ff6b6b",
};
return (
<Box sx={{ mt: 2 }}>
<Paper sx={{ p: 3 }}>
<Stack spacing={3}>
{/* --- Start MixFetch Section --- */}
<Stack direction="row" alignItems="center" spacing={2}>
<Button
variant="contained"
disabled={status === "starting" || status === "ready"}
onClick={handleStart}
>
Start MixFetch
</Button>
{status === "starting" && <CircularProgress size={20} />}
<Typography
fontFamily="monospace"
fontSize="small"
sx={{ color: statusColor[status] }}
>
{statusText[status]}
</Typography>
</Stack>
{/* --- Fetch Controls (disabled until ready) --- */}
<Box
sx={{
opacity: isReady ? 1 : 0.5,
pointerEvents: isReady ? "auto" : "none",
}}
>
{/* Single fetch */}
<Stack direction="row" spacing={2}>
<TextField
disabled={busy}
fullWidth
label="URL"
type="text"
variant="outlined"
defaultValue={defaultUrl}
onChange={(e) => setUrl(e.target.value)}
size="small"
/>
<Button
variant="outlined"
disabled={busy}
onClick={handleFetch}
>
Fetch
</Button>
</Stack>
{busy && (
<Box mt={2}>
<CircularProgress />
</Box>
)}
{html && (
<>
<Box mt={2}>
<strong>Response</strong>
</Box>
<Paper sx={{ p: 2, mt: 1 }} elevation={4}>
<Typography fontFamily="monospace" fontSize="small">
{html}
</Typography>
</Paper>
</>
)}
{/* Concurrent fetch demo */}
<Box mt={3}>
<strong>Concurrent Requests</strong>
<Box mt={1}>
<Button
variant="outlined"
disabled={concurrentBusy}
onClick={handleConcurrentFetch}
>
Send 5 Concurrent Requests (posts/1-5)
</Button>
</Box>
</Box>
{concurrentBusy && (
<Box mt={2}>
<CircularProgress />
</Box>
)}
{concurrentResults.length > 0 && (
<Paper sx={{ p: 2, mt: 2 }} elevation={4}>
{concurrentResults.map((result, i) => (
<Typography key={i} fontFamily="monospace" fontSize="small">
{result}
</Typography>
))}
</Paper>
)}
</Box>
</Stack>
</Paper>
{/* --- Log Panel --- */}
{logs.length > 0 && (
<Paper
ref={logContainerRef}
sx={{ p: 2, mt: 2, maxHeight: 200, overflow: "auto" }}
>
<strong>Log</strong>
{logs.map((entry, i) => (
<Typography
key={i}
fontFamily="monospace"
fontSize="small"
sx={{ color: logColors[entry.level] }}
>
{entry.timestamp} [{logLabels[entry.level]}] {entry.message}
</Typography>
))}
</Paper>
)}
</Box>
);
};
@@ -1,6 +1,6 @@
{
"nodes": 677,
"locations": 77,
"nodes": 683,
"locations": 75,
"mixnodes": 240,
"exit_gateways": 429
"exit_gateways": 435
}
@@ -1 +1 @@
Thursday, June 4th 2026, 11:40:35 UTC
Tuesday, June 9th 2026, 13:20:08 UTC
@@ -48,14 +48,16 @@ Options:
Specify whether basic system hardware information should be exposed. default: true [env: NYMNODE_HTTP_EXPOSE_SYSTEM_HARDWARE=] [possible values: true, false]
--expose-crypto-hardware <EXPOSE_CRYPTO_HARDWARE>
Specify whether detailed system crypto hardware information should be exposed. default: true [env: NYMNODE_HTTP_EXPOSE_CRYPTO_HARDWARE=] [possible values: true, false]
--nyxd-urls <NYXD_URLS>
Addresses to nyxd chain endpoint which the node will use for chain interactions [env: NYMNODE_NYXD=]
--nyxd-websocket-url <NYXD_WEBSOCKET_URL>
Url to the websocket endpoint of a nyx validator, for example `wss://rpc.nymtech.net/websocket`. It is used for subscribing to new block events [env: NYMNODE_NYXD_WEBSOCKET=]
--mixnet-bind-address <MIXNET_BIND_ADDRESS>
Address this node will bind to for listening for mixnet packets default: `[::]:1789` [env: NYMNODE_MIXNET_BIND_ADDRESS=]
--mixnet-announce-port <MIXNET_ANNOUNCE_PORT>
If applicable, custom port announced in the self-described API that other clients and nodes will use. Useful when the node is behind a proxy [env: NYMNODE_MIXNET_ANNOUNCE_PORT=]
--nym-api-urls <NYM_API_URLS>
Addresses to nym APIs from which the node gets the view of the network [env: NYMNODE_NYM_APIS=]
--nyxd-urls <NYXD_URLS>
Addresses to nyxd chain endpoint which the node will use for chain interactions [env: NYMNODE_NYXD=]
--enable-console-logging <ENABLE_CONSOLE_LOGGING>
Specify whether running statistics of this node should be logged to the console [env: NYMNODE_ENABLE_CONSOLE_LOGGING=] [possible values: true, false]
--wireguard-enabled <WIREGUARD_ENABLED>
@@ -0,0 +1,749 @@
// Single interactive playground for the mix-* TypeScript SDK, modelled on
// wasm/smolmix/internal-dev but driving the published @nymproject/mix-* packages
// and trimmed/adapted for a docs audience. One shared tunnel, several sections
// (DNS, GET, WebSocket, stress, download), each with a verbose timeline log and
// and, where it teaches something, a tunnel-vs-clearnet comparison.
//
// The package import is dynamic (see ./lib.ts loadModules) so the multi-MB wasm
// loads only when the visitor clicks Setup, not on page render.
import React, { useCallback, useEffect, useRef, useState } from 'react';
import {
loadModules,
randomClientId,
clampSurbs,
formatSize,
formatRate,
hexPreview,
sha256hex,
saveFile,
dohResolve,
generateRequests,
type PlaygroundMods,
type MixWebSocketLike,
type SetupOpts,
} from './lib';
import {
useLogs,
LogPanel,
StatusText,
Spinner,
Button,
box,
row,
input,
num,
legend,
sub,
type Status,
} from './ui';
const VERIFY_TEXT_URL = 'https://www.cl.cam.ac.uk/~mgk25/ucs/examples/UTF-8-demo.txt';
// Default IPR exit for the playground. Users can switch to auto-discovery
// with the "Use random IPR" toggle.
const DEFAULT_IPR =
'6B6iuWX4bQP4GVA4Yq7XmZencaaGw6BaPY6xJWYSwsbF.6g6LRx1fgU2Q2A4ZPKonYHtfBARh1GPMe1LtXk6vpRR8@q2A2cbooyC16YJzvdYaSMH9X3cSiieZNtfBr8cE8Fi1';
function eqBytes(a: Uint8Array, b: Uint8Array): boolean {
if (a.byteLength !== b.byteLength) return false;
for (let i = 0; i < a.byteLength; i++) if (a[i] !== b[i]) return false;
return true;
}
export function MixPlayground() {
const { log, lines } = useLogs();
const [mods, setMods] = useState<PlaygroundMods | null>(null);
const [connected, setConnected] = useState(false);
const [busy, setBusy] = useState(false); // setup/disconnect in flight
const [tunnelStatus, setTunnelStatus] = useState<Status>({ text: 'Not started', colour: 'gray' });
// Connection form.
const [useRandomIpr, setUseRandomIpr] = useState(false);
const [iprAddress, setIprAddress] = useState(DEFAULT_IPR);
const [clientId, setClientId] = useState('');
const [forceTls, setForceTls] = useState(true);
const [disablePoisson, setDisablePoisson] = useState(false);
const [disableCover, setDisableCover] = useState(false);
const [openSurbs, setOpenSurbs] = useState(10); // matches SurbsConfig::default (ipr.rs)
const [dataSurbs, setDataSurbs] = useState(2); // matches SurbsConfig::default (ipr.rs)
const [primaryDns, setPrimaryDns] = useState('');
const [fallbackDns, setFallbackDns] = useState('');
const [debug, setDebug] = useState(true);
// Section inputs.
const [dnsHost, setDnsHost] = useState('example.com');
const [getUrl, setGetUrl] = useState('https://httpbin.org/get');
const [wsUrl, setWsUrl] = useState('wss://echo.websocket.org');
const [wsMessage, setWsMessage] = useState('Hello from the mixnet!');
const [wsStatus, setWsStatus] = useState<Status>({ text: 'Not connected', colour: 'gray' });
const [wsConnected, setWsConnected] = useState(false);
const [burstCount, setBurstCount] = useState(10);
const [burstMin, setBurstMin] = useState(64);
const [burstMax, setBurstMax] = useState(1024);
const [burstBusy, setBurstBusy] = useState(false);
const [stressCount, setStressCount] = useState(10);
const [stressMode, setStressMode] = useState<'uniform' | 'mixed' | 'drip'>('mixed');
const [stressUrl, setStressUrl] = useState('https://jsonplaceholder.typicode.com/posts/');
const [stressTimeout, setStressTimeout] = useState(60);
const [stressBusy, setStressBusy] = useState(false);
const [stressStatus, setStressStatus] = useState<Status>({ text: '' });
const [downloadUrl, setDownloadUrl] = useState(
'https://web.cs.ucdavis.edu/~rogaway/papers/moral-fn.pdf',
);
const [textBusy, setTextBusy] = useState(false);
const [textStatus, setTextStatus] = useState<Status>({ text: '' });
const [textOutput, setTextOutput] = useState<string | null>(null);
const [pdfBusy, setPdfBusy] = useState(false);
const [pdfStatus, setPdfStatus] = useState<Status>({ text: '' });
const [pdfInfo, setPdfInfo] = useState<{ size: number; hash: string } | null>(null);
const [filePreview, setFilePreview] = useState<{ url: string; isImage: boolean } | null>(null);
const [bothStatus, setBothStatus] = useState<Status>({ text: '' });
const wsRef = useRef<MixWebSocketLike | null>(null);
const wsSendQueue = useRef<number[]>([]);
const burstRef = useRef<{
payloads: Uint8Array[];
received: number;
verified: number;
mismatches: number;
rtts: number[];
expected: number;
resolve: () => void;
} | null>(null);
const cachedPdf = useRef<ArrayBuffer | null>(null);
// Generate the client id after mount (not at render) to keep SSG and client
// hydration in agreement; see randomClientId in ./lib.
useEffect(() => {
setClientId((c) => c || randomClientId());
}, []);
// Revoke the previous object URL when the download changes or on unmount.
useEffect(() => () => { if (filePreview) URL.revokeObjectURL(filePreview.url); }, [filePreview]);
// Connection -------------------------------------------------------------
async function setup() {
setBusy(true);
const cid = clientId || randomClientId();
if (cid !== clientId) setClientId(cid);
setTunnelStatus({ text: 'Loading wasm...', colour: 'orange' });
let m = mods;
try {
if (!m) {
const t0 = performance.now();
m = await loadModules();
setMods(m);
log('master', `Modules loaded (${(performance.now() - t0).toFixed(0)} ms)`);
}
} catch (e) {
setTunnelStatus({ text: 'Failed to load wasm', colour: 'red' });
log('master', `module load failed: ${e}`, 'red');
setBusy(false);
return;
}
if (!useRandomIpr && !iprAddress.trim()) {
setTunnelStatus({ text: "IPR address required (or check 'random')", colour: 'red' });
setBusy(false);
return;
}
const opts: SetupOpts = {
...(useRandomIpr ? {} : { preferredIpr: iprAddress.trim() }),
clientId: cid,
forceTls,
disablePoissonTraffic: disablePoisson,
disableCoverTraffic: disableCover,
openReplySurbs: clampSurbs(openSurbs),
dataReplySurbs: clampSurbs(dataSurbs),
primaryDns: primaryDns.trim() || undefined,
fallbackDns: fallbackDns.trim() || undefined,
debug,
};
log(
'master',
`setupMixTunnel (clientId=${cid}, IPR: ${
useRandomIpr ? 'auto-discover' : iprAddress.trim().slice(0, 28) + '...'
})`,
);
setTunnelStatus({ text: 'Connecting to mixnet...', colour: 'orange' });
// The gateway/IPR/smoltcp detail is printed by the Rust client straight to
// the worker's console; it can't be forwarded to this panel. Point the user
// there rather than silently dropping it.
log(
'master',
debug
? 'Connecting... (gateway, IPR discovery and smoltcp logs are in the browser console)'
: 'Connecting... (tick "Verbose transport logs" for the gateway/IPR detail in the console)',
'gray',
);
try {
const t0 = performance.now();
const st = await m.getTunnelState();
if (st.state === 'ready') {
log('master', 'Tunnel already up; reusing it.', 'green');
} else {
await m.setupMixTunnel(opts);
log('master', `setupMixTunnel OK: tunnel ready in ${((performance.now() - t0) / 1000).toFixed(1)}s`, 'green');
}
const final = await m.getTunnelState();
log('master', `tunnel state: ${final.state}${final.reason ? ` (${final.reason})` : ''}`);
setConnected(true);
setTunnelStatus({ text: 'Connected', colour: 'green' });
} catch (e) {
const msg = String(e);
if (/already initialised/.test(msg)) {
setTunnelStatus({ text: 'Tunnel spent; reload the page to reconnect', colour: 'red' });
log('master', 'tunnel already initialised but not ready; reload the page', 'red');
} else {
setTunnelStatus({ text: `Failed: ${msg}`, colour: 'red' });
log('master', `setupMixTunnel failed: ${msg}`, 'red');
}
} finally {
setBusy(false);
}
}
async function disconnect() {
if (!mods) return;
setBusy(true);
log('master', 'Disconnecting...');
try {
await mods.disconnectMixTunnel();
log('master', 'Disconnected. Reload the page to reconnect (the wasm tunnel is one-shot).', 'green');
setConnected(false);
setTunnelStatus({ text: 'Disconnected', colour: 'gray' });
} catch (e) {
log('master', `disconnect failed: ${e}`, 'red');
} finally {
setBusy(false);
}
}
// DNS --------------------------------------------------------------------
async function dnsTunnel() {
if (!mods) return;
const h = dnsHost.trim();
if (!h) return log('dns', 'Hostname is required', 'red');
log('dns', `tunnel resolve ${h}`);
const t0 = performance.now();
try {
const ip = await mods.mixDNS(h);
log('dns', `tunnel ${h} => ${ip} (${(performance.now() - t0).toFixed(0)} ms)`, 'green');
} catch (e) {
log('dns', `tunnel resolve failed: ${e}`, 'red');
}
}
async function dnsClearnet() {
const h = dnsHost.trim();
if (!h) return log('dns', 'Hostname is required', 'red');
log('dns', `clearnet DoH resolve ${h}`);
const t0 = performance.now();
try {
const ip = await dohResolve(h);
log(
'dns',
`clearnet ${h} => ${ip} (${(performance.now() - t0).toFixed(0)} ms); visible in DevTools Network`,
'green',
);
} catch (e) {
log('dns', `clearnet DoH failed: ${e}`, 'red');
}
}
// GET --------------------------------------------------------------------
async function getTunnel() {
if (!mods) return;
const u = getUrl.trim();
if (!u) return log('get', 'URL is required', 'red');
log('get', `tunnel GET ${u}`);
const t0 = performance.now();
try {
const resp = await mods.mixFetch(u, {});
log('get', `tunnel ${resp.status} ${resp.statusText} (${(performance.now() - t0).toFixed(0)} ms)`, 'green');
} catch (e) {
log('get', `tunnel GET failed: ${e}`, 'red');
}
}
async function getClearnet() {
const u = getUrl.trim();
if (!u) return log('get', 'URL is required', 'red');
log('get', `clearnet GET ${u}`);
const t0 = performance.now();
try {
const resp = await window.fetch(u, { mode: 'cors' });
log(
'get',
`clearnet ${resp.status} ${resp.statusText} (${(performance.now() - t0).toFixed(0)} ms); visible in DevTools Network`,
'green',
);
} catch (e) {
log('get', `clearnet fetch failed: ${e}`, 'red');
}
}
// WebSocket --------------------------------------------------------------
const onWsMessage = useCallback(
(ev: Event) => {
const e = ev as MessageEvent;
let rtt: number | null = null;
if (wsSendQueue.current.length) rtt = performance.now() - (wsSendQueue.current.shift() as number);
const b = burstRef.current;
if (b) {
if (rtt != null) b.rtts.push(rtt);
const recvBuf = new Uint8Array(e.data as ArrayBuffer);
const sent = b.payloads[b.received];
if (sent && eqBytes(recvBuf, sent)) b.verified++;
else b.mismatches++;
b.received++;
if (b.received >= b.expected) b.resolve();
return;
}
const data = e.data;
let preview: string;
if (typeof data === 'string') preview = data.length <= 200 ? data : data.slice(0, 200) + '...';
else if (data instanceof ArrayBuffer) preview = `[binary ${data.byteLength} bytes] ${hexPreview(data)}`;
else preview = `[${typeof data}]`;
log('ws', rtt != null ? `recv (${rtt.toFixed(0)} ms RTT): ${preview}` : `recv: ${preview}`, 'green');
},
[log],
);
async function wsConnect() {
if (!mods) return;
const url = wsUrl.trim();
if (!url) return log('ws', 'WebSocket URL is required', 'red');
if (wsRef.current && wsRef.current.readyState !== 3) await wsRef.current.close().catch(() => {});
setWsStatus({ text: 'Connecting...', colour: 'orange' });
wsSendQueue.current = [];
log('ws', `connecting to ${url}`);
const t0 = performance.now();
const ws = new mods.MixWebSocket(url);
ws.addEventListener('message', onWsMessage);
ws.addEventListener('close', (ev) => {
const e = ev as CloseEvent;
log('ws', `closed: ${e.code} ${e.reason || ''}${e.wasClean ? '' : ' (unclean)'}`, 'orange');
setWsStatus({ text: 'Closed', colour: 'gray' });
setWsConnected(false);
wsRef.current = null;
});
ws.addEventListener('error', () => {
log('ws', 'error', 'red');
setWsStatus({ text: 'Error', colour: 'red' });
});
try {
await ws.opened();
const ms = (performance.now() - t0).toFixed(0);
log('ws', `connected in ${ms} ms (protocols=${ws.protocols.join(',') || 'none'})`, 'green');
setWsStatus({ text: `Connected (${ms} ms)`, colour: 'green' });
setWsConnected(true);
wsRef.current = ws;
} catch (e) {
log('ws', `connect failed: ${e}`, 'red');
setWsStatus({ text: 'Error', colour: 'red' });
}
}
async function wsSend() {
const ws = wsRef.current;
if (!ws || ws.readyState !== 1) return;
wsSendQueue.current.push(performance.now());
await ws.send(wsMessage);
log('ws', `send: ${wsMessage}`);
}
async function wsClose() {
const ws = wsRef.current;
if (!ws) return;
log('ws', 'closing...');
await ws.close(1000, 'user requested');
}
async function wsBurst() {
const ws = wsRef.current;
if (!ws || ws.readyState !== 1) return;
if (burstCount < 1 || burstCount > 500) return log('ws', 'burst count must be 1-500', 'red');
if (burstMin < 1 || burstMax < burstMin) return log('ws', 'invalid size range', 'red');
const payloads: Uint8Array[] = [];
let totalBytes = 0;
for (let i = 0; i < burstCount; i++) {
const size = burstMin === burstMax ? burstMin : burstMin + Math.floor(Math.random() * (burstMax - burstMin + 1));
const buf = new Uint8Array(size);
crypto.getRandomValues(buf);
payloads.push(buf);
totalBytes += size;
}
log('ws', `echo burst: ${burstCount} msgs, ${formatSize(burstMin)}-${formatSize(burstMax)} (${formatSize(totalBytes)} total)`);
setBurstBusy(true);
const done = new Promise<void>((resolve) => {
burstRef.current = { payloads, received: 0, verified: 0, mismatches: 0, rtts: [], expected: burstCount, resolve };
});
const t0 = performance.now();
for (let i = 0; i < burstCount; i++) {
wsSendQueue.current.push(performance.now());
ws.send(payloads[i]); // fire in order; Comlink preserves FIFO to the worker
}
await done;
const totalMs = performance.now() - t0;
const b = burstRef.current!;
burstRef.current = null;
const rtts = b.rtts.slice().sort((a, c) => a - c);
const pick = (q: number) => (rtts.length ? rtts[Math.min(rtts.length - 1, Math.floor(rtts.length * q))].toFixed(0) : 'n/a');
const avg = rtts.length ? (rtts.reduce((a, c) => a + c, 0) / rtts.length).toFixed(0) : 'n/a';
const msgPerSec = (burstCount / (totalMs / 1000)).toFixed(1);
log('ws', `burst done: ${burstCount} msgs in ${(totalMs / 1000).toFixed(2)}s (${msgPerSec} msg/s, ${formatRate(totalBytes, totalMs)})`, 'green');
log('ws', `verify: ${b.verified}/${burstCount} OK${b.mismatches ? `, ${b.mismatches} MISMATCH` : ''}`, b.mismatches === 0 ? 'green' : 'red');
log('ws', `RTT: min=${pick(0)} avg=${avg} p50=${pick(0.5)} p95=${pick(0.95)} max=${pick(1)} ms`);
setBurstBusy(false);
}
// Stress -----------------------------------------------------------------
async function oneStress(req: { id: number; url: string; label: string }) {
const start = performance.now();
try {
const resp = await mods!.mixFetch(req.url, {});
const body = await resp.text();
const el = ((performance.now() - start) / 1000).toFixed(2);
log('stress', `[#${req.id} ${req.label}] ${resp.status} OK ${el}s (${body.length}B)`, 'green');
return { ok: true, id: req.id, label: req.label };
} catch (e) {
const el = ((performance.now() - start) / 1000).toFixed(2);
log('stress', `[#${req.id} ${req.label}] FAIL ${el}s: ${e}`, 'red');
return { ok: false, id: req.id, label: req.label };
}
}
async function runStress() {
if (!mods) return;
setStressBusy(true);
setStressStatus({ text: 'Running...', colour: 'orange' });
const reqs = generateRequests(stressCount, stressMode, stressTimeout, stressUrl.trim());
if (stressMode !== 'uniform') {
const bd: Record<string, number> = {};
reqs.forEach((r) => (bd[r.label] = (bd[r.label] || 0) + 1));
log('stress', `${stressCount} requests, ${stressMode} mode, profiles: ${JSON.stringify(bd)}`);
} else {
log('stress', `${stressCount} requests, uniform mode`);
}
const t0 = performance.now();
const settled = await Promise.allSettled(reqs.map((r) => oneStress(r)));
const totalSec = ((performance.now() - t0) / 1000).toFixed(2);
const ok = settled.filter((s) => s.status === 'fulfilled' && s.value.ok).length;
const fail = stressCount - ok;
log('stress', `done: ${ok}/${stressCount} OK, ${fail} failed (${totalSec}s total)`, fail === 0 ? 'green' : 'red');
setStressStatus({ text: `Done: ${ok}/${stressCount} OK, ${fail} failed (${totalSec}s)`, colour: fail === 0 ? 'green' : 'red' });
setStressBusy(false);
}
// Download ---------------------------------------------------------------
async function verifyText() {
if (!mods) return;
setTextBusy(true);
setTextStatus({ text: 'Fetching...', colour: 'orange' });
log('download', `GET ${VERIFY_TEXT_URL} over the tunnel... (live transport logs in the browser console)`, 'orange');
const t0 = performance.now();
try {
const resp = await mods.mixFetch(VERIFY_TEXT_URL, {});
const text = await resp.text();
const ms = (performance.now() - t0).toFixed(0);
setTextStatus({ text: `${formatSize(text.length)} in ${ms} ms`, colour: 'green' });
setTextOutput(text);
log('download', `UTF-8 demo: ${formatSize(text.length)} in ${ms} ms`, 'green');
} catch (e) {
setTextStatus({ text: `Failed: ${e}`, colour: 'red' });
log('download', `UTF-8 demo FAILED: ${e}`, 'red');
} finally {
setTextBusy(false);
}
}
async function fetchFile() {
if (!mods) return;
const url = downloadUrl.trim();
if (!url) return log('download', 'Download URL is required', 'red');
setPdfBusy(true);
cachedPdf.current = null;
setPdfInfo(null);
setFilePreview(null);
setPdfStatus({ text: 'Fetching...', colour: 'orange' });
log('download', `GET ${url} over the tunnel... (live transport logs in the browser console)`, 'orange');
const t0 = performance.now();
try {
const resp = await mods.mixFetch(url, {});
const buf = await resp.arrayBuffer();
const ms = performance.now() - t0;
const hash = await sha256hex(buf);
cachedPdf.current = buf;
setPdfInfo({ size: buf.byteLength, hash });
const contentType = resp.headers.get('content-type') || '';
const isImage = contentType.startsWith('image/') || /\.(jpe?g|png|gif|webp|avif|svg)(\?|$)/i.test(url);
const objectUrl = URL.createObjectURL(new Blob([buf], contentType ? { type: contentType } : undefined));
setFilePreview({ url: objectUrl, isImage });
setPdfStatus({ text: `${formatSize(buf.byteLength)} in ${(ms / 1000).toFixed(1)}s`, colour: 'green' });
log(
'download',
`${formatSize(buf.byteLength)} in ${(ms / 1000).toFixed(1)}s (${formatRate(buf.byteLength, ms)}); SHA-256: ${hash.slice(0, 16)}...`,
'green',
);
} catch (e) {
setPdfStatus({ text: `Failed: ${e}`, colour: 'red' });
log('download', `FAILED: ${e}`, 'red');
} finally {
setPdfBusy(false);
}
}
function savePdf() {
const buf = cachedPdf.current;
if (!buf) return;
const filename = decodeURIComponent(downloadUrl.trim().split('/').pop()?.split('?')[0] || 'download');
saveFile(buf, filename, 'application/octet-stream');
}
async function runBoth() {
setBothStatus({ text: 'Running...', colour: 'orange' });
const t0 = performance.now();
await Promise.allSettled([verifyText(), fetchFile()]);
setBothStatus({ text: `Done in ${((performance.now() - t0) / 1000).toFixed(1)}s`, colour: 'green' });
}
// Render -----------------------------------------------------------------
return (
<div style={{ margin: '1.5rem 0' }}>
{/* Connection */}
<div style={box}>
<div style={legend}>Connection</div>
<div style={row}>
<label style={{ ...sub, display: 'flex', gap: 6, alignItems: 'center' }}>
<input type="checkbox" checked={useRandomIpr} onChange={(e) => setUseRandomIpr(e.target.checked)} />
Use random IPR
</label>
<input
style={input}
value={iprAddress}
onChange={(e) => setIprAddress(e.target.value)}
placeholder="<nym-address of IPR exit node>"
disabled={useRandomIpr}
/>
</div>
<details style={{ margin: '0.5rem 0' }}>
<summary style={{ cursor: 'pointer', ...sub }}>Advanced options</summary>
<div style={{ padding: '0.6rem 0' }}>
<div style={row}>
<label style={sub}>
<input type="checkbox" checked={forceTls} onChange={(e) => setForceTls(e.target.checked)} /> Force TLS
</label>
<label style={sub}>
<input type="checkbox" checked={disablePoisson} onChange={(e) => setDisablePoisson(e.target.checked)} /> Disable Poisson traffic
</label>
<label style={sub}>
<input type="checkbox" checked={disableCover} onChange={(e) => setDisableCover(e.target.checked)} /> Disable cover traffic
</label>
</div>
<div style={row}>
<label style={sub}>Client ID</label>
<input style={input} value={clientId} onChange={(e) => setClientId(e.target.value)} />
</div>
<div style={row}>
<label style={sub}>Open SURBs</label>
<input style={num} type="number" min={0} max={50} value={openSurbs} onChange={(e) => setOpenSurbs(+e.target.value)} />
<label style={sub}>Data SURBs</label>
<input style={num} type="number" min={0} max={50} value={dataSurbs} onChange={(e) => setDataSurbs(+e.target.value)} />
</div>
<div style={row}>
<label style={sub}>Primary DNS</label>
<input style={input} value={primaryDns} onChange={(e) => setPrimaryDns(e.target.value)} placeholder="8.8.8.8:53" />
<label style={sub}>Fallback DNS</label>
<input style={input} value={fallbackDns} onChange={(e) => setFallbackDns(e.target.value)} placeholder="1.1.1.1:53" />
</div>
</div>
</details>
<div style={row}>
<Button onClick={setup} disabled={busy || connected}>
{busy ? 'Working...' : 'setupMixTunnel'}
</Button>
<Button onClick={disconnect} disabled={busy || !connected}>
disconnectMixTunnel
</Button>
<label
style={sub}
title="Routes the Rust client's deep [smolmix] logs (gateway, IPR discovery, smoltcp) to the browser console. Set before connecting."
>
<input type="checkbox" checked={debug} onChange={(e) => setDebug(e.target.checked)} /> Verbose transport logs console
</label>
<StatusText status={tunnelStatus} />
</div>
<LogPanel lines={lines('master')} placeholder="Press setupMixTunnel to bring up the tunnel." />
<div style={{ ...sub, marginTop: '0.5rem' }}>
One-shot per page: after <code>disconnectMixTunnel</code> you must reload to reconnect, and each load uses a fresh client identity.
</div>
<div style={{ ...sub, marginTop: '0.35rem' }}>
This timeline shows the API-level events your code sees; the Rust client's deep transport logs (gateway, IPR discovery, smoltcp) go to the browser console behind <strong>Verbose transport logs</strong>.
</div>
</div>
{/* DNS */}
<div style={box}>
<div style={legend}>DNS resolve: tunnel vs clearnet</div>
<div style={row}>
<input style={input} value={dnsHost} onChange={(e) => setDnsHost(e.target.value)} placeholder="example.com" />
<Button onClick={dnsTunnel} disabled={!connected}>via tunnel (IPR)</Button>
<Button onClick={dnsClearnet}>via DoH (clearnet)</Button>
</div>
<div style={sub}>The clearnet DoH query appears in DevTools Network; the tunnel resolution does not.</div>
<div style={sub}>Resolve the same hostname twice: the second answer comes from the in-wasm DNS cache, served locally with no mixnet round-trip.</div>
<LogPanel lines={lines('dns')} />
</div>
{/* GET */}
<div style={box}>
<div style={legend}>GET: tunnel vs clearnet</div>
<div style={row}>
<input style={input} value={getUrl} onChange={(e) => setGetUrl(e.target.value)} placeholder="https://..." />
<Button onClick={getTunnel} disabled={!connected}>via tunnel</Button>
<Button onClick={getClearnet}>via window.fetch</Button>
</div>
<div style={sub}>Both buttons request the same URL, but the clearnet one reaches the server from your own IP and the tunnel one from the IPR's exit gateway.</div>
<div style={sub}>The clearnet button is a normal browser request, so some hosts block it with CORS while the tunnel request to the same URL succeeds; the defaults here are CORS-permissive.</div>
<div style={sub}>The first tunnel request to a host runs a full TCP + TLS handshake (visible in the browser console with debug logging on). The HTTPS connection is then pooled, so a second request to the same host skips the handshake; the log timings show the difference.</div>
<LogPanel lines={lines('get')} />
</div>
{/* WebSocket */}
<div style={box}>
<div style={legend}>WebSocket</div>
<div style={row}>
<input style={input} value={wsUrl} onChange={(e) => setWsUrl(e.target.value)} placeholder="wss://..." />
<Button onClick={wsConnect} disabled={!connected || wsConnected}>Connect</Button>
<Button onClick={wsClose} disabled={!wsConnected}>Close</Button>
<StatusText status={wsStatus} />
</div>
<div style={row}>
<input style={input} value={wsMessage} onChange={(e) => setWsMessage(e.target.value)} />
<Button onClick={wsSend} disabled={!wsConnected || burstBusy}>Send</Button>
</div>
<div style={row}>
<label style={sub}>Echo burst</label>
<input style={num} type="number" min={1} max={500} value={burstCount} onChange={(e) => setBurstCount(+e.target.value)} />
<label style={sub}>size</label>
<input style={num} type="number" min={1} value={burstMin} onChange={(e) => setBurstMin(+e.target.value)} />
<span style={sub}></span>
<input style={num} type="number" min={1} value={burstMax} onChange={(e) => setBurstMax(+e.target.value)} />
<span style={sub}>bytes</span>
<Button onClick={wsBurst} disabled={!wsConnected || burstBusy}>{burstBusy ? 'Bursting...' : 'Send burst'}</Button>
</div>
<div style={sub}>Connecting runs a TCP handshake (plus a TLS handshake for wss://) inside the worker, visible in the browser console with debug logging on.</div>
<LogPanel lines={lines('ws')} />
</div>
{/* Stress */}
<div style={box}>
<div style={legend}>Stress test</div>
<div style={row}>
<label style={sub}>Requests</label>
<input style={num} type="number" min={1} max={200} value={stressCount} onChange={(e) => setStressCount(+e.target.value)} />
<label style={sub}>Mode</label>
<select style={{ ...input, flex: '0 0 9rem' }} value={stressMode} onChange={(e) => setStressMode(e.target.value as typeof stressMode)}>
<option value="uniform">Uniform</option>
<option value="mixed">Mixed sizes</option>
<option value="drip">Slow drip</option>
</select>
<Button onClick={runStress} disabled={!connected || stressBusy}>{stressBusy ? 'Running...' : 'Run stress test'}</Button>
<StatusText status={stressStatus} />
</div>
{stressMode === 'uniform' && (
<div style={row}>
<label style={sub}>Base URL</label>
<input style={input} value={stressUrl} onChange={(e) => setStressUrl(e.target.value)} />
</div>
)}
{stressMode === 'mixed' && <div style={sub}>Random mix of 128 B / 1 KB / 10 KB / 100 KB / 1 MB responses (httpbin.org/bytes).</div>}
{stressMode === 'drip' && (
<div style={row}>
<label style={sub}>Timeout (s)</label>
<input style={num} type="number" min={5} max={300} value={stressTimeout} onChange={(e) => setStressTimeout(+e.target.value)} />
<span style={sub}>safe / boundary / over / slow-start, relative to this timeout (httpbin.org/drip).</span>
</div>
)}
<div style={sub}>Requests to the same host share one pooled TCP + TLS connection, so only the first pays the handshake cost.</div>
<LogPanel lines={lines('stress')} />
</div>
{/* Download */}
<div style={box}>
<div style={legend}>File download</div>
<div style={row}>
<Button onClick={verifyText} disabled={!connected || textBusy}>Fetch UTF-8 text</Button>
{textBusy ? <Spinner label="downloading... see the browser console for progress" /> : <StatusText status={textStatus} />}
</div>
{textOutput != null && (
<pre
style={{
maxHeight: 180,
overflowY: 'auto',
fontSize: 12,
whiteSpace: 'pre-wrap',
background: 'rgba(127,127,127,0.06)',
border: '1px solid rgba(127,127,127,0.2)',
borderRadius: 6,
padding: '0.5rem',
}}
>
{textOutput}
</pre>
)}
<div style={row}>
<input style={input} value={downloadUrl} onChange={(e) => setDownloadUrl(e.target.value)} />
<Button onClick={fetchFile} disabled={!connected || pdfBusy}>Fetch file</Button>
<Button onClick={savePdf} disabled={!pdfInfo}>Save</Button>
<Button onClick={() => filePreview && window.open(filePreview.url, '_blank')} disabled={!filePreview}>Open in new tab</Button>
<Button onClick={runBoth} disabled={!connected}>Run both</Button>
{pdfBusy ? <Spinner label="downloading... see the browser console for progress" /> : <StatusText status={bothStatus} />}
</div>
{pdfInfo && (
<div style={sub}>
Size: {pdfInfo.size.toLocaleString()} bytes · SHA-256: <code>{pdfInfo.hash}</code>
</div>
)}
{filePreview?.isImage && (
<img
src={filePreview.url}
alt="File downloaded over the mixnet"
style={{
maxHeight: 240,
maxWidth: '100%',
marginTop: '0.5rem',
borderRadius: 6,
border: '1px solid rgba(127,127,127,0.25)',
display: 'block',
}}
/>
)}
<div style={sub}>Fetches a real file over the tunnel and reports its size and SHA-256. Fetch it twice and the second download reuses the pooled HTTPS connection, skipping the handshake.</div>
<LogPanel lines={lines('download')} />
</div>
</div>
);
}
@@ -0,0 +1,189 @@
// Pure helpers + package loader for the mix playground. No React here: this is
// the logic ported from `wasm/smolmix/internal-dev/index.js`, adapted to drive
// the *published* @nymproject/mix-* packages instead of internal-dev's own
// worker. The differences that matter:
// - published `mixFetch` returns a real `Response` (internal-dev returned a
// raw `{body,status,statusText,headers}` and wrapped it);
// - there is no live `setDebugLogging`; debug is a `setupMixTunnel` opt;
// - the WebSocket is the EventTarget-based `MixWebSocket` (async send/close,
// `opened()`, binaryType fixed to arraybuffer).
// Local mirror of the published `SetupMixTunnelOpts` (subset we surface).
export interface SetupOpts {
preferredIpr?: string;
clientId?: string;
forceTls?: boolean;
disablePoissonTraffic?: boolean;
disableCoverTraffic?: boolean;
openReplySurbs?: number;
dataReplySurbs?: number;
primaryDns?: string;
fallbackDns?: string;
debug?: boolean;
}
export interface TunnelState {
state: string;
reason?: string;
}
// Mirror of the published `MixWebSocket` runtime surface.
export interface MixWebSocketLike extends EventTarget {
send(data: string | ArrayBuffer | Uint8Array): Promise<void>;
close(code?: number, reason?: string): Promise<void>;
opened(): Promise<void>;
readonly readyState: number;
readonly protocols: string[];
}
// The slice of the three packages the playground uses. They share one tunnel
// (mix-tunnel is deduped), so setup/disconnect/state are taken from mix-fetch.
export interface PlaygroundMods {
setupMixTunnel(opts?: SetupOpts): Promise<void>;
disconnectMixTunnel(): Promise<void>;
getTunnelState(): Promise<TunnelState>;
mixFetch(url: string, init?: RequestInit): Promise<Response>;
mixDNS(hostname: string): Promise<string>;
MixWebSocket: new (url: string, protocols?: string | string[]) => MixWebSocketLike;
}
// Lazy-load the three facades. Literal specifiers keep webpack code-splitting
// the wasm into async chunks (loaded only when the user clicks Setup). The
// `@ts-ignore`s are harmless once installed; they keep a fresh checkout
// type-checking before `pnpm install`.
export async function loadModules(): Promise<PlaygroundMods> {
const [f, d, w] = await Promise.all([
// @ts-ignore -- @nymproject/mix-fetch resolves at runtime; lazy wasm chunk
import('@nymproject/mix-fetch'),
// @ts-ignore -- @nymproject/mix-dns resolves at runtime; lazy wasm chunk
import('@nymproject/mix-dns'),
// @ts-ignore -- @nymproject/mix-websocket resolves at runtime; lazy wasm chunk
import('@nymproject/mix-websocket'),
]);
return {
setupMixTunnel: f.setupMixTunnel,
disconnectMixTunnel: f.disconnectMixTunnel,
getTunnelState: f.getTunnelState,
mixFetch: f.mixFetch,
mixDNS: d.mixDNS,
MixWebSocket: w.MixWebSocket,
} as unknown as PlaygroundMods;
}
// Fresh client-storage id per page load so a reload gets a clean identity and
// doesn't collide with the gateway connection from the previous load (which
// lingers in the gateway's post-disconnect grace window). See tunnel.rs:
// "Randomise per session to get a clean client".
//
// Called from a post-mount effect, never at module/render time: Math.random at
// render would differ between SSG and the client and trip React hydration.
export const randomClientId = () => `smolmix-playground-${Math.random().toString(36).slice(2, 8)}`;
export const clampSurbs = (n: number) => Math.min(50, Math.max(0, n));
export function formatSize(bytes: number): string {
if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + ' MB';
if (bytes >= 1024) return (bytes / 1024).toFixed(1) + ' KB';
return bytes + ' B';
}
export function formatRate(bytes: number, ms: number): string {
return (bytes / 1024 / (ms / 1000)).toFixed(1) + ' KB/s';
}
export function hexPreview(data: Uint8Array | ArrayBuffer, maxBytes = 64): string {
const bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
const len = Math.min(bytes.length, maxBytes);
const hex = Array.from(bytes.slice(0, len), (b) => b.toString(16).padStart(2, '0')).join(' ');
return bytes.length > maxBytes ? `${hex} ...` : hex;
}
export async function sha256hex(buf: ArrayBuffer): Promise<string> {
const hash = await crypto.subtle.digest('SHA-256', buf);
return Array.from(new Uint8Array(hash), (b) => b.toString(16).padStart(2, '0')).join('');
}
export function saveFile(buf: ArrayBuffer, filename: string, mimeType: string): void {
const blob = new Blob([buf], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
// The browser exposes no raw DNS API; "clearnet DNS" from JS is DoH (HTTPS) to
// a public resolver. Google's JSON API is CORS-friendly and returns
// { Status, Answer: [{ name, type, TTL, data }] } where type=1 is an A record.
// The request shows up in DevTools Network as a plain HTTPS fetch.
export async function dohResolve(hostname: string): Promise<string> {
const resp = await window.fetch(
`https://dns.google/resolve?name=${encodeURIComponent(hostname)}&type=A`,
{ mode: 'cors' },
);
const json = await resp.json();
if (json.Status !== 0) throw new Error(`DoH status=${json.Status}`);
const a = (json.Answer as Array<{ type: number; data: string }> | undefined)?.find(
(x) => x.type === 1,
);
if (!a) throw new Error('no A record');
return a.data;
}
// Stress-test request generation (uniform / mixed / drip), ported verbatim.
export interface StressRequest {
id: number;
url: string;
label: string;
}
const SIZE_PROFILES = [
{ label: 'tiny', bytes: 128 },
{ label: 'small', bytes: 1024 },
{ label: 'medium', bytes: 10240 },
{ label: 'large', bytes: 102400 },
{ label: 'xlarge', bytes: 1048576 },
];
function buildDripProfiles(timeoutSec: number) {
return [
{ label: 'safe', duration: Math.round(timeoutSec * 0.5), delay: 0, bytes: 100 },
{ label: 'boundary', duration: Math.round(timeoutSec * 0.92), delay: 0, bytes: 100 },
{ label: 'over', duration: Math.round(timeoutSec * 1.08), delay: 0, bytes: 100 },
{
label: 'slow-start',
duration: Math.round(timeoutSec * 0.83),
delay: Math.round(timeoutSec * 0.17),
bytes: 100,
},
];
}
export function generateRequests(
count: number,
mode: 'uniform' | 'mixed' | 'drip',
timeoutSec: number,
baseUrl: string,
): StressRequest[] {
const requests: StressRequest[] = [];
if (mode === 'uniform') {
for (let i = 1; i <= count; i++) requests.push({ id: i, url: `${baseUrl}${i}`, label: 'uniform' });
} else if (mode === 'mixed') {
for (let i = 1; i <= count; i++) {
const p = SIZE_PROFILES[Math.floor(Math.random() * SIZE_PROFILES.length)];
requests.push({ id: i, url: `https://httpbin.org/bytes/${p.bytes}`, label: p.label });
}
} else {
const profiles = buildDripProfiles(timeoutSec);
for (let i = 1; i <= count; i++) {
const p = profiles[Math.floor(Math.random() * profiles.length)];
requests.push({
id: i,
url: `https://httpbin.org/drip?duration=${p.duration}&numbytes=${p.bytes}&delay=${p.delay}&code=200`,
label: p.label,
});
}
}
return requests;
}
@@ -0,0 +1,139 @@
import React, { useEffect, useRef, useState } from 'react';
import { box, row, legend, sub, input, Button, LogPanel, StatusText, useLogs, type Status } from './ui';
// Raw mixnet messaging demo, styled to match the mix-* playground sections.
// Uses @nymproject/sdk-full-fat (a separate wasm client from the smolmix tunnel)
// to create a Nym client, send a message to a Nym address, and receive it.
//
// The SDK is imported dynamically on Connect, not at module scope: that keeps
// the page SSR/SSG-safe and means the second wasm runtime + gateway connection
// only load when the visitor opts in.
const nymApiUrl = 'https://validator.nymtech.net/api';
const preferredGateway = 'q2A2cbooyC16YJzvdYaSMH9X3cSiieZNtfBr8cE8Fi1';
// Minimal shape of the bits of NymMixnetClient we use, to type the `any` from
// the dynamic import.
interface MessagingClient {
client: {
start(opts: {
clientId: string;
nymApiUrl: string;
forceTls?: boolean;
preferredGateway?: string;
}): Promise<void>;
stop(): Promise<void>;
send(args: { payload: { message: string; mimeType: string }; recipient: string }): Promise<void>;
};
events: {
subscribeToLoaded(cb: (e: { args: unknown }) => void): void;
subscribeToConnected(cb: (e: { args: { address: string } }) => void): void;
subscribeToTextMessageReceivedEvent(cb: (e: { args: { payload: string } }) => void): void;
};
}
export function MessagingDemo() {
const { log, lines } = useLogs();
const [status, setStatus] = useState<Status>({ text: 'Not connected', colour: 'gray' });
const [connected, setConnected] = useState(false);
const [busy, setBusy] = useState(false);
const [selfAddress, setSelfAddress] = useState('');
const [recipient, setRecipient] = useState('');
const [message, setMessage] = useState('hello through the mixnet');
const clientRef = useRef<MessagingClient | null>(null);
useEffect(() => {
return () => {
clientRef.current?.client.stop().catch(() => {});
};
}, []);
async function connect() {
setBusy(true);
setStatus({ text: 'Loading SDK...', colour: 'orange' });
log('msg', 'Loading @nymproject/sdk-full-fat (wasm)...');
try {
// @ts-ignore -- published separately; dynamic import keeps the wasm off SSR and lazy
const mod = await import('@nymproject/sdk-full-fat');
const nym = (await mod.createNymMixnetClient()) as unknown as MessagingClient;
clientRef.current = nym;
nym.events.subscribeToLoaded(() => log('msg', 'client wasm loaded', 'green'));
nym.events.subscribeToConnected((e) => {
const addr = e.args.address;
setSelfAddress(addr);
setRecipient((r) => r || addr); // default to sending to yourself
setConnected(true);
setStatus({ text: 'Connected', colour: 'green' });
log('msg', `connected; self address: ${addr}`, 'green');
});
nym.events.subscribeToTextMessageReceivedEvent((e) => {
log('msg', `received: ${e.args.payload}`, 'green');
});
log('msg', 'Starting client and connecting to a gateway...');
setStatus({ text: 'Connecting to mixnet...', colour: 'orange' });
await nym.client.start({ clientId: crypto.randomUUID(), nymApiUrl, forceTls: true, preferredGateway });
} catch (err) {
setStatus({ text: 'Failed', colour: 'red' });
log('msg', `error: ${err instanceof Error ? err.message : String(err)}`, 'red');
setBusy(false);
}
setBusy(false);
}
async function send() {
const nym = clientRef.current;
if (!nym || !recipient || !message) return;
try {
await nym.client.send({ payload: { message, mimeType: 'text/plain' }, recipient });
log('msg', `sent: ${message}`);
} catch (err) {
log('msg', `send failed: ${err instanceof Error ? err.message : String(err)}`, 'red');
}
}
return (
<div style={box}>
<div style={legend}>Raw mixnet messaging</div>
<div style={sub}>
Creates a client with <code>@nymproject/sdk-full-fat</code> and sends a message to a Nym
address through the mixnet (defaults to your own address). This loads a separate wasm runtime
and connects its own client, so it is opt-in.
</div>
<div style={{ ...row, marginTop: '0.75rem' }}>
<Button onClick={connect} disabled={busy || connected}>
{busy ? 'Connecting...' : 'Connect'}
</Button>
<StatusText status={status} />
</div>
{selfAddress && (
<div style={{ ...sub, wordBreak: 'break-all', margin: '0 0 0.5rem' }}>
Your address: <code>{selfAddress}</code>
</div>
)}
<div style={row}>
<input
style={input}
value={recipient}
onChange={(e) => setRecipient(e.target.value)}
placeholder="recipient Nym address"
disabled={!connected}
/>
</div>
<div style={row}>
<input
style={input}
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="message"
disabled={!connected}
/>
<Button onClick={send} disabled={!connected}>
Send
</Button>
</div>
<LogPanel lines={lines('msg')} placeholder="Press Connect to create a mixnet client." />
</div>
);
}
@@ -0,0 +1,145 @@
// Shared presentational primitives for the playground sections (MixPlayground
// and the raw-messaging demo) so they share one look. Theme-neutral inline
// styles (rgba greys read on light and dark Nextra themes), a per-section log
// store, and an autoscrolling log panel.
import React, { useCallback, useEffect, useRef, useState } from 'react';
export type Colour = 'green' | 'red' | 'orange' | 'gray' | undefined;
export const COLOURS: Record<string, string> = {
green: '#16a34a',
red: '#dc2626',
orange: '#d97706',
gray: '#9ca3af',
};
export interface LogEntry {
ts: string;
msg: string;
colour?: Colour;
}
// One append-only buffer per section, keyed by section name. Red entries mirror
// to console.error so they sit alongside the Rust-side `[smolmix] ...` logs.
export function useLogs() {
const [store, setStore] = useState<Record<string, LogEntry[]>>({});
const log = useCallback((section: string, msg: string, colour?: Colour) => {
const ts = new Date().toISOString().slice(11, 23);
if (colour === 'red') console.error(`[smolmix-playground:${section}]`, msg);
setStore((s) => ({ ...s, [section]: [...(s[section] ?? []), { ts, msg, colour }] }));
}, []);
const lines = useCallback((section: string) => store[section] ?? [], [store]);
return { log, lines };
}
export const box: React.CSSProperties = {
border: '1px solid rgba(127,127,127,0.3)',
borderRadius: 8,
padding: '1rem',
margin: '1rem 0',
};
export const row: React.CSSProperties = {
display: 'flex',
gap: '0.5rem',
alignItems: 'center',
flexWrap: 'wrap',
marginBottom: '0.5rem',
};
export const btn: React.CSSProperties = {
padding: '0.35rem 0.8rem',
borderRadius: 6,
border: '1px solid rgba(127,127,127,0.4)',
background: 'transparent',
cursor: 'pointer',
fontSize: 14,
};
export const input: React.CSSProperties = {
padding: '0.35rem 0.6rem',
borderRadius: 6,
border: '1px solid rgba(127,127,127,0.4)',
background: 'transparent',
fontSize: 14,
flex: '1 1 14rem',
minWidth: 0,
};
export const num: React.CSSProperties = { ...input, flex: '0 0 5rem' };
export const legend: React.CSSProperties = { fontWeight: 600, marginBottom: '0.6rem' };
export const sub: React.CSSProperties = { fontSize: 12, opacity: 0.65 };
export function Button(props: React.ButtonHTMLAttributes<HTMLButtonElement>) {
return (
<button
{...props}
style={{ ...btn, ...(props.disabled ? { opacity: 0.4, cursor: 'not-allowed' } : {}) }}
/>
);
}
export function LogPanel({ lines, placeholder }: { lines: LogEntry[]; placeholder?: string }) {
const ref = useRef<HTMLPreElement>(null);
useEffect(() => {
if (ref.current) ref.current.scrollTop = ref.current.scrollHeight;
}, [lines]);
return (
<pre
ref={ref}
style={{
maxHeight: 220,
overflowY: 'auto',
padding: '0.6rem',
borderRadius: 6,
background: 'rgba(127,127,127,0.08)',
border: '1px solid rgba(127,127,127,0.25)',
fontSize: 12.5,
lineHeight: 1.5,
margin: '0.5rem 0 0',
whiteSpace: 'pre-wrap',
}}
>
{lines.length === 0
? placeholder ?? 'Idle.'
: lines.map((l, i) => (
<div key={i} style={l.colour ? { color: COLOURS[l.colour] } : undefined}>
[{l.ts}] {l.msg}
</div>
))}
</pre>
);
}
export interface Status {
text: string;
colour?: Colour;
}
export function StatusText({ status }: { status: Status }) {
return (
<span style={{ ...sub, color: status.colour ? COLOURS[status.colour] : undefined }}>
{status.text}
</span>
);
}
// A small CSS spinner with optional label. Used while a tunnel request is in
// flight, since mixFetch buffers the whole body and exposes no byte progress;
// the live transport detail goes to the browser console instead.
export function Spinner({ label }: { label?: string }) {
return (
<span style={{ ...sub, display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<style>{'@keyframes mixspin{to{transform:rotate(360deg)}}'}</style>
<span
aria-hidden
style={{
width: 11,
height: 11,
border: '2px solid rgba(127,127,127,0.35)',
borderTopColor: COLOURS.orange,
borderRadius: '50%',
display: 'inline-block',
animation: 'mixspin 0.7s linear infinite',
}}
/>
{label}
</span>
);
}
+10 -2
View File
@@ -22,10 +22,18 @@
*/
// nym-sdk / nym-bin-common / nym-network-defaults (Rust SDK crates)
export const NYM_SDK_VERSION = "1.21.0";
export const NYM_SDK_VERSION = "1.21.1";
// smolmix standalone crate
export const SMOLMIX_VERSION = "1.21.0";
export const SMOLMIX_VERSION = "1.21.1";
// TypeScript SDK packages (published to npm). mix-fetch is on its own 2.x track
// after the v1 to v2 break; the tunnel + mix-dns + mix-websocket facades share
// a 0.x line for now. Bump these to match the published npm versions.
export const MIX_FETCH_VERSION = "2.0.0";
export const MIX_TUNNEL_VERSION = "0.1.0";
export const MIX_DNS_VERSION = "0.1.0";
export const MIX_WEBSOCKET_VERSION = "0.1.0";
// Minimum supported Rust version (matches workspace rust-version in root Cargo.toml)
export const RUST_MSRV = "1.87";
+133 -21
View File
@@ -62,6 +62,107 @@ const config = {
permanent: true,
basePath: false,
},
// TS SDK reorg 2026-05: the per-package /developers/typescript/examples/*
// and /developers/typescript/playground/* pages were consolidated into
// top-level package pages (mix-fetch, mix-dns, mix-websocket) and
// typescript/quick-start, typescript/smart-contracts, typescript/cosmos-kit.
{
source: "/docs/developers/typescript/examples/mix-fetch",
destination: "/docs/developers/mix-fetch",
permanent: true,
basePath: false,
},
{
source: "/docs/developers/typescript/examples/mixnet",
destination: "/docs/developers/typescript/quick-start",
permanent: true,
basePath: false,
},
{
source: "/docs/developers/typescript/examples/nym-smart-contracts",
destination: "/docs/developers/typescript/smart-contracts",
permanent: true,
basePath: false,
},
{
source: "/docs/developers/typescript/examples/cosmos-kit",
destination: "/docs/developers/typescript/cosmos-kit",
permanent: true,
basePath: false,
},
{
source: "/docs/developers/typescript/examples",
destination: "/docs/developers/typescript",
permanent: true,
basePath: false,
},
{
source: "/docs/developers/typescript/playground/mixfetch",
destination: "/docs/developers/mix-fetch",
permanent: true,
basePath: false,
},
{
source: "/docs/developers/typescript/playground/traffic",
destination: "/docs/developers/typescript/quick-start",
permanent: true,
basePath: false,
},
{
source: "/docs/developers/typescript/playground/mixnodes",
destination: "/docs/developers/typescript/smart-contracts",
permanent: true,
basePath: false,
},
{
source: "/docs/developers/typescript/playground/wallet",
destination: "/docs/developers/typescript/cosmos-kit",
permanent: true,
basePath: false,
},
{
source: "/docs/developers/typescript/playground/cosmos-kit",
destination: "/docs/developers/typescript/cosmos-kit",
permanent: true,
basePath: false,
},
{
source: "/docs/developers/typescript/playground",
destination: "/docs/developers/typescript",
permanent: true,
basePath: false,
},
// The per-package typedoc dirs moved out of typescript/api/<pkg>/ to
// <pkg>/api/ so each package's API reference nests under its own sidebar
// entry (matches the rust/<module>/ pattern). @nymproject/sdk's typedoc
// stays at typescript/api/sdk/ since its landing is typescript.mdx.
{
source: "/docs/developers/typescript/api/mix-tunnel/:path*",
destination: "/docs/developers/mix-tunnel/api/:path*",
permanent: true,
basePath: false,
},
{
source: "/docs/developers/typescript/api/mix-fetch/:path*",
destination: "/docs/developers/mix-fetch/api/:path*",
permanent: true,
basePath: false,
},
{
source: "/docs/developers/typescript/api/mix-dns/:path*",
destination: "/docs/developers/mix-dns/api/:path*",
permanent: true,
basePath: false,
},
{
source: "/docs/developers/typescript/api/mix-websocket/:path*",
destination: "/docs/developers/mix-websocket/api/:path*",
permanent: true,
basePath: false,
},
{
source: "/docs/architecture/nym-vs-others.html",
destination: "/docs/network/overview/comparisons",
@@ -368,13 +469,13 @@ const config = {
},
{
source: "/developers/integrations/integration-options.html",
destination: "/docs/developers/integrations",
destination: "/docs/developers",
permanent: true,
basePath: false,
},
{
source: "/developers/faq/integrations-faq.html",
destination: "/docs/developers/integrations",
destination: "/docs/developers",
permanent: true,
basePath: false,
},
@@ -434,7 +535,7 @@ const config = {
},
{
source: "/developers/integrations",
destination: "/docs/developers/integrations/payment-integration.html",
destination: "/docs/developers",
permanent: true,
basePath: false,
},
@@ -794,7 +895,7 @@ const config = {
},
{
source: "/developers/faq/integrations-faq.html",
destination: "/docs/developers/integrations",
destination: "/docs/developers",
permanent: true,
basePath: false,
},
@@ -884,7 +985,7 @@ const config = {
},
{
source: "/developers/faq/integrations-faq.html",
destination: "/docs/developers/integrations",
destination: "/docs/developers",
permanent: true,
basePath: false,
},
@@ -1179,16 +1280,39 @@ const config = {
},
// Docs reorg: language-based sidebar
// Deleted routing pages → merged into integrations
// Deleted routing pages → merged into the developer overview
{
source: "/docs/developers/native",
destination: "/docs/developers/integrations",
destination: "/docs/developers",
permanent: true,
basePath: false,
},
{
source: "/docs/developers/browsers",
destination: "/docs/developers/integrations",
destination: "/docs/developers",
permanent: true,
basePath: false,
},
// --- Developers reorg (2026-06): single front door + collapsed mix-* pages ---
// integrations.mdx merged into the overview (index)
{
source: "/docs/developers/integrations",
destination: "/docs/developers",
permanent: true,
basePath: false,
},
// mix-dns concepts merged into its reference page
{
source: "/docs/developers/mix-dns/concepts",
destination: "/docs/developers/mix-dns/guides",
permanent: true,
basePath: false,
},
// mix-tunnel concepts removed; security lives in exit-security, architecture on its own page
{
source: "/docs/developers/mix-tunnel/concepts",
destination: "/docs/developers/mix-tunnel",
permanent: true,
basePath: false,
},
@@ -1208,7 +1332,7 @@ const config = {
},
{
source: "/docs/developers/typescript/api/mix-fetch",
destination: "/docs/developers/typescript/api/mix-fetch/globals",
destination: "/docs/developers/mix-fetch/api/globals",
permanent: false,
basePath: false,
},
@@ -1218,18 +1342,6 @@ const config = {
permanent: false,
basePath: false,
},
{
source: "/docs/developers/typescript/examples",
destination: "/docs/developers/typescript/examples/mix-fetch",
permanent: false,
basePath: false,
},
{
source: "/docs/developers/typescript/playground",
destination: "/docs/developers/typescript/playground/mixfetch",
permanent: false,
basePath: false,
},
{
source: "/docs/developers/typescript/api",
destination: "/docs/developers/typescript/api/sdk",
+4 -1
View File
@@ -39,7 +39,10 @@
"@nextui-org/accordion": "^2.0.40",
"@nextui-org/react": "^2.4.8",
"@nymproject/contract-clients": ">=1.2.4-rc.2 || ^1",
"@nymproject/mix-fetch-full-fat": "^1.4.3",
"@nymproject/mix-dns": "^0.1.0",
"@nymproject/mix-fetch": "^2.0.0",
"@nymproject/mix-tunnel": "^0.1.0",
"@nymproject/mix-websocket": "^0.1.0",
"@nymproject/sdk-full-fat": ">=1.5.1-rc.0 || ^1.4.1",
"@redocly/cli": "^1.25.15",
"@types/mdx": "^2.0.13",
+14 -4
View File
@@ -1,22 +1,32 @@
{
"index": "Overview",
"integrations": "Choosing an Approach",
"concepts": "Key Concepts",
"sep-intro": {
"type": "separator"
},
"--": {
"type": "separator",
"title": "Rust"
},
"smolmix": "smolmix",
"smolmix": "smolmix (TCP/UDP tunnel)",
"rust": "nym-sdk",
"-": {
"type": "separator",
"title": "TypeScript"
},
"mix-fetch": "mix-fetch",
"typescript": "TypeScript SDK",
"playground": "Playground (embedded clients)",
"mix-tunnel": "mix-tunnel (shared tunnel)",
"mix-fetch": "mix-fetch (HTTPS requests)",
"mix-dns": "mix-dns (DNS resolution)",
"mix-websocket": "mix-websocket (ws / wss)",
"mix-architecture": "mix-* Family Architecture",
"typescript": "Raw Messaging SDK",
"sep-extras": {
"type": "separator"
},
"---": {
"type": "separator",
"title": "Extras"
@@ -0,0 +1,3 @@
{
"nym-connect": "Nym Connect"
}
@@ -0,0 +1,20 @@
---
title: "Interacting with the Nyx Blockchain"
description: "Query and transact against Nyx, the Cosmos-SDK chain underpinning Nym: the nyxd CLI wallet, Ledger, the Cosmos chain registry, and running an RPC node."
schemaType: "TechArticle"
section: "Developers"
lastUpdated: "2026-06-06"
---
# Interacting with the Nyx blockchain
Nyx is the Cosmos-SDK blockchain that underpins Nym. It holds the NYM token and the mixnet smart contracts (node bonding, rewarding, and the directory). This section covers the ways to query it and submit transactions.
For smart-contract access from TypeScript, see [`@nymproject/contract-clients`](https://www.npmjs.com/package/@nymproject/contract-clients), covered under [Smart Contracts](/developers/typescript/smart-contracts) in the TypeScript SDK.
## In this section
- [CLI Wallet](/developers/chain/cli-wallet): use the `nyxd` binary to create keypairs and to sign and broadcast transactions from the command line.
- [Ledger Live](/developers/chain/ledger-live): use a Ledger hardware wallet with the Nyx chain.
- [Cosmos Registry](/developers/chain/cosmos-registry): Nyx's entry in the Cosmos chain registry (chain info and RPC endpoints).
- [RPC Nodes](/developers/chain/rpc-node): run a node that holds a copy of the chain for querying and broadcasting, without taking part in consensus.
@@ -0,0 +1,6 @@
{
"cli-wallet": "CLI Wallet",
"ledger-live": "Ledger Live",
"cosmos-registry": "Cosmos Registry",
"rpc-node": "RPC Nodes"
}
@@ -1,4 +1,5 @@
{
"socks5": "SOCKS Proxy",
"websocket": "Websocket"
"websocket": "Websocket",
"webassembly-client": "WebAssembly Client"
}
@@ -0,0 +1,4 @@
{
"exit-security": "Exit Security",
"message-queue": "Message Queue & Cover Traffic"
}
@@ -0,0 +1,97 @@
---
title: "Exit Security: What the Mixnet Protects and What It Doesn't"
description: "The canonical security model for traffic that leaves the Nym mixnet at an IPR exit gateway. Applies to smolmix, mix-tunnel, mix-fetch, mix-dns, and mix-websocket alike."
schemaType: "TechArticle"
section: "Developers"
lastUpdated: "2026-06-03"
---
# Exit security
import { Callout } from 'nextra/components'
Every tool that reaches an external service through the Nym mixnet shares the same security model, whether it's the Rust [`smolmix`](/developers/smolmix) crate or the mix-* packages built on [`mix-tunnel`](/developers/mix-tunnel) ([`mix-fetch`](/developers/mix-fetch), [`mix-dns`](/developers/mix-dns), [`mix-websocket`](/developers/mix-websocket)). They all exit the mixnet at an [IPR (Internet Packet Router)](/network/infrastructure/exit-services#ip-packet-router) gateway, so they inherit the same properties and the same single caveat. This page is the canonical statement of that model; the package pages link here rather than restating it.
## The one-sentence version
The mixnet hides **who** you are from the destination and **where** you're going from the network, but the exit gateway sees your **destination** and any payload you didn't encrypt yourself.
## Proxy mode or end-to-end?
This page is about **proxy mode**: your traffic leaves the mixnet at an IPR exit and continues to a third-party server over clearnet, where the security trade-offs apply.
If both ends run a Nym client (**end-to-end**), traffic never exits the mixnet. It stays Sphinx-encrypted from your client to the other client, there is no IPR, and the [encrypt-your-own-payload](#encrypt-your-own-payload) concern below does not arise: the mixnet is the encrypted channel. What still applies to end-to-end traffic is everything that is not exit-specific: the [trust boundaries](#trust-boundaries) (unlinkability is statistical, not absolute) and [what the mixnet does not protect](#what-the-mixnet-does-not-protect) (application identity, fingerprinting, traffic analysis). For the end-to-end wiring itself, see [`nym-sdk`](/developers/rust) and the [TypeScript SDK](/developers/typescript).
## What each hop sees
```text
you
│ Sphinx
entry gateway
│ Sphinx
3 mix layers
│ Sphinx
IPR exit gateway
│ plain IP (Sphinx removed here)
destination
```
| Segment | Mixnet encryption | What's visible |
|---|---|---|
| Your machine → mixnet entry | Sphinx (layered) | Entry gateway sees your IP but not the destination |
| Inside the mixnet (entry gateway + 3 mix layers) | Sphinx (layered) | Each node only knows its previous and next hop |
| Exit gateway (IPR) | Sphinx removed, raw IP packet exposed | IPR sees destination IP + port. Payload depends on your application layer (see below). |
| IPR → remote host | None (Sphinx is mixnet-only; your own TLS, if any, still applies) | Remote host sees the IPR's IP, not yours |
The Sphinx encryption is the **mixnet transport layer**: it protects packets as they traverse the mix nodes. At the exit gateway the Sphinx layers are stripped and the original IP packet is forwarded to the destination. This is analogous to how a Tor exit node or VPN endpoint unwraps its tunnel.
## Encrypt your own payload
Because the IPR removes the Sphinx layers, whatever is inside that IP packet is visible to the exit unless you encrypted it yourself.
- **Application-layer encryption closes the gap.** TLS, the Noise Protocol, or any authenticated encryption keeps the payload as ciphertext to the IPR. It still sees the destination IP and port, but not the content. Over TLS the IPR only ever handles ciphertext bound for that destination; the bytes inside stay opaque to it.
- **Unencrypted payloads are fully visible.** Plain HTTP, unencrypted WebSocket (`ws://`), and plain UDP DNS are readable in full at the exit. The mixnet still hides your identity, so the exit reads the content without being able to attribute it to you.
## Trust boundaries
- You trust the mixnet to provide unlinkability between sender and receiver. Sphinx provides this cryptographically at the per-packet level: a node cannot read addressing beyond its own hop. Unlinkability of your *traffic pattern* over time is weaker, and statistical rather than absolute. It comes from mixing and cover traffic, and degrades with low network traffic, with cover traffic or Poisson timing disabled, and against an adversary that can observe a large fraction of the network.
- You trust the IPR exit gateway in the same way you trust a VPN exit or Tor exit node: it can inspect your raw IP packets. The difference is that the IPR doesn't know who is sending the traffic (the mixnet hides your identity).
<Callout type="warning">
Treat the IPR exactly as you would a VPN exit or a Tor exit node: it can inspect your raw IP packets. The difference Nym adds is that the IPR doesn't know who's sending the traffic. Protect the payload with TLS or equivalent, and pin a trusted exit (`preferredIpr`) if the exit operator matters to you.
</Callout>
## What the mixnet does not protect
The mixnet operates at the **network layer**: it hides your IP and unlinks sender from receiver. It does nothing at the **application layer**, so anything you reveal in the content of your traffic is yours to manage:
- **Application identity.** If you log in, send a cookie, or include an API token, the destination knows who you are regardless of the network path. The mixnet anonymises the pipe, not what you put through it.
- **Fingerprinting.** A stable request pattern, a distinctive TLS or HTTP fingerprint, or a recognisable account correlates your traffic across sessions. `mix-fetch`'s [default headers](/developers/mix-fetch/guides#default-request-headers) reduce trivial fingerprinting but do not make you indistinguishable from a real browser.
Separately, the network-layer guarantee itself is not absolute:
- **Statistical traffic analysis.** Unlinkability is probabilistic, not a guarantee. It is strong by default but weakens with low network traffic, with cover traffic and Poisson timing turned off, and against an adversary observing a large fraction of the network.
If you need anonymity at the application layer too, design for it explicitly: fresh identities, no cross-session correlators, and no logged-in accounts you also use over clearnet.
## Comparison with other privacy tools
| | Nym (mixnet) | Tor | VPN |
|---|---|---|---|
| **Exit node sees traffic?** | Yes (encrypt it) | Yes (encrypt it) | Yes (encrypt it) |
| **Exit node knows sender?** | No (mixnet hides identity) | No (onion routing) | Yes (VPN provider knows) |
| **Timing analysis resistance** | Strong with defaults (mixing, cover traffic) | Weak (low-latency) | None |
| **UDP support** | Yes | No (TCP only) | Yes |
The timing-analysis rating assumes the defaults. Cover traffic and Poisson timing can be turned off to trade that resistance for latency and bandwidth, moving Nym's row toward Tor's. The named switches for this (`disableCoverTraffic` / `disablePoissonTraffic`) are specific to the browser/wasm packages ([`mix-tunnel`](/developers/mix-tunnel/guides#configuration) and the feature packages built on it); the native `smolmix` crate does not expose them by those names. The UDP row reflects a design difference, not a ranking: Tor is TCP-only by design, while the Nym IPR routes raw IP.
## Read more
The package pages add the parts specific to their transport (where TLS terminates, what the resolver sees, WSS vs `ws://`):
- [Exit Gateway Services](/network/infrastructure/exit-services#ip-packet-router): how the IPR allocates addresses and routes raw IP packets, and how it differs from the SOCKS-based Network Requester.
- The per-package "Security model" section on [mix-fetch](/developers/mix-fetch/concepts#security-model), [mix-dns](/developers/mix-dns/guides#security-model), and [mix-websocket](/developers/mix-websocket/concepts#security-model) for the transport-specific exposure.
@@ -82,7 +82,7 @@ sequenceDiagram
## What does `send()` do then?
When passing a message to a client (however you do it, either piping messages from an app to a standalone client or via one of the `send` functions exposed by the SDKs), you are **putting that message into the queue** to be source encrypted and sent in the future, in order to ensure that traffic leaving the client does so in a manner that to an external observer is uniform / does not create any 'burst' or change in traffic timings that could aid traffic analysis.
When passing a message to a client (however you do it, either piping messages from an app to a standalone client or via one of the `send` functions exposed by the SDKs), you are **putting that message into the queue** to be source-encrypted and sent later, so that traffic leaving the client stays uniform to an external observer and creates no burst or timing change that could aid traffic analysis.
## Note on Client Shutdown
Accidentally dropping a client before your message has been sent is possible and should be avoided (see the [troubleshooting guide](/developers/rust/mixnet/troubleshooting) for more on this). To avoid it:
+63 -15
View File
@@ -1,30 +1,78 @@
---
title: "Overview"
description: "Developer documentation index for the Nym mixnet: Rust and TypeScript SDKs, smolmix, mix-fetch, chain interaction, and CLI tools."
description: "Choose a Nym integration path by runtime and approach, then find the crate or package: nym-sdk, smolmix, mix-tunnel, mix-fetch, mix-dns, mix-websocket, and the TypeScript SDK."
schemaType: "TechArticle"
section: "Developers"
lastUpdated: "2026-05-12"
lastUpdated: "2026-06-09"
---
import { Callout } from 'nextra/components'
# Overview
This section covers the SDKs, standalone crates, blockchain interaction, and developer tools for building on the Nym mixnet.
Every Nym integration sends its traffic through the mixnet via a Nym client. Which crate or package you use comes down to two questions:
## Start here
1. **Runtime**: where does your code run?
2. **Approach**: do you control both sides of the connection (**end-to-end**), or are you reaching a third-party service through the mixnet (**proxy**)?
If you're new, read **[Choosing an Approach](/developers/integrations)** first. It maps your runtime (native vs browser vs mobile) and your architecture (end-to-end vs proxy) onto the right crate/library.
The table below maps those two answers to a package.
## Crates/Libraries
## Choosing a package
| Crate/library | Language | Use it for |
| Runtime | End-to-end (both sides run Nym) | Proxy (exit to clearnet) |
|---|---|---|
| [`nym-sdk`](/developers/rust) | Rust | E2E messaging, `AsyncRead`/`AsyncWrite` streams, client pooling. Start with the [Tour](/developers/rust/tour). |
| [`smolmix`](/developers/smolmix) | Rust | `TcpStream` and `UdpSocket` over the Mixnet via a userspace IP stack. Compatible with `tokio-rustls`, `hyper`, `tungstenite`. |
| [`mix-fetch`](/developers/mix-fetch) | TypeScript | `fetch()`-compatible API for browser HTTP(S) requests over the Mixnet. |
| [TypeScript SDK](/developers/typescript) | TypeScript | Browser-side Mixnet Client (raw messaging) and Nyx Smart Contracts. |
| [Standalone Clients](/developers/clients) | Language-agnostic | SOCKS5 and WebSocket binaries for piping traffic through the Mixnet without an SDK. |
| **Native Rust** (desktop / CLI / server) | [`nym-sdk`](/developers/rust): Mixnet, Stream, Client Pool | [`smolmix`](/developers/smolmix): `TcpStream` / `UdpSocket` · [`nym-sdk` SOCKS](/developers/rust) |
| **Browser / WebView** (JS + WASM) | [TypeScript SDK](/developers/typescript): `@nymproject/sdk` raw messaging | [`mix-fetch`](/developers/mix-fetch) HTTP/S · [`mix-dns`](/developers/mix-dns) DNS · [`mix-websocket`](/developers/mix-websocket) WS/WSS |
## Other sections
<Callout type="info">
**Mobile is a host, not a runtime.** The same phone can run either row. Compile the Rust SDK to a native library (`uniffi` plus [`cargo-swift`](https://github.com/antoniusnaumann/cargo-swift) for an iOS XCFramework, or [`cargo-ndk`](https://github.com/bbqsrc/cargo-ndk) for Android `jniLibs/`), or load the WASM packages inside a WebView (Capacitor, Cordova, Ionic, WKWebView, Android WebView). The SDK ships [FFI bindings](/developers/rust/ffi) for Go and C/C++ only; for Swift or Kotlin you generate your own from the [`sdk/ffi/shared`](https://github.com/nymtech/nym/tree/develop/sdk/ffi) uniffi crate. The WebView path needs no Nym-specific native code. On Android the native path has a [TLS bootstrap gotcha](/developers/rust/mixnet/troubleshooting#android-mixnet-bootstrap-fails-with-a-certificate-revoked--ocsp-error).
</Callout>
- **[Chain interaction](/developers/chain)**: query Nyx state, submit transactions, and call Nym smart contracts.
- **[APIs](/apis/introduction)**: auto-generated reference for Nym infrastructure HTTP endpoints.
## End-to-end or proxy
The runtime axis is about where your code runs: a native process has raw sockets and a filesystem, so it runs the full Rust client; a browser or WebView has neither (only WebSockets and `fetch`, under mixed-content rules), so it runs a WASM client inside a Web Worker. The approach axis is about who runs Nym at the other end.
**End-to-end**: both sides run a Nym client. Traffic stays Sphinx-encrypted the whole way ([what this protects](/developers/concepts/exit-security#proxy-mode-or-end-to-end)). Use it for peer-to-peer setups or anywhere you control both endpoints.
![](/images/developers/nym-arch-client-to-client.png)
**Proxy**: only your side runs Nym. Traffic exits the mixnet at an Exit Gateway and continues to the destination over the public internet. The mixnet anonymises the sender; protecting the payload (TLS, Noise) is your application's job, exactly as on a direct connection. Use it for third-party services such as blockchain RPCs or external APIs.
![](/images/developers/nym-arch-ip-routing.png)
<Callout type="warning">
Past the Exit Gateway, traffic travels the public internet like any other connection. The mixnet anonymises the sender but does not encrypt the payload beyond the gateway. Use TLS or another application-layer cipher. See [Exit security](/developers/concepts/exit-security) for what the exit can and cannot observe.
</Callout>
In a browser or WebView, your app talks to that WASM client through JS bindings rather than direct calls. The mixnet behaviour is identical in both modes, only the integration shape differs. See [mix-* architecture](/developers/mix-architecture) for the full picture.
![](/images/developers/nym-browser-arch.png)
## Packages
### Rust
| Crate | Use it for |
|---|---|
| [`nym-sdk`](/developers/rust) | End-to-end mixnet messaging, `AsyncRead`/`AsyncWrite` byte streams, client pooling. Start with the [Tour](/developers/rust/tour). |
| [`smolmix`](/developers/smolmix) | `TcpStream` and `UdpSocket` over the mixnet via a userspace IP stack. Compatible with `tokio-rustls`, `hyper`, `tokio-tungstenite`, and the rest of the async Rust ecosystem. |
### TypeScript
The four mix-* packages share one tunnel ([`mix-tunnel`](/developers/mix-tunnel)) and one WASM instance; install only what you need. See [mix-* architecture](/developers/mix-architecture) for how they're wired.
| Package | Use it for |
|---|---|
| [`mix-tunnel`](/developers/mix-tunnel) | The shared tunnel the three feature packages build on. Most apps don't import it directly. |
| [`mix-fetch`](/developers/mix-fetch) | Drop-in `fetch()` for HTTP and HTTPS through the mixnet. |
| [`mix-dns`](/developers/mix-dns) | Hostname-to-IP resolution through the mixnet. UDP DNS via the IPR. |
| [`mix-websocket`](/developers/mix-websocket) | WebSocket-like class for WS and WSS through the mixnet. |
| [TypeScript SDK](/developers/typescript) | `@nymproject/sdk`: end-to-end raw messaging when you control both ends. Smart contracts via `@nymproject/contract-clients`. |
### Standalone and other
| Resource | Use it for |
|---|---|
| [SOCKS5 / WebSocket clients](/developers/clients) | Language-agnostic binaries for piping traffic through the mixnet without an SDK. |
| [Chain interaction](/developers/chain) | Query Nyx state, submit transactions, call Nym smart contracts. |
| [APIs](/apis/introduction) | Auto-generated reference for Nym infrastructure HTTP endpoints. |
@@ -1,63 +0,0 @@
---
title: "Choosing an Approach"
description: "Decide which Nym integration path fits your project. Compare nym-sdk, smolmix, mix-fetch, and the TypeScript SDK by runtime environment and architecture."
schemaType: "TechArticle"
section: "Developers"
lastUpdated: "2026-05-12"
---
import { Callout } from 'nextra/components';
# Choosing an Approach
Any application that integrates with Nym sends its traffic through the Mixnet via a Nym client. The right product depends on two factors: your **environment** (where your code runs) and your **architecture** (whether you control both sides of the communication).
## At a glance
| | **End-to-end** (both sides run Nym) | **Proxy mode** (Nym → clearnet exit) |
|---|---|---|
| **Rust** (native / desktop / CLI) | [`nym-sdk`](/developers/rust) (Stream, Mixnet, Client Pool) | [`smolmix`](/developers/smolmix) (TCP / UDP) · [`nym-sdk`](/developers/rust) SOCKS client |
| **TypeScript** (browser) | [TypeScript SDK](/developers/typescript) (WASM Mixnet Client, messaging only) | [`mix-fetch`](/developers/mix-fetch) (HTTP) |
| **Mobile** (iOS / Android) | via [`nym-vpn-client`](#mobile) (uniffi + cargo-swift / cargo-ndk) | via [`nym-vpn-client`](#mobile) (uniffi + cargo-swift / cargo-ndk) |
## Environment
Different runtimes have different transport constraints: a browser cannot open raw sockets or access the filesystem, while a desktop app can.
- **Native / Desktop / CLI**: full access to system networking and persistent storage. Use [`nym-sdk`](/developers/rust) (the Rust SDK) for E2E messaging or byte streams, or [`smolmix`](/developers/smolmix) for TCP/UDP socket-shaped access in proxy mode.
- **Browser**: restricted to WebSockets, Web Transport, and `fetch`; HTTPS-only mixed-content rules; no filesystem access. Use [`mix-fetch`](/developers/mix-fetch) for HTTP(S) requests, or the [TypeScript SDK](/developers/typescript)'s WASM Mixnet Client for raw message passing.
### Mobile
There is no first-party mobile SDK, but [`nym-vpn-client`](https://github.com/nymtech/nym-vpn-client) ships production iOS and Android apps built around the Nym stack and is the reference we'd point you at. The relevant pieces are `nym-vpn-core/crates/nym-vpn-lib-uniffi` ([`uniffi`](https://mozilla.github.io/uniffi-rs/) FFI wrapper), `nym-vpn-core/iOS.mk` ([`cargo-swift`](https://github.com/antoniusnaumann/cargo-swift) → XCFramework + SwiftPM), and `nym-vpn-core/Android.mk` ([`cargo-ndk`](https://github.com/bbqsrc/cargo-ndk) → `jniLibs/`, driven from Gradle).
If you try this and hit (or solve) blockers, drop a note in the [Nym dev channel on Matrix](https://matrix.to/#/#dev:nymtech.chat) or open an issue on [GitHub](https://github.com/nymtech/nym).
## Architecture
The second factor is whether you control both sides of the communication.
**End-to-end (E2E)**: both sides run Nym clients. All traffic stays Sphinx-encrypted the entire way. Appropriate for peer-to-peer setups or any case where you control both endpoints.
![](/images/developers/nym-arch-client-to-client.png)
**Proxy**: only the client side runs Nym. Traffic exits the Mixnet at an Exit Gateway and continues to the destination over the public internet. The mixnet anonymises the sender; payload protection (TLS, Noise, etc.) is your application's job, as on a direct connection. Appropriate when connecting to third-party services such as blockchain RPCs or external APIs.
![](/images/developers/nym-arch-ip-routing.png)
<Callout type="warning">
Once traffic leaves the Exit Gateway, it travels over the public internet to the destination, exactly like any other server-initiated connection. The mixnet anonymises the sender but does not encrypt the payload past the gateway. Use TLS or another application-layer cipher as you would on a direct connection. See [Exit Gateway Services](/network/infrastructure/exit-services) for what the exit can and cannot observe.
</Callout>
**Browser apps**: both proxy and E2E modes work slightly differently in a browser setting. The Nym client runs as a WASM blob inside a Web Worker, and your application communicates with it via JS bindings rather than direct function calls. The mixnet behaviour is identical; the integration shape differs.
![](/images/developers/nym-browser-arch.png)
## Where to go next
- **Rust, E2E messaging or byte streams**: [`nym-sdk`](/developers/rust)
- **Rust, TCP/UDP socket replacements**: [`smolmix`](/developers/smolmix)
- **Browser, HTTP(S) requests**: [`mix-fetch`](/developers/mix-fetch)
- **Browser, raw mixnet messaging or Nyx smart contracts**: [TypeScript SDK](/developers/typescript)
- **Background on Sphinx, gateways, and the mixnet itself**: [Key Concepts](/developers/concepts)
@@ -0,0 +1,85 @@
---
title: "mix-* Architecture: How the Browser Mixnet Packages Are Wired"
description: "The shared browser architecture behind mix-tunnel, mix-fetch, mix-dns, and mix-websocket: one Web Worker, one WASM instance, a Comlink boundary, and a smoltcp + rustls stack reaching the mixnet via an IPR."
schemaType: "TechArticle"
section: "Developers"
lastUpdated: "2026-06-05"
---
import { Callout } from 'nextra/components'
# Architecture
The four mix-* packages ([`mix-tunnel`](/developers/mix-tunnel), [`mix-fetch`](/developers/mix-fetch), [`mix-dns`](/developers/mix-dns), and [`mix-websocket`](/developers/mix-websocket)) are not four independent clients. They are thin facades over a single shared tunnel, which runs in a Web Worker. This page explains that shared machinery once, so the per-package pages can stay focused on their own API.
```text
Main thread (your app)
mix-fetch, mix-dns, mix-websocket
│ each re-exports mix-tunnel's controls and calls into it
mix-tunnel → Comlink proxy
▼ postMessage (structured clone), into the worker
Web Worker (one per page)
smolmix-wasm
├─ IPR client → WebSocket (WSS) to entry gateway
├─ smoltcp (userspace TCP/IP stack)
└─ rustls (TLS, Mozilla CA bundle compiled in)
Nym mixnet: entry → 3 mix layers → IPR exit → internet
```
## The package family
Only `mix-tunnel` owns the tunnel. The three feature packages each depend on it and **re-export** its controls (`setupMixTunnel`, `disconnectMixTunnel`, and `getTunnelState`) alongside their own operation:
| Package | Adds | Re-exports from mix-tunnel |
|---|---|---|
| `mix-tunnel` | the tunnel itself | (owns them) |
| `mix-fetch` | `mixFetch`, `createMixFetch` | setup / disconnect / state |
| `mix-dns` | `mixDNS` | setup / disconnect / state |
| `mix-websocket` | `MixWebSocket` | setup / disconnect / state |
So `import { setupMixTunnel, mixFetch } from '@nymproject/mix-fetch'` and `import { setupMixTunnel } from '@nymproject/mix-tunnel'` reach the **same** `setupMixTunnel`. You rarely import `mix-tunnel` directly; you get it transitively through whichever feature package you use.
## One tunnel, one WASM instance
The bundler deduplicates [`@nymproject/mix-tunnel`](https://www.npmjs.com/package/@nymproject/mix-tunnel) to a single module, so no matter how many feature packages a page imports, there is exactly one Web Worker and one `smolmix-wasm` instance. Any feature package reaches that same instance through `getMixTunnel`. Bringing the tunnel up with `setupMixTunnel`, though, is a one-time operation: the first call succeeds and a second rejects with `tunnel already initialised`, so call it once.
This is why the tunnel is configured once, at the first `setupMixTunnel`, and why options passed to a later call have no effect until teardown. It is also why a single connection to the entry gateway, a single IPR exit, and a single DNS cache are shared across all your `mixFetch` / `mixDNS` / `MixWebSocket` traffic.
## The worker boundary
The mixnet work (Sphinx packet construction, cover traffic, Poisson send timing, the smoltcp poll loop) is CPU-bound and must not block the UI thread. So `mix-tunnel` runs all of it in a Web Worker and talks to it over [Comlink](https://github.com/GoogleChromeLabs/comlink), which wraps `postMessage` in an async RPC. The main thread holds a `Comlink.Remote` proxy; every call (`mixFetch`, `mixDNS`, `ws.send`) is an `await` that hops the worker boundary. That boundary is the reason `MixWebSocket.send()` and `.close()` return promises where the browser `WebSocket` returns `void`.
<Callout type="info">
**The `proxy` re-export.** `mix-websocket` needs to pass a message callback *into* the worker. Comlink marks a value as "transfer this by proxy, not by clone" using a `Symbol` that is created per module instance. If `mix-websocket` bundled its own copy of Comlink, that symbol would not match the one the worker-owning module's serialiser checks for, and the callback would fall through to structured clone, which cannot clone functions, so it throws. To avoid that, `mix-tunnel` re-exports `proxy`, and `mix-websocket` imports it from there, so both sides share one Comlink instance and the marker symbol matches.
</Callout>
## Inside the worker
The worker hosts `smolmix-wasm`, the WebAssembly build of the Rust [`smolmix`](/developers/smolmix) crate. Three pieces do the work:
- **IPR client**: opens a WebSocket (WSS by default, via `forceTls`) to a Nym entry gateway and speaks the mixnet protocol, exiting at an [IPR (Internet Packet Router)](/network/infrastructure/exit-services#ip-packet-router).
- **`smoltcp`**: a userspace TCP/IP stack. Because the browser exposes no raw sockets, smolmix runs its own TCP and UDP over the mixnet's IP transport. A reactor polls smoltcp and wakes the relevant tasks when data arrives.
- **`rustls`**: terminates TLS for `https://` and `wss://` end-to-end with the destination, with the Mozilla CA bundle compiled into the WASM. The IPR sees only ciphertext for encrypted targets.
The tunnel is **one-shot per WASM instance**: `setupMixTunnel` can initialise it once. After `disconnectMixTunnel`, the instance is spent and the page must reload to build a new tunnel.
## Tunnel lifecycle
```text
(no tunnel) ──setupMixTunnel()──▶ connecting ──▶ ready ──┐
│ mixFetch / mixDNS / MixWebSocket
shutdown ◀──disconnectMixTunnel()── (still ready)
```
`getTunnelState()` reflects this as `connecting | ready | shutting_down | shutdown | failed` (see [tunnel state](/developers/mix-tunnel/guides#tunnel-state); the diagram shows the happy path, `shutting_down` is the transient during teardown and `failed` carries a `reason`). The transitions are coarse; the fine-grained gateway, IPR-discovery, and smoltcp events are logged to the browser console when the tunnel is brought up with `debug: true`.
## Going deeper
- The native crate and its design: [`smolmix`](/developers/smolmix).
- The package sources, including bundler/WASM-inlining specifics and the worker plumbing: [`sdk/typescript/packages`](https://github.com/nymtech/nym/tree/develop/sdk/typescript/packages) and the WASM crate under [`wasm/smolmix`](https://github.com/nymtech/nym/tree/develop/wasm/smolmix).
- See it run: the [mixnet playground](/developers/playground).
@@ -0,0 +1,40 @@
---
title: "mix-dns: Hostname Resolution Over the Nym Mixnet"
description: "TypeScript package that resolves hostnames through the Nym mixnet as UDP DNS via an IPR exit gateway."
schemaType: "TechArticle"
section: "Developers"
lastUpdated: "2026-06-05"
---
# mix-dns
[`@nymproject/mix-dns`](https://www.npmjs.com/package/@nymproject/mix-dns) resolves hostnames to IPs through the Nym mixnet. The query travels as a UDP datagram to a public resolver via the [IPR (Internet Packet Router)](/network/infrastructure/exit-services#ip-packet-router) exit gateway, not through the browser or OS resolver.
```text
┌──────────────────────────────────────────────────────────────┐
│ Your browser app │
│ └─ mixDNS('example.com') │
│ └─ mix-tunnel (smolmix-wasm, Web Worker) │
│ └─ UDP datagram via the IPR │
│ └─ public resolver (default 8.8.8.8:53) │
└──────────────────────────────────────────────────────────────┘
```
The resolver sees a query from the IPR's IP, not yours, and the browser's own resolver path (the OS stub resolver, any local DoH) is bypassed entirely.
## When to use it
`mix-dns` is for cases where you need the resolved IP itself, not a connection that uses it. [`mix-fetch`](/developers/mix-fetch) and [`mix-websocket`](/developers/mix-websocket) already resolve via the mixnet internally; you don't need to call `mixDNS` before either.
Direct uses:
- Validate that a hostname resolves to an expected IP range before connecting through any path.
- Build IP-based allow / deny lists for an app that performs the connection itself.
- Probe whether a hostname is reachable from the IPR exit's perspective without opening a connection.
## In this section
- [Get started](/developers/mix-dns/get-started): install and resolve your first hostname.
- [Reference & security](/developers/mix-dns/guides): configure the resolver, and what it sees.
- [TypeDoc reference](/developers/mix-dns/api/globals): generated from the source.
- [Browser example](https://github.com/nymtech/nym/tree/develop/sdk/typescript/examples/mix-dns/browser): a runnable example app.
@@ -0,0 +1,5 @@
{
"get-started": "Get started",
"guides": "Reference & security",
"api": "TypeDoc Reference"
}
@@ -0,0 +1,5 @@
{
"globals": "API Index",
"functions": "Functions",
"interfaces": "Interfaces"
}
@@ -0,0 +1,6 @@
{
"disconnectMixTunnel": "disconnectMixTunnel",
"getTunnelState": "getTunnelState",
"mixDNS": "mixDNS",
"setupMixTunnel": "setupMixTunnel"
}
@@ -0,0 +1,19 @@
[**@nymproject/mix-dns**](../globals.md) • **Docs**
***
[@nymproject/mix-dns](../globals.md) / disconnectMixTunnel
# Function: disconnectMixTunnel()
> **disconnectMixTunnel**(): `Promise`\<`void`\>
Tear the tunnel down. After this, the WASM is unusable until page reload.
## Returns
`Promise`\<`void`\>
## Source
mix-tunnel/dist/esm/index.d.ts:16
@@ -0,0 +1,19 @@
[**@nymproject/mix-dns**](../globals.md) • **Docs**
***
[@nymproject/mix-dns](../globals.md) / getTunnelState
# Function: getTunnelState()
> **getTunnelState**(): `Promise`\<`TunnelState`\>
Inspect the current tunnel state. Pre-setup reads as `connecting`.
## Returns
`Promise`\<`TunnelState`\>
## Source
mix-tunnel/dist/esm/index.d.ts:18
@@ -0,0 +1,26 @@
[**@nymproject/mix-dns**](../globals.md) • **Docs**
***
[@nymproject/mix-dns](../globals.md) / mixDNS
# Function: mixDNS()
> **mixDNS**(`hostname`): `Promise`\<`string`\>
Resolve a hostname through the mixnet. Returns the IP as a string
(e.g. `"93.184.216.34"`).
The tunnel must already be set up via `setupMixTunnel()`.
## Parameters
**hostname**: `string`
## Returns
`Promise`\<`string`\>
## Source
[mix-dns/src/index.ts:23](https://github.com/nymtech/nym/blob/8ea9a230a7d5819511b34aec8d6705e038511ad3/sdk/typescript/packages/mix-dns/src/index.ts#L23)
@@ -0,0 +1,23 @@
[**@nymproject/mix-dns**](../globals.md) • **Docs**
***
[@nymproject/mix-dns](../globals.md) / setupMixTunnel
# Function: setupMixTunnel()
> **setupMixTunnel**(`opts`?): `Promise`\<`void`\>
Initialise the mixnet tunnel. Idempotent; safe to call from multiple feature packages.
## Parameters
**opts?**: [`SetupMixTunnelOpts`](../interfaces/SetupMixTunnelOpts.md)
## Returns
`Promise`\<`void`\>
## Source
mix-tunnel/dist/esm/index.d.ts:14
@@ -0,0 +1,16 @@
**@nymproject/mix-dns** • [**Docs**](globals.md)
***
# @nymproject/mix-dns
## Interfaces
- [SetupMixTunnelOpts](interfaces/SetupMixTunnelOpts.md)
## Functions
- [mixDNS](functions/mixDNS.md)
- [setupMixTunnel](functions/setupMixTunnel.md)
- [disconnectMixTunnel](functions/disconnectMixTunnel.md)
- [getTunnelState](functions/getTunnelState.md)
@@ -0,0 +1,167 @@
[**@nymproject/mix-dns**](../globals.md) • **Docs**
***
[@nymproject/mix-dns](../globals.md) / SetupMixTunnelOpts
# Interface: SetupMixTunnelOpts
## Properties
### preferredIpr?
> `optional` **preferredIpr**: `string`
#### Source
mix-tunnel/dist/esm/types.d.ts:2
***
### clientId?
> `optional` **clientId**: `string`
#### Source
mix-tunnel/dist/esm/types.d.ts:3
***
### forceTls?
> `optional` **forceTls**: `boolean`
#### Source
mix-tunnel/dist/esm/types.d.ts:4
***
### disablePoissonTraffic?
> `optional` **disablePoissonTraffic**: `boolean`
#### Source
mix-tunnel/dist/esm/types.d.ts:5
***
### disableCoverTraffic?
> `optional` **disableCoverTraffic**: `boolean`
#### Source
mix-tunnel/dist/esm/types.d.ts:6
***
### openReplySurbs?
> `optional` **openReplySurbs**: `number`
#### Source
mix-tunnel/dist/esm/types.d.ts:7
***
### dataReplySurbs?
> `optional` **dataReplySurbs**: `number`
#### Source
mix-tunnel/dist/esm/types.d.ts:8
***
### primaryDns?
> `optional` **primaryDns**: `string`
#### Source
mix-tunnel/dist/esm/types.d.ts:9
***
### fallbackDns?
> `optional` **fallbackDns**: `string`
#### Source
mix-tunnel/dist/esm/types.d.ts:10
***
### storagePassphrase?
> `optional` **storagePassphrase**: `string`
#### Source
mix-tunnel/dist/esm/types.d.ts:11
***
### connectTimeoutMs?
> `optional` **connectTimeoutMs**: `number`
#### Source
mix-tunnel/dist/esm/types.d.ts:12
***
### dnsTimeoutMs?
> `optional` **dnsTimeoutMs**: `number`
#### Source
mix-tunnel/dist/esm/types.d.ts:13
***
### tcpKeepaliveMs?
> `optional` **tcpKeepaliveMs**: `number`
#### Source
mix-tunnel/dist/esm/types.d.ts:14
***
### tcpBufferSize?
> `optional` **tcpBufferSize**: `number`
#### Source
mix-tunnel/dist/esm/types.d.ts:15
***
### maxRedirects?
> `optional` **maxRedirects**: `number`
#### Source
mix-tunnel/dist/esm/types.d.ts:16
***
### debug?
> `optional` **debug**: `boolean`
#### Source
mix-tunnel/dist/esm/types.d.ts:17
@@ -0,0 +1,3 @@
{
"SetupMixTunnelOpts": "SetupMixTunnelOpts"
}
@@ -0,0 +1,34 @@
---
title: "Get started with mix-dns"
description: "Install @nymproject/mix-dns and resolve a hostname through the Nym mixnet."
schemaType: "TechArticle"
section: "Developers"
lastUpdated: "2026-06-05"
---
# Get started
## Installation
```bash
npm install @nymproject/mix-dns
```
ESM only, with the worker and WASM inlined via [`mix-tunnel`](/developers/mix-tunnel/get-started#installation); no bundler config needed.
## Quick start
```ts
import { setupMixTunnel, mixDNS, disconnectMixTunnel } from '@nymproject/mix-dns';
await setupMixTunnel();
const ip = await mixDNS('example.com');
console.log(ip); // e.g. an IPv4 address string
await disconnectMixTunnel();
```
`mixDNS` returns the first resolved address as a string: an IPv4 A record when available, otherwise IPv6. It rejects if the hostname cannot be resolved. To resolve and immediately use the result via `mixFetch`, the simpler path is to skip `mixDNS` entirely and call `mixFetch('https://example.com')`, which handles resolution itself.
Resolve hostnames live in the [mixnet playground](/developers/playground), with a tunnel-vs-clearnet (DoH) comparison.
@@ -0,0 +1,35 @@
---
title: "mix-dns reference & security"
description: "Configure the DNS resolver used by mix-dns, and what the resolver sees through the IPR exit."
schemaType: "TechArticle"
section: "Developers"
lastUpdated: "2026-06-09"
---
import { Callout } from 'nextra/components'
# Reference & security
## Configuration
The DNS resolver is configured at tunnel setup, not per-call. Pass the resolver in `setupMixTunnel`:
```ts
await setupMixTunnel({
// Set the resolver explicitly. Defaults are 8.8.8.8:53 primary and
// 1.1.1.1:53 fallback. Both fields take a `host:port` socket address;
// fallbackDns is used if the primary fails to respond.
primaryDns: '8.8.8.8:53',
fallbackDns: '1.1.1.1:53',
});
```
The full options surface is documented under [`SetupMixTunnelOpts`](/developers/mix-tunnel/api/interfaces/SetupMixTunnelOpts).
## Security model
`mix-dns` follows the shared [mixnet exit security model](/developers/concepts/exit-security). The transport-specific exposure: at the IPR exit the query leaves as a plain UDP DNS request to the resolver, so the resolver sees the queried hostname and the IPR's IP, never yours. There is no TLS to terminate; the query and response are plaintext on the IPR-to-resolver leg.
<Callout type="warning">
At the resolver the query is plaintext UDP. The resolver can read the hostname you are looking up, while the mixnet keeps it from learning who you are. Choosing `8.8.8.8` vs `1.1.1.1` only changes which third party sees the queries; both see them coming from the IPR. To remove the resolver from your trust set, pick one you already trust, or layer DNS-over-HTTPS via `mixFetch` to a DoH endpoint instead of `mixDNS`.
</Callout>
+18 -114
View File
@@ -1,130 +1,34 @@
---
title: "mix-fetch: fetch() Over the Nym Mixnet"
description: "Package providing a fetch()-compatible API that routes HTTP(S) requests through the Nym mixnet via a Network Requester. Available for browsers and Node.js."
description: "Drop-in fetch() replacement that routes HTTP and HTTPS requests through the Nym mixnet via an IPR exit gateway."
schemaType: "TechArticle"
section: "Developers"
lastUpdated: "2026-05-12"
lastUpdated: "2026-06-05"
---
import { Callout } from 'nextra/components'
# mix-fetch
`mix-fetch` is a replacement for [`fetch`](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch) that routes HTTP(S) requests through the Nym mixnet. The call signature is identical; underneath, the request is tunnelled through a WASM Nym client to a Network Requester (a Nym service provider, typically operated by an Exit Gateway), which decodes a SOCKS5-shaped connect request and opens a TCP connection to the destination. TLS runs end-to-end between the WASM bundle and the destination server.
Available for browsers and Node.js, with the WASM core shared between both.
[`@nymproject/mix-fetch`](https://www.npmjs.com/package/@nymproject/mix-fetch) routes HTTP and HTTPS through the Nym mixnet behind the browser [`fetch`](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch) signature: `mixFetch(url, init)` returns the same `Response` you would get from `fetch(url, init)`. The request travels mixnet hops first, exits at an [IPR (Internet Packet Router)](/network/infrastructure/exit-services#ip-packet-router) gateway, and reaches the destination with the IPR's IP, not yours. It is not a perfect substitute for `fetch`: no cookies or credentials, no HTTP cache, no `AbortController`, and HTTPS-only in practice (plain HTTP is fully visible at the exit; see [drop-in caveats](/developers/mix-fetch/guides#drop-in-caveats)).
```text
┌────────────────────────────────────────────────────────────────────┐
│ Your app (browser or Node.js)
│ └─ mixFetch('https://...') (fetch() replacement)
│ └─ Go-WASM HTTP/TLS client (embedded Mozilla CA bundle)
│ └─ Rust-WASM SOCKS5 framing + Nym mixnet transport
│ └─ WebSocket entry gateway
│ └─ mixnet (Sphinx, 3 mix hops by default)
│ └─ Network Requester decodes SOCKS5
│ request, opens TCP to dest │
│ └─ destination server │
│ (TLS handshake here) │
│ Your browser app
│ └─ mixFetch('https://...')
│ └─ mix-tunnel (shared singleton, Web Worker, smolmix-wasm)
│ └─ smoltcp userspace TCP/IP + rustls TLS
│ └─ WebSocket to entry gateway │
│ └─ Nym mixnet (3 mix layers)
│ └─ IPR exit gateway → destination
└────────────────────────────────────────────────────────────────────┘
```
Two WASM modules sit in the bundle: a Go module that handles HTTP and TLS (Go's `crypto/tls` compiled to WASM, with an embedded Mozilla root CA list), and a Rust module that handles SOCKS5 request framing and the Nym mixnet client itself. They communicate via JS bindings.
TLS terminates end-to-end between the WASM bundle and the destination server. The IPR sees destination IP and port; for HTTPS targets, payload is TLS ciphertext.
Because TLS terminates at the destination (not at the Network Requester or any node before it), every hop from the entry gateway onwards only sees TLS ciphertext for HTTPS targets. This is the same trust model as a normal HTTPS request through a SOCKS proxy.
## In this section
<Callout type="info">
The "SOCKS5" framing here is Nym's binary `Socks5Request` format wrapped in Sphinx packets, not RFC 1928 SOCKS5 over a TCP socket. The Network Requester decodes it on the mixnet side and proxies onwards as regular TCP.
</Callout>
## Runtime and platform support
### Browser
The WASM core runs in a Web Worker and needs:
- WebSocket support, for the entry-gateway connection
- WebAssembly
- A CSP that permits `wss://` connections and `worker-src 'self'` (or `blob:` for the `*-full-fat` variants, which load workers as inline blobs)
Mixed-content rules apply: target URLs must be HTTPS.
### Node.js
The same WASM core runs in a `worker_threads` worker. The `ws` package polyfills `WebSocket`, and a Node-flavoured `comlink` adapter (`mix-fetch-node/src/node-adapter.ts`) bridges `worker_threads` to the same Worker-like API surface.
## Installation
### Browser variants
| Variant | Package | When to use |
|---|---|---|
| ESM | `@nymproject/mix-fetch` | Modern project, you can configure your bundler |
| ESM full-fat | `@nymproject/mix-fetch-full-fat` | Modern project, can't configure your bundler |
| CommonJS | `@nymproject/mix-fetch-commonjs` | Legacy project, you can configure your bundler |
| CommonJS full-fat | `@nymproject/mix-fetch-commonjs-full-fat` | Legacy project, can't configure your bundler |
### Node.js variant
| Variant | Package | When to use |
|---|---|---|
| CommonJS | `@nymproject/mix-fetch-node-commonjs` | Node.js (currently the only published Node variant) |
The standard browser variants need your bundler to handle WASM and web workers (see [Bundling](/developers/typescript/bundling)). The `*-full-fat` variants inline both as Base64 so no bundler configuration is needed.
<Callout type="warning">
The `*-full-fat` variants are large (~18 MB), since they inline ~10 MB of WASM (Go runtime + Rust core) and the web-worker source as Base64. Prefer a standard variant if bundle size matters.
</Callout>
```bash
# Browser
npm install @nymproject/mix-fetch-full-fat
# Node.js
npm install @nymproject/mix-fetch-node-commonjs
```
<Callout type="info">
`mixFetch` caps concurrent connections at **10 per destination host** (Go `http.Transport`'s `MaxConnsPerHost`, see `wasm/mix-fetch/go-mix-conn/internal/mixfetch/mixfetch.go:214`). Keep-alive is disabled, so each request opens a fresh TCP connection through the mixnet; extra concurrent requests to the same host queue until a slot frees. Different hosts are independent.
</Callout>
## Playground and examples
See the [interactive playground](/developers/typescript/playground/mixfetch) for a working `mixFetch` example you can run in the browser.
The first call bootstraps the WASM Nym client (gateway handshake, key generation, cover traffic). Subsequent calls reuse the active client; the Rust side holds it in a `OnceLock` singleton, so there is one client per page (or per Node process).
## When to use mix-fetch
| | mix-fetch | WASM Mixnet Client | smolmix | Plain fetch (no mixnet) |
|---|---|---|---|---|
| **Runtime** | Browser, Node.js | Browser | Native (Rust) | Anywhere |
| **Architecture** | Proxy (Network Requester → destination) | E2E (both sides Nym) | Proxy | Direct |
| **API shape** | `fetch()` replacement | Send/recv text or binary messages | `TcpStream` / `UdpSocket` | `fetch()` |
| **HTTP support** | Yes | No | Yes (via `hyper` over `TcpStream`) | Yes |
| **Sender unlinkability** | Strong (mixnet) | Strong (mixnet) | Strong (mixnet) | None |
| **Concurrency** | 10 per host | Unlimited | Unlimited | Unlimited |
## Security model
<Callout type="warning">
Use HTTPS targets. Plaintext HTTP requests are visible to the Network Requester and to any router between it and the destination.
</Callout>
### What's protected
| Segment | Mixnet encryption | What's visible |
|---|---|---|
| App → entry gateway | Sphinx (layered) over a WebSocket | Entry gateway sees your IP, not the destination |
| Inside the mixnet | Sphinx (layered) | Each node only knows previous / next hop |
| Network Requester | Sphinx removed; SOCKS5 connect request decoded | The Requester sees destination hostname + port; payload is application-layer TLS |
| Network Requester → destination | None (regular TCP) | TLS handshake + ciphertext (with HTTPS targets); cleartext (with HTTP targets) |
### Why mix-fetch ships its own CA store
The browser's TLS stack and CA store aren't accessible from JavaScript or from a WASM SOCKS client; on Node, the TLS stack lives outside the Web Worker that hosts the mixnet client. `mix-fetch` therefore performs TLS itself, inside the WASM bundle, against the destination server. The bundle ships with an embedded Mozilla root CA list (refreshed from [curl.se's bundle](https://curl.se/docs/caextract.html), verified by SHA-256 in `wasm/mix-fetch/go-mix-conn/scripts/update-root-certs.sh`) and an in-WASM TLS implementation (Go's `crypto/tls`, configured at `wasm/mix-fetch/go-mix-conn/internal/sslhelpers/ssl_helper.go`). The mixnet path sees encrypted TLS ciphertext, not plaintext.
`mix-fetch` handles the TLS layer for you: HTTPS targets are protected end-to-end between the WASM bundle and the destination, as if a browser had initiated the TLS handshake directly. Plaintext HTTP targets remain visible to the Network Requester and to any router beyond it. See [Exit Gateway Services](/network/infrastructure/exit-services) for what the exit can and cannot observe.
## API reference
Generated reference: [typedoc output](/developers/typescript/api/mix-fetch).
- [Get started](/developers/mix-fetch/get-started): install and make your first mixnet request.
- [Reference](/developers/mix-fetch/guides): request shape, default headers, drop-in caveats, configuration.
- [Concepts & security](/developers/mix-fetch/concepts): what the IPR exit sees.
- [Migrating from v1.x](/developers/mix-fetch/migration): the v1 to v2 clean break.
- [TypeDoc reference](/developers/mix-fetch/api/globals): generated from the source.
- [Browser example](https://github.com/nymtech/nym/tree/develop/sdk/typescript/examples/mix-fetch/browser): a runnable example app.
@@ -0,0 +1,7 @@
{
"get-started": "Get started",
"guides": "Reference",
"concepts": "Concepts & security",
"migration": "Migrating from v1.x",
"api": "TypeDoc Reference"
}
@@ -0,0 +1,5 @@
{
"globals": "API Index",
"functions": "Functions",
"interfaces": "Interfaces"
}
@@ -0,0 +1,7 @@
{
"createMixFetch": "createMixFetch",
"disconnectMixTunnel": "disconnectMixTunnel",
"getTunnelState": "getTunnelState",
"mixFetch": "mixFetch",
"setupMixTunnel": "setupMixTunnel"
}
@@ -0,0 +1,25 @@
[**@nymproject/mix-fetch**](../globals.md) • **Docs**
***
[@nymproject/mix-fetch](../globals.md) / createMixFetch
# Function: createMixFetch()
> **createMixFetch**(`opts`?): `Promise`\<(`url`, `init`?) => `Promise`\<`Response`\>\>
Convenience: set up the tunnel and return a fetch-bound function. Equivalent
to `await setupMixTunnel(opts); return mixFetch;`. Safe to call multiple
times; the underlying tunnel is a singleton.
## Parameters
**opts?**: [`SetupMixTunnelOpts`](../interfaces/SetupMixTunnelOpts.md)
## Returns
`Promise`\<(`url`, `init`?) => `Promise`\<`Response`\>\>
## Source
[mix-fetch/src/index.ts:62](https://github.com/nymtech/nym/blob/8ea9a230a7d5819511b34aec8d6705e038511ad3/sdk/typescript/packages/mix-fetch/src/index.ts#L62)
@@ -0,0 +1,19 @@
[**@nymproject/mix-fetch**](../globals.md) • **Docs**
***
[@nymproject/mix-fetch](../globals.md) / disconnectMixTunnel
# Function: disconnectMixTunnel()
> **disconnectMixTunnel**(): `Promise`\<`void`\>
Tear the tunnel down. After this, the WASM is unusable until page reload.
## Returns
`Promise`\<`void`\>
## Source
mix-tunnel/dist/esm/index.d.ts:16
@@ -0,0 +1,19 @@
[**@nymproject/mix-fetch**](../globals.md) • **Docs**
***
[@nymproject/mix-fetch](../globals.md) / getTunnelState
# Function: getTunnelState()
> **getTunnelState**(): `Promise`\<`TunnelState`\>
Inspect the current tunnel state. Pre-setup reads as `connecting`.
## Returns
`Promise`\<`TunnelState`\>
## Source
mix-tunnel/dist/esm/index.d.ts:18
@@ -0,0 +1,28 @@
[**@nymproject/mix-fetch**](../globals.md) • **Docs**
***
[@nymproject/mix-fetch](../globals.md) / mixFetch
# Function: mixFetch()
> **mixFetch**(`url`, `init`?): `Promise`\<`Response`\>
Fetch over the mixnet. Drop-in replacement for the browser `fetch()`.
Requires the tunnel to be up: call `setupMixTunnel(opts)` first, or use
`createMixFetch(opts)` to combine setup + fetch.
## Parameters
**url**: `string`
**init?**: `RequestInit`
## Returns
`Promise`\<`Response`\>
## Source
[mix-fetch/src/index.ts:39](https://github.com/nymtech/nym/blob/8ea9a230a7d5819511b34aec8d6705e038511ad3/sdk/typescript/packages/mix-fetch/src/index.ts#L39)
@@ -0,0 +1,23 @@
[**@nymproject/mix-fetch**](../globals.md) • **Docs**
***
[@nymproject/mix-fetch](../globals.md) / setupMixTunnel
# Function: setupMixTunnel()
> **setupMixTunnel**(`opts`?): `Promise`\<`void`\>
Initialise the mixnet tunnel. Idempotent; safe to call from multiple feature packages.
## Parameters
**opts?**: [`SetupMixTunnelOpts`](../interfaces/SetupMixTunnelOpts.md)
## Returns
`Promise`\<`void`\>
## Source
mix-tunnel/dist/esm/index.d.ts:14
@@ -0,0 +1,17 @@
**@nymproject/mix-fetch** • [**Docs**](globals.md)
***
# @nymproject/mix-fetch
## Interfaces
- [SetupMixTunnelOpts](interfaces/SetupMixTunnelOpts.md)
## Functions
- [mixFetch](functions/mixFetch.md)
- [createMixFetch](functions/createMixFetch.md)
- [setupMixTunnel](functions/setupMixTunnel.md)
- [disconnectMixTunnel](functions/disconnectMixTunnel.md)
- [getTunnelState](functions/getTunnelState.md)
@@ -0,0 +1,167 @@
[**@nymproject/mix-fetch**](../globals.md) • **Docs**
***
[@nymproject/mix-fetch](../globals.md) / SetupMixTunnelOpts
# Interface: SetupMixTunnelOpts
## Properties
### preferredIpr?
> `optional` **preferredIpr**: `string`
#### Source
mix-tunnel/dist/esm/types.d.ts:2
***
### clientId?
> `optional` **clientId**: `string`
#### Source
mix-tunnel/dist/esm/types.d.ts:3
***
### forceTls?
> `optional` **forceTls**: `boolean`
#### Source
mix-tunnel/dist/esm/types.d.ts:4
***
### disablePoissonTraffic?
> `optional` **disablePoissonTraffic**: `boolean`
#### Source
mix-tunnel/dist/esm/types.d.ts:5
***
### disableCoverTraffic?
> `optional` **disableCoverTraffic**: `boolean`
#### Source
mix-tunnel/dist/esm/types.d.ts:6
***
### openReplySurbs?
> `optional` **openReplySurbs**: `number`
#### Source
mix-tunnel/dist/esm/types.d.ts:7
***
### dataReplySurbs?
> `optional` **dataReplySurbs**: `number`
#### Source
mix-tunnel/dist/esm/types.d.ts:8
***
### primaryDns?
> `optional` **primaryDns**: `string`
#### Source
mix-tunnel/dist/esm/types.d.ts:9
***
### fallbackDns?
> `optional` **fallbackDns**: `string`
#### Source
mix-tunnel/dist/esm/types.d.ts:10
***
### storagePassphrase?
> `optional` **storagePassphrase**: `string`
#### Source
mix-tunnel/dist/esm/types.d.ts:11
***
### connectTimeoutMs?
> `optional` **connectTimeoutMs**: `number`
#### Source
mix-tunnel/dist/esm/types.d.ts:12
***
### dnsTimeoutMs?
> `optional` **dnsTimeoutMs**: `number`
#### Source
mix-tunnel/dist/esm/types.d.ts:13
***
### tcpKeepaliveMs?
> `optional` **tcpKeepaliveMs**: `number`
#### Source
mix-tunnel/dist/esm/types.d.ts:14
***
### tcpBufferSize?
> `optional` **tcpBufferSize**: `number`
#### Source
mix-tunnel/dist/esm/types.d.ts:15
***
### maxRedirects?
> `optional` **maxRedirects**: `number`
#### Source
mix-tunnel/dist/esm/types.d.ts:16
***
### debug?
> `optional` **debug**: `boolean`
#### Source
mix-tunnel/dist/esm/types.d.ts:17
@@ -0,0 +1,3 @@
{
"SetupMixTunnelOpts": "SetupMixTunnelOpts"
}
@@ -0,0 +1,24 @@
---
title: "mix-fetch concepts & security"
description: "What the IPR exit sees when you route HTTP through mix-fetch, and what TLS keeps private."
schemaType: "TechArticle"
section: "Developers"
lastUpdated: "2026-06-05"
---
import { Callout } from 'nextra/components'
# Concepts & security
## Security model
`mix-fetch` follows the shared [mixnet exit security model](/developers/concepts/exit-security): the IPR exit sees your destination, and you rely on TLS to keep the payload as ciphertext to it. What that means specifically for HTTP/S:
| At the IPR exit | What's visible |
|---|---|
| HTTPS (`https://`) | Destination IP and port. Payload is TLS ciphertext, terminating at the destination rather than the IPR. |
| HTTP (`http://`) | Destination IP and port, plus the full request and response in plaintext. |
<Callout type="warning">
TLS terminates inside the WASM instance (via [`rustls`](https://docs.rs/rustls) in smolmix-wasm), not in the browser. The Mozilla CA bundle is compiled into the WASM. Mixed content rules still apply at the page level, so serve your app over HTTPS.
</Callout>
@@ -0,0 +1,46 @@
---
title: "Get started with mix-fetch"
description: "Install @nymproject/mix-fetch and make your first HTTP request through the Nym mixnet."
schemaType: "TechArticle"
section: "Developers"
lastUpdated: "2026-06-05"
---
# Get started
## Installation
```bash
npm install @nymproject/mix-fetch
```
ESM only, with the worker and WASM inlined via [`mix-tunnel`](/developers/mix-tunnel/get-started#installation); no bundler config needed.
## Quick start
```ts
import { setupMixTunnel, mixFetch, disconnectMixTunnel } from '@nymproject/mix-fetch';
// Bring the shared mixnet tunnel up. Same call works from mix-dns and mix-websocket.
await setupMixTunnel();
// Drop-in fetch. The Response is the real DOM Response, not a wrapper.
const res = await mixFetch('https://example.com');
console.log(res.status, await res.text());
// Tear down. The WASM is unusable after this until page reload.
await disconnectMixTunnel();
```
For one-shot use without an explicit setup step, the `createMixFetch` helper combines setup and fetch:
```ts
import { createMixFetch } from '@nymproject/mix-fetch';
const mixFetch = await createMixFetch({ disableCoverTraffic: true });
const res = await mixFetch('https://example.com');
```
Call `createMixFetch` once and reuse the function it returns. It calls `setupMixTunnel` internally, so calling `createMixFetch` a second time rejects with `tunnel already initialised`; the tunnel is [one-shot per page](/developers/mix-tunnel/get-started).
Run `mixFetch` live in the [mixnet playground](/developers/playground), with a tunnel-vs-clearnet comparison.
@@ -0,0 +1,110 @@
---
title: "mix-fetch guides"
description: "Request shape, default headers, drop-in caveats, and tunnel configuration for mix-fetch."
schemaType: "TechArticle"
section: "Developers"
lastUpdated: "2026-06-05"
---
import { Callout } from 'nextra/components'
# Reference
## Request shape
The `init` argument is the standard [`RequestInit`](https://developer.mozilla.org/en-US/docs/Web/API/RequestInit). Headers, method, and body all work. `AbortController` (`signal`) is not supported: an in-flight request cannot be cancelled.
```ts
const res = await mixFetch('https://httpbin.org/post', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hello: 'mixnet' }),
});
console.log(await res.json());
```
Binary responses come back via the standard `Response.arrayBuffer()` / `Response.blob()` methods:
```ts
const res = await mixFetch('https://example.com/image.png');
const blob = await res.blob();
```
Repeated headers (`Set-Cookie`, `Vary`, `Link`, `WWW-Authenticate`) are preserved. The wasm side returns headers as a `[name, value]` pair sequence, which `Headers` reconstructs verbatim.
## Default request headers
When the caller doesn't set them, `mixFetch` injects four browser-shape headers before the request leaves the tunnel. The shim exists because many CDNs (cloudflare's bot management) and host policies (wikimedia's User-Agent policy) reject requests that look unlike a real browser. Caller-supplied values always win.
| Header | Injected default |
|--------|------------------|
| `User-Agent` | `Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36` |
| `Accept` | `text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8` |
| `Accept-Language` | `en-US,en;q=0.9` |
| `Accept-Encoding` | `identity` |
`Accept-Encoding` is forced to `identity` rather than `gzip, deflate, br` because the wasm build has no decompressor. Advertising compression would let the server return a compressed body the wasm build cannot decode, so `Response.text()` or `.json()` would see raw compressed bytes. Responses therefore arrive uncompressed, so large text or JSON bodies transfer more bytes over the slower mixnet path.
To override any of these, set the header in the `init.headers` bag like normal:
```ts
const res = await mixFetch('https://example.com', {
headers: { 'User-Agent': 'my-app/1.0' },
});
```
<Callout type="info">
The shim does not attempt full browser impersonation. TLS fingerprint (JA3), HTTP/2, and header ordering are still distinguishable from a real Chrome request. If you need stronger blend-in, you'll need to handle that at the application or destination layer.
</Callout>
## Drop-in caveats
`mixFetch` matches the `fetch()` call signature but is not a perfect substitute. The differences are intentional and follow from running outside the browser's networking stack:
| Difference | What it means | What to do |
|---|---|---|
| **No same-origin restriction** | Requests aren't subject to browser CORS preflight. The IPR honours its exit policy regardless of `Origin`. | Don't rely on CORS as an access-control mechanism for `mixFetch` requests; treat them as you would server-to-server calls. |
| **No cookies / credentials** | The browser cookie jar is not shared with the WASM instance. `credentials: 'include'` has no effect. | Pass auth tokens via `Authorization` or other explicit headers. |
| **No HTTP cache** | The browser HTTP cache is not consulted. Every call hits the network. | Cache responses at the application layer if needed. |
| **No service-worker interception** | Requests don't pass through any `fetch` event handlers registered by service workers. | n/a |
| **HTTPS only in practice** | The IPR sees plaintext HTTP in full. | Always target `https://` URLs. |
## Errors
`mixFetch` follows `fetch` semantics for HTTP status: a 4xx or 5xx response **resolves** with a `Response` carrying that status, so check `response.ok` or `response.status` yourself. The promise **rejects** only on a transport-level failure: a connection or TLS failure, a DNS failure, or the IPR refusing the destination under its exit policy. A rejection is a plain `Error` whose message describes the cause; there is no typed error class, so match on the message if you need to branch.
## Timeouts and cancellation
There is no per-request timeout, and `AbortController` / `signal` is ignored: an in-flight `mixFetch` cannot be cancelled. To bound how long you wait, race it against a timer. This stops you waiting but does not cancel the underlying request:
```ts
const res = await Promise.race([
mixFetch(url),
new Promise<Response>((_, reject) =>
setTimeout(() => reject(new Error('mixFetch timeout')), 30_000),
),
]);
```
Connection and DNS timeouts at the tunnel level are set once via `connectTimeoutMs` and `dnsTimeoutMs` in [`SetupMixTunnelOpts`](/developers/mix-tunnel/api/interfaces/SetupMixTunnelOpts).
## Configuration
`setupMixTunnel(opts)` (and `createMixFetch(opts)`) accept the shared tunnel options from [`@nymproject/mix-tunnel`](/developers/mix-tunnel/guides#configuration). The most commonly touched are:
```ts
await setupMixTunnel({
// Pin a specific IPR (otherwise auto-discovered from the topology).
preferredIpr: 'D1rrUqJY9pesL3pTaMaxLnpZGGYQ4ZpZwpQXCqaeBXTW.6PpFkRvF...',
// Lower latency and bandwidth at the cost of traffic-analysis resistance.
disableCoverTraffic: true,
disablePoissonTraffic: true,
});
```
The full option surface is documented under [`SetupMixTunnelOpts`](/developers/mix-tunnel/api/interfaces/SetupMixTunnelOpts).
<Callout type="info">
The first `mixFetch` call after `setupMixTunnel()` may take a few seconds: gateway handshake, IPR discovery, and the first DNS resolution all happen on demand. Subsequent calls reuse the tunnel and complete in roughly the time of a normal HTTPS request plus mixnet latency.
</Callout>
@@ -0,0 +1,72 @@
---
title: "mix-fetch: Migrating from v1.x to v2"
description: "What changed between mix-fetch v1 and v2: removed packages, the new setup options, dropped request-init flags, and the underlying architecture change to a single WASM module exiting via an IPR."
schemaType: "TechArticle"
section: "Developers"
lastUpdated: "2026-05-29"
---
# Migrating from v1.x
v2 is a clean break. The package no longer ships a Go-WASM HTTP/TLS client or a SOCKS5-shaped Network Requester request; it routes through the shared [`mix-tunnel`](/developers/mix-tunnel) and exits at an IPR. If you're starting fresh, you don't need this page; see [mix-fetch](/developers/mix-fetch).
## Removed packages
The five-variant publish (`@nymproject/mix-fetch`, `mix-fetch-full-fat`, `mix-fetch-commonjs`, `mix-fetch-commonjs-full-fat`, `mix-fetch-node-commonjs`) is gone. There is one package, ESM only:
| v1.x | v2.0 |
|---|---|
| `@nymproject/mix-fetch` (ESM) | `@nymproject/mix-fetch` |
| `@nymproject/mix-fetch-full-fat` | `@nymproject/mix-fetch` (always inlined) |
| `@nymproject/mix-fetch-commonjs` | (no CJS build) |
| `@nymproject/mix-fetch-commonjs-full-fat` | (no CJS build) |
| `@nymproject/mix-fetch-node-commonjs` | (no Node build) |
The single v2 package is ESM-only and always inlines its wasm (the v1 `full-fat` behaviour is now the default), so there is no separate bundler-config step. CommonJS and Node builds are not shipped. The Node build in particular is not just a packaging variant: the mixnet transport runs on browser globals (`WebSocket`, Web Worker) inside the wasm, so a Node target would need a runtime-compatibility layer, not a CommonJS rebuild. If you need a CJS or Node variant, open an issue describing the use case.
## Removed options
The v1 setup options bag covered Network Requester selection and gateway tuning. v2 replaces it with the smolmix [`SetupMixTunnelOpts`](/developers/mix-tunnel/api/interfaces/SetupMixTunnelOpts) surface:
| v1 option | v2 equivalent |
|---|---|
| `clientId` | retained as `clientId` in `SetupMixTunnelOpts` (defaults to `smolmix-wasm` if unset) |
| `preferredGateway` | (replaced by `preferredIpr`, the exit-side pin) |
| `preferredNetworkRequester` | (no replacement: v2 exits via IPR, not Network Requester) |
| `mixFetchOverride.requestTimeoutMs` | (no replacement at TS layer: surface via smolmix-wasm config if needed) |
| `forceTls: true` | retained as `forceTls` in `SetupMixTunnelOpts` (defaults to WSS; you rarely need to set it) |
| `extra.hiddenGateways` | (no replacement) |
## Removed request-init flags
The v1 `mode: 'unsafe-ignore-cors'` flag is gone. v2 doesn't perform browser-side CORS checks at all (see [Drop-in caveats](/developers/mix-fetch/guides#drop-in-caveats)), so the flag has no meaning.
## New setup
```ts
// v1
import { createMixFetch } from '@nymproject/mix-fetch-full-fat';
const mixFetch = await createMixFetch({
clientId: 'my-app',
preferredGateway: 'q2A2cbooyC16YJzvdYaSMH9X3cSiieZNtfBr8cE8Fi1',
mixFetchOverride: { requestTimeoutMs: 60_000 },
forceTls: true,
});
// v2
import { createMixFetch } from '@nymproject/mix-fetch';
const mixFetch = await createMixFetch({
// Optional: pin an exit IPR instead of an entry gateway.
// preferredIpr: '...',
});
```
## Architectural change
v1 ran two WASM modules: a Go module with `crypto/tls` + Mozilla CA bundle, and a Rust module handling Nym's `Socks5Request` framing to a Network Requester exit. v2 runs one WASM module ([smolmix-wasm](https://github.com/nymtech/nym/tree/develop/wasm/smolmix)) with a userspace TCP/IP stack ([smoltcp](https://docs.rs/smoltcp)) and `rustls` for TLS. The exit is an IPR, not a Network Requester.
Consequences:
- **One WASM module, smaller bundle.** v1's Go runtime accounted for ~6 MB of the full-fat bundle; v2 drops it.
- **Shared infrastructure with `mix-dns` and `mix-websocket`.** The same tunnel handles all three.
- **IPR exit policies apply.** What was allowed by your previous Network Requester may not be allowed by your default IPR, which applies the current [Nym exit policy](https://nymtech.net/.wellknown/network-requester/exit-policy.txt).

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