7c890ea0c5
* 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
333 lines
10 KiB
Plaintext
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;
|
|
}
|
|
```
|