Files
nym/documentation/docs/pages/developers/rust/mixnet/tutorial.mdx
T
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

333 lines
10 KiB
Plaintext

---
title: "Mixnet Tutorial: Send Your First Private Message"
description: "Step-by-step Rust tutorial to connect to the Nym mixnet, send and receive messages, reply anonymously with SURBs, and persist client identity."
schemaType: "HowTo"
section: "Developers"
lastUpdated: "2026-04-17"
---
# Tutorial: Send Your First Private Message
import { Callout } from 'nextra/components'
import { CodeVerified } from '../../../../components/code-verified'
import { RUST_MSRV } from '../../../../components/versions'
A program that sends a Sphinx-encrypted message to itself through the Nym Mixnet, receives it, and replies anonymously using SURBs. Later sections cover persistent identity and concurrent send/receive.
Requires Rust {RUST_MSRV}+ and an internet connection (clients connect to the live Mixnet).
<CodeVerified />
## Step 1: Set up the project
```sh
cargo init nym-mixnet-demo
cd nym-mixnet-demo
```
Add dependencies to `Cargo.toml`:
```toml
[dependencies]
nym-sdk = "1.21.1"
nym-bin-common = { version = "1.21.1", features = ["basic_tracing"] }
tokio = { version = "1", features = ["full"] }
```
## Step 2: Connect and send
Replace the contents of `src/main.rs`:
```rust
use nym_sdk::mixnet::{self, MixnetMessageSender};
#[tokio::main]
async fn main() {
nym_bin_common::logging::setup_tracing_logger();
// connect_new() creates an ephemeral client; keys are generated in
// memory and discarded on disconnect.
let mut client = mixnet::MixnetClient::connect_new().await.unwrap();
let our_address = client.nym_address();
println!("Connected: {our_address}");
// The message is Sphinx-encrypted and mixed across 5 nodes.
// send_plain_message only blocks until the message is queued;
// encryption and mixing happen in background tasks.
client
.send_plain_message(*our_address, "hello from the mixnet!")
.await
.unwrap();
println!("Sent, waiting for arrival...");
```
<Callout type="info">
`setup_tracing_logger()` shows what the SDK is doing under the hood: gateway connections, topology fetches, Sphinx packet encryption. If the output is too verbose, comment out the line or filter with `RUST_LOG=warn cargo run`.
</Callout>
## Step 3: Receive
```rust
// wait_for_messages() returns the next batch of incoming messages.
// Filter empty messages: these are SURB replenishment requests.
let message = loop {
if let Some(msgs) = client.wait_for_messages().await {
if let Some(msg) = msgs.into_iter().find(|m| !m.message.is_empty()) {
break msg;
}
}
};
println!("Received: {}", String::from_utf8_lossy(&message.message));
```
## Step 4: Reply anonymously
Every message includes a `sender_tag`, an opaque `AnonymousSenderTag` that lets you reply **without knowing the sender's address**. The SDK bundles SURBs (Single Use Reply Blocks) with every outgoing message by default:
```rust
let sender_tag = message.sender_tag.expect("should have sender tag");
// send_reply uses the SURB; the sender's address is never revealed.
client.send_reply(sender_tag, "hello back, anonymously!").await.unwrap();
let reply = loop {
if let Some(msgs) = client.wait_for_messages().await {
if let Some(msg) = msgs.into_iter().find(|m| !m.message.is_empty()) {
break msg;
}
}
};
println!("Reply: {}", String::from_utf8_lossy(&reply.message));
client.disconnect().await;
}
```
## Step 5: Run it
```sh
RUST_LOG=info cargo run
```
```
Connected: 8gk4Y...@2xU4d...
Sent, waiting for arrival...
Received: hello from the mixnet!
Reply: hello back, anonymously!
```
## Going further: persist your identity
The ephemeral client above generates a new address on every run. To keep the same address across restarts, use `MixnetClientBuilder` with on-disk storage:
```rust
use nym_sdk::mixnet::{self, MixnetMessageSender, StoragePaths};
#[tokio::main]
async fn main() {
// Keys are generated on first run, then loaded from disk on subsequent runs.
let paths = StoragePaths::new_from_dir("./my-client-data").unwrap();
let mut client = mixnet::MixnetClientBuilder::new_with_default_storage(paths)
.await
.unwrap()
.build()
.unwrap()
.connect_to_mixnet()
.await
.unwrap();
let our_address = client.nym_address();
println!("Address: {our_address}");
// Same API as before: send, receive, SURB reply.
client
.send_plain_message(*our_address, "hello from persistent client!")
.await
.unwrap();
println!("Sent, waiting for arrival...");
let message = loop {
if let Some(msgs) = client.wait_for_messages().await {
if let Some(msg) = msgs.into_iter().find(|m| !m.message.is_empty()) {
break msg;
}
}
};
println!("Received: {}", String::from_utf8_lossy(&message.message));
let sender_tag = message.sender_tag.expect("should have sender tag");
client.send_reply(sender_tag, "anonymous reply!").await.unwrap();
let reply = loop {
if let Some(msgs) = client.wait_for_messages().await {
if let Some(msg) = msgs.into_iter().find(|m| !m.message.is_empty()) {
break msg;
}
}
};
println!("Reply: {}", String::from_utf8_lossy(&reply.message));
// Always disconnect for clean shutdown; background tasks need to be
// stopped and state files flushed.
client.disconnect().await;
}
```
Run it twice; the address stays the same.
## Going further: send and receive from different tasks
Add `futures` to your `Cargo.toml`:
```toml
futures = "0.3"
```
Use `split_sender()` to get a clone-able send handle for use in separate tasks:
```rust
use futures::StreamExt;
use nym_sdk::mixnet::{self, MixnetMessageSender};
#[tokio::main]
async fn main() {
let mut client = mixnet::MixnetClient::connect_new().await.unwrap();
let addr = *client.nym_address();
// split_sender() returns a clone-able MixnetClientSender.
let sender = client.split_sender();
// Spawn a receiver: the original client implements futures::Stream.
let rx = tokio::spawn(async move {
if let Some(msg) = client.next().await {
println!("Received: {}", String::from_utf8_lossy(&msg.message));
}
client.disconnect().await;
});
// Spawn a sender on a different task.
let tx = tokio::spawn(async move {
sender.send_plain_message(addr, "hello from another task!").await.unwrap();
});
tx.await.unwrap();
rx.await.unwrap();
}
```
## What's happening underneath
`connect_new()` generates an ephemeral identity (ed25519 + x25519 keypair), fetches the current network topology, selects a gateway, and opens a persistent WebSocket connection. `send_plain_message()` wraps the payload in Sphinx packets, layered encryption where each of the 5 Mix Nodes can only decrypt one layer and learn the next hop, never the full route. `wait_for_messages()` drains a local queue fed by the gateway; messages arrive out of order by design, to defeat timing analysis.
SURBs (Single Use Reply Blocks) are pre-computed return routes bundled with each outgoing message. The recipient uses them to reply without learning the sender's address. Each is single-use; the SDK replenishes them automatically.
`split_sender()` clones the send channel while the original client retains the receive side. Both halves can run on separate tokio tasks without synchronization.
## Complete code
### Ephemeral client
New address on every run, good for quick experiments:
```rust
use nym_sdk::mixnet::{self, MixnetMessageSender};
#[tokio::main]
async fn main() {
nym_bin_common::logging::setup_tracing_logger();
let mut client = mixnet::MixnetClient::connect_new().await.unwrap();
let our_address = client.nym_address();
println!("Connected: {our_address}");
client
.send_plain_message(*our_address, "hello from the mixnet!")
.await
.unwrap();
println!("Sent, waiting for arrival...");
let message = loop {
if let Some(msgs) = client.wait_for_messages().await {
if let Some(msg) = msgs.into_iter().find(|m| !m.message.is_empty()) {
break msg;
}
}
};
println!("Received: {}", String::from_utf8_lossy(&message.message));
let sender_tag = message.sender_tag.expect("should have sender tag");
client.send_reply(sender_tag, "hello back, anonymously!").await.unwrap();
let reply = loop {
if let Some(msgs) = client.wait_for_messages().await {
if let Some(msg) = msgs.into_iter().find(|m| !m.message.is_empty()) {
break msg;
}
}
};
println!("Reply: {}", String::from_utf8_lossy(&reply.message));
client.disconnect().await;
}
```
### Persistent identity
Same address across restarts. Use this for real applications:
```rust
use nym_sdk::mixnet::{self, MixnetMessageSender, StoragePaths};
#[tokio::main]
async fn main() {
nym_bin_common::logging::setup_tracing_logger();
let paths = StoragePaths::new_from_dir("./my-client-data").unwrap();
let mut client = mixnet::MixnetClientBuilder::new_with_default_storage(paths)
.await
.unwrap()
.build()
.unwrap()
.connect_to_mixnet()
.await
.unwrap();
let our_address = client.nym_address();
println!("Address: {our_address}");
client
.send_plain_message(*our_address, "hello from persistent client!")
.await
.unwrap();
println!("Sent, waiting for arrival...");
let message = loop {
if let Some(msgs) = client.wait_for_messages().await {
if let Some(msg) = msgs.into_iter().find(|m| !m.message.is_empty()) {
break msg;
}
}
};
println!("Received: {}", String::from_utf8_lossy(&message.message));
let sender_tag = message.sender_tag.expect("should have sender tag");
client.send_reply(sender_tag, "anonymous reply!").await.unwrap();
let reply = loop {
if let Some(msgs) = client.wait_for_messages().await {
if let Some(msg) = msgs.into_iter().find(|m| !m.message.is_empty()) {
break msg;
}
}
};
println!("Reply: {}", String::from_utf8_lossy(&reply.message));
client.disconnect().await;
}
```