Rust SDK
The Rust SDK allows developers building applications in Rust to import and interact with Nym clients as they would any other dependency, instead of running the client as a seperate process on their machine. This makes both developing and running applications much easier, reducing complexity in the development process (not having to restart another client in a seperate console window/tab) and being able to have a single binary for other people to use.
Currently developers can use the Rust SDK to import either websocket client (nym-client
) or socks-client
functionality into their Rust code.
Development status
The SDK is still somewhat a work in progress: interfaces are fairly stable but still may change in subsequent releases.
The nym-sdk
crate is not yet availiable via crates.io. As such, in order to import the crate you must specify the Nym monorepo in your Cargo.toml
file:
nym-sdk = { git = "https://github.com/nymtech/nym" }
In order to generate the crate docs run cargo doc --open
from nym/sdk/rust/nym-sdk/
In the future the SDK will be made up of several components, each of which will allow developers to interact with different parts of Nym’s infrastructure.
Component | Functionality | Released |
---|---|---|
Mixnet | Create / load clients & keypairs, subscribe to Mixnet events, send & receive messages | ✔️ |
Coconut | Create & verify Coconut credentials | 🛠️ |
Validator | Sign & broadcast Nyx blockchain transactions, query the blockchain | ❌ |
The mixnet
component currently exposes the logic of two clients: the websocket client, and the socks client.
The coconut
component is currently being worked on. Right now it exposes logic allowing for the creation of coconut credentials on the Sandbox testnet.
Websocket client examples
All the codeblocks below can be found in the
nym-sdk
examples directory in the monorepo. Just navigate tonym/sdk/rust/nym-sdk/examples/
and run the files from there. If you wish to run these outside of the workspace - such as if you want to use one as the basis for your own project - then make sure to import thesdk
,tokio
, andnym_bin_common
crates.
Different message types
There are two methods for sending messages through the mixnet using your client:
send_plain_message()
is the most simple: pass the recipient address and the message you wish to send as a string (this was previouslysend_str()
). This is a nicer-to-use wrapper aroundsend_message()
.send_message()
allows you to also define the amount of SURBs to send along with your message (which is sent as bytes).
Simple example
Lets look at a very simple example of how you can import and use the websocket client in a piece of Rust code (examples/simple.rs
):
use nym_sdk::mixnet;
use nym_sdk::mixnet::MixnetMessageSender;
#[tokio::main]
async fn main() {
nym_bin_common::logging::setup_logging();
// Passing no config makes the client fire up an ephemeral session and figure shit out on its own
let mut client = mixnet::MixnetClient::connect_new().await.unwrap();
// Be able to get our client address
let our_address = client.nym_address();
println!("Our client nym address is: {our_address}");
// Send a message throught the mixnet to ourselves
client
.send_plain_message(*our_address, "hello there")
.await
.unwrap();
println!("Waiting for message (ctrl-c to exit)");
client
.on_messages(|msg| println!("Received: {}", String::from_utf8_lossy(&msg.message)))
.await;
}
Simply importing the nym_sdk
crate into your project allows you to create a client and send traffic through the mixnet.
Creating and storing keypairs
The example above involves ephemeral keys - if we want to create and then maintain a client identity over time, our code becomes a little more complex as we need to create, store, and conditionally load these keys (examples/builder_with_storage
):
use nym_sdk::mixnet;
use nym_sdk::mixnet::MixnetMessageSender;
use std::path::PathBuf;
#[tokio::main]
async fn main() {
nym_bin_common::logging::setup_logging();
// Specify some config options
let config_dir = PathBuf::from("/tmp/mixnet-client");
let storage_paths = mixnet::StoragePaths::new_from_dir(&config_dir).unwrap();
// Create the client with a storage backend, and enable it by giving it some paths. If keys
// exists at these paths, they will be loaded, otherwise they will be generated.
let client = mixnet::MixnetClientBuilder::new_with_default_storage(storage_paths)
.await
.unwrap()
.build()
.await
.unwrap();
// Now we connect to the mixnet, using keys now stored in the paths provided.
let mut client = client.connect_to_mixnet().await.unwrap();
// Be able to get our client address
let our_address = client.nym_address();
println!("Our client nym address is: {our_address}");
// Send a message throught the mixnet to ourselves
client
.send_plain_message(*our_address, "hello there")
.await
.unwrap();
println!("Waiting for message");
if let Some(received) = client.wait_for_messages().await {
for r in received {
println!("Received: {}", String::from_utf8_lossy(&r.message));
}
}
client.disconnect().await;
}
As seen in the example above, the mixnet::MixnetClientBuilder::new()
function handles checking for keys in a storage location, loading them if present, or creating them and storing them if not, making client key management very simple.
Assuming our client config is stored in /tmp/mixnet-client
, the following files are generated:
$ tree /tmp/mixnet-client
mixnet-client
├── ack_key.pem
├── db.sqlite
├── db.sqlite-shm
├── db.sqlite-wal
├── gateway_details.json
├── gateway_shared.pem
├── persistent_reply_store.sqlite
├── private_encryption.pem
├── private_identity.pem
├── public_encryption.pem
└── public_identity.pem
1 directory, 11 files
Manually handling storage
If you’re integrating mixnet functionality into an existing app and want to integrate saving client configs and keys into your existing storage logic, you can manually perform the actions taken automatically above (examples/manually_handle_keys_and_config.rs
)
use nym_client_core::client::base_client::storage::gateway_details::{
GatewayDetailsStore, PersistedGatewayDetails,
};
use nym_sdk::mixnet::{
self, EmptyReplyStorage, EphemeralCredentialStorage, KeyManager, KeyStore, MixnetClientStorage,
MixnetMessageSender,
};
use nym_topology::provider_trait::async_trait;
#[tokio::main]
async fn main() {
nym_bin_common::logging::setup_logging();
// Just some plain data to pretend we have some external storage that the application
// implementer is using.
let mock_storage = MockClientStorage::empty();
let mut client = mixnet::MixnetClientBuilder::new_with_storage(mock_storage)
.build()
.await
.unwrap()
.connect_to_mixnet()
.await
.unwrap();
// Be able to get our client address
let our_address = client.nym_address();
println!("Our client nym address is: {our_address}");
// Send important info up the pipe to a buddy
client
.send_plain_message(*our_address, "hello there")
.await
.unwrap();
println!("Waiting for message");
if let Some(received) = client.wait_for_messages().await {
for r in received {
println!("Received: {}", String::from_utf8_lossy(&r.message));
}
}
client.disconnect().await;
}
#[allow(unused)]
struct MockClientStorage {
pub key_store: MockKeyStore,
pub gateway_details_store: MockGatewayDetailsStore,
pub reply_store: EmptyReplyStorage,
pub credential_store: EphemeralCredentialStorage,
}
impl MockClientStorage {
fn empty() -> Self {
Self {
key_store: MockKeyStore,
gateway_details_store: MockGatewayDetailsStore,
reply_store: EmptyReplyStorage::default(),
credential_store: EphemeralCredentialStorage::default(),
}
}
}
impl MixnetClientStorage for MockClientStorage {
type KeyStore = MockKeyStore;
type ReplyStore = EmptyReplyStorage;
type CredentialStore = EphemeralCredentialStorage;
type GatewayDetailsStore = MockGatewayDetailsStore;
fn into_runtime_stores(self) -> (Self::ReplyStore, Self::CredentialStore) {
(self.reply_store, self.credential_store)
}
fn key_store(&self) -> &Self::KeyStore {
&self.key_store
}
fn reply_store(&self) -> &Self::ReplyStore {
&self.reply_store
}
fn credential_store(&self) -> &Self::CredentialStore {
&self.credential_store
}
fn gateway_details_store(&self) -> &Self::GatewayDetailsStore {
&self.gateway_details_store
}
}
struct MockKeyStore;
#[async_trait]
impl KeyStore for MockKeyStore {
type StorageError = MyError;
async fn load_keys(&self) -> Result<KeyManager, Self::StorageError> {
println!("loading stored keys");
Err(MyError)
}
async fn store_keys(&self, _keys: &KeyManager) -> Result<(), Self::StorageError> {
println!("storing keys");
Ok(())
}
}
struct MockGatewayDetailsStore;
#[async_trait]
impl GatewayDetailsStore for MockGatewayDetailsStore {
type StorageError = MyError;
async fn load_gateway_details(&self) -> Result<PersistedGatewayDetails, Self::StorageError> {
println!("loading stored gateway details");
Err(MyError)
}
async fn store_gateway_details(
&self,
_details: &PersistedGatewayDetails,
) -> Result<(), Self::StorageError> {
println!("storing gateway details");
Ok(())
}
}
//
// struct MockReplyStore;
//
// #[async_trait]
// impl ReplyStorageBackend for MockReplyStore {
// type StorageError = MyError;
//
// async fn flush_surb_storage(
// &mut self,
// _storage: &CombinedReplyStorage,
// ) -> Result<(), Self::StorageError> {
// todo!()
// }
//
// async fn init_fresh(&mut self, _fresh: &CombinedReplyStorage) -> Result<(), Self::StorageError> {
// todo!()
// }
//
// async fn load_surb_storage(&self) -> Result<CombinedReplyStorage, Self::StorageError> {
// todo!()
// }
// }
//
// struct MockCredentialStore;
//
// #[async_trait]
// impl CredentialStorage for MockCredentialStore {
// type StorageError = MyError;
//
// async fn insert_coconut_credential(
// &self,
// _voucher_value: String,
// _voucher_info: String,
// _serial_number: String,
// _binding_number: String,
// _signature: String,
// _epoch_id: String,
// ) -> Result<(), Self::StorageError> {
// todo!()
// }
//
// async fn get_next_coconut_credential(&self) -> Result<CoconutCredential, Self::StorageError> {
// todo!()
// }
//
// async fn consume_coconut_credential(&self, id: i64) -> Result<(), Self::StorageError> {
// todo!()
// }
// }
#[derive(thiserror::Error, Debug)]
#[error("foobar")]
struct MyError;
Anonymous replies with SURBs
Both functions used to send messages through the mixnet (send_message
and send_plain_message
) send a pre-determined number of SURBs along with their messages by default.
The number of SURBs is set here.
const DEFAULT_NUMBER_OF_SURBS: u32 = 5;
You can read more about how SURBs function under the hood here.
In order to reply to an incoming message using SURBs, you can construct a recipient
from the sender_tag
sent along with the message you wish to reply to:
use nym_sdk::mixnet::{
AnonymousSenderTag, MixnetClientBuilder, MixnetMessageSender, ReconstructedMessage,
StoragePaths,
};
use std::path::PathBuf;
#[tokio::main]
async fn main() {
nym_bin_common::logging::setup_logging();
// Specify some config options
let config_dir = PathBuf::from("/tmp/surb-example");
let storage_paths = StoragePaths::new_from_dir(&config_dir).unwrap();
// Create the client with a storage backend, and enable it by giving it some paths. If keys
// exists at these paths, they will be loaded, otherwise they will be generated.
let client = MixnetClientBuilder::new_with_default_storage(storage_paths)
.await
.unwrap()
.build()
.await
.unwrap();
// Now we connect to the mixnet, using keys now stored in the paths provided.
let mut client = client.connect_to_mixnet().await.unwrap();
// Be able to get our client address
let our_address = client.nym_address();
println!("\nOur client nym address is: {our_address}");
// Send a message through the mixnet to ourselves using our nym address
client
.send_plain_message(*our_address, "hello there")
.await
.unwrap();
// we're going to parse the sender_tag (AnonymousSenderTag) from the incoming message and use it to 'reply' to ourselves instead of our Nym address.
// we know there will be a sender_tag since the sdk sends SURBs along with messages by default.
println!("Waiting for message\n");
// get the actual message - discard the empty vec sent along with a potential SURB topup request
let mut message: Vec<ReconstructedMessage> = Vec::new();
while let Some(new_message) = client.wait_for_messages().await {
if new_message.is_empty() {
continue;
}
message = new_message;
break;
}
let mut parsed = String::new();
if let Some(r) = message.first() {
parsed = String::from_utf8(r.message.clone()).unwrap();
}
// parse sender_tag: we will use this to reply to sender without needing their Nym address
let return_recipient: AnonymousSenderTag = message[0].sender_tag.unwrap();
println!(
"\nReceived the following message: {} \nfrom sender with surb bucket {}",
parsed, return_recipient
);
// reply to self with it: note we use `send_str_reply` instead of `send_str`
println!("Replying with using SURBs");
client
.send_reply(return_recipient, "hi an0n!")
.await
.unwrap();
println!("Waiting for message (once you see it, ctrl-c to exit)\n");
client
.on_messages(|msg| println!("\nReceived: {}", String::from_utf8_lossy(&msg.message)))
.await;
}
Importing and using a custom network topology
If you want to send traffic through a sub-set of nodes (for instance, ones you control, or a small test setup) when developing, debugging, or performing research, you will need to import these nodes as a custom network topology, instead of grabbing it from the Mainnet Nym-API
(examples/custom_topology_provider.rs
).
There are two ways to do this:
Import a custom Nym API endpoint
If you are also running a Validator and Nym API for your network, you can specify that endpoint as such and interact with it as clients usually do (under the hood):
// Copyright 2023 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use nym_sdk::mixnet;
use nym_sdk::mixnet::MixnetMessageSender;
use nym_topology::provider_trait::{async_trait, TopologyProvider};
use nym_topology::{nym_topology_from_detailed, NymTopology};
use url::Url;
struct MyTopologyProvider {
validator_client: nym_validator_client::client::NymApiClient,
}
impl MyTopologyProvider {
fn new(nym_api_url: Url) -> MyTopologyProvider {
MyTopologyProvider {
validator_client: nym_validator_client::client::NymApiClient::new(nym_api_url),
}
}
async fn get_topology(&self) -> NymTopology {
let mixnodes = self
.validator_client
.get_cached_active_mixnodes()
.await
.unwrap();
// in our topology provider only use mixnodes that have mix_id divisible by 3
// and have more than 100k nym (i.e. 100'000'000'000 unym) in stake
// why? because this is just an example to showcase arbitrary uses and capabilities of this trait
let filtered_mixnodes = mixnodes
.into_iter()
.filter(|mix| {
mix.mix_id() % 3 == 0 && mix.total_stake() > "100000000000".parse().unwrap()
})
.collect::<Vec<_>>();
let gateways = self.validator_client.get_cached_gateways().await.unwrap();
nym_topology_from_detailed(filtered_mixnodes, gateways)
}
}
#[async_trait]
impl TopologyProvider for MyTopologyProvider {
// this will be manually refreshed on a timer specified inside mixnet client config
async fn get_new_topology(&mut self) -> Option<NymTopology> {
Some(self.get_topology().await)
}
}
#[tokio::main]
async fn main() {
nym_bin_common::logging::setup_logging();
let nym_api = "https://validator.nymtech.net/api/".parse().unwrap();
let my_topology_provider = MyTopologyProvider::new(nym_api);
// Passing no config makes the client fire up an ephemeral session and figure things out on its own
let mut client = mixnet::MixnetClientBuilder::new_ephemeral()
.custom_topology_provider(Box::new(my_topology_provider))
.build()
.await
.unwrap()
.connect_to_mixnet()
.await
.unwrap();
let our_address = client.nym_address();
println!("Our client nym address is: {our_address}");
// Send a message through the mixnet to ourselves
client
.send_plain_message(*our_address, "hello there")
.await
.unwrap();
println!("Waiting for message (ctrl-c to exit)");
client
.on_messages(|msg| println!("Received: {}", String::from_utf8_lossy(&msg.message)))
.await;
}
Import a specific topology manually
If you aren’t running a Validator and Nym API, and just want to import a specific sub-set of mix nodes, you can simply overwrite the grabbed topology manually:
// Copyright 2023 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use nym_sdk::mixnet;
use nym_sdk::mixnet::MixnetMessageSender;
use nym_topology::mix::Layer;
use nym_topology::{mix, NymTopology};
use std::collections::BTreeMap;
#[tokio::main]
async fn main() {
nym_bin_common::logging::setup_logging();
// Passing no config makes the client fire up an ephemeral session and figure shit out on its own
let mut client = mixnet::MixnetClient::connect_new().await.unwrap();
let starting_topology = client.read_current_topology().await.unwrap();
// but we don't like our default topology, we want to use only those very specific, hardcoded, nodes:
let mut mixnodes = BTreeMap::new();
mixnodes.insert(
1,
vec![mix::Node {
mix_id: 63,
owner: "n1k52k5n45cqt5qpjh8tcwmgqm0wkt355yy0g5vu".to_string(),
host: "172.105.92.48".parse().unwrap(),
mix_host: "172.105.92.48:1789".parse().unwrap(),
identity_key: "GLdR2NRVZBiCoCbv4fNqt9wUJZAnNjGXHkx3TjVAUzrK"
.parse()
.unwrap(),
sphinx_key: "CBmYewWf43iarBq349KhbfYMc9ys2ebXWd4Vp4CLQ5Rq"
.parse()
.unwrap(),
layer: Layer::One,
version: "1.1.0".into(),
}],
);
mixnodes.insert(
2,
vec![mix::Node {
mix_id: 23,
owner: "n1fzv4jc7fanl9s0qj02ge2ezk3kts545kjtek47".to_string(),
host: "178.79.143.65".parse().unwrap(),
mix_host: "178.79.143.65:1789".parse().unwrap(),
identity_key: "4Yr4qmEHd9sgsuQ83191FR2hD88RfsbMmB4tzhhZWriz"
.parse()
.unwrap(),
sphinx_key: "8ndjk5oZ6HxUZNScLJJ7hk39XtUqGexdKgW7hSX6kpWG"
.parse()
.unwrap(),
layer: Layer::Two,
version: "1.1.0".into(),
}],
);
mixnodes.insert(
3,
vec![mix::Node {
mix_id: 66,
owner: "n1ae2pjd7q9p0dea65pqkvcm4x9s264v4fktpyru".to_string(),
host: "139.162.247.97".parse().unwrap(),
mix_host: "139.162.247.97:1789".parse().unwrap(),
identity_key: "66UngapebhJRni3Nj52EW1qcNsWYiuonjkWJzHFsmyYY"
.parse()
.unwrap(),
sphinx_key: "7KyZh8Z8KxuVunqytAJ2eXFuZkCS7BLTZSzujHJZsGa2"
.parse()
.unwrap(),
layer: Layer::Three,
version: "1.1.0".into(),
}],
);
// but we like the available gateways, so keep using them!
// (we like them because the author of this example is too lazy to use the same hardcoded gateway
// during client initialisation to make sure we are able to send to ourselves : ) )
let custom_topology = NymTopology::new(mixnodes, starting_topology.gateways().to_vec());
client.manually_overwrite_topology(custom_topology).await;
// and everything we send now should only ever go via those nodes
let our_address = client.nym_address();
println!("Our client nym address is: {our_address}");
// Send a message through the mixnet to ourselves
client
.send_plain_message(*our_address, "hello there")
.await
.unwrap();
println!("Waiting for message (ctrl-c to exit)");
client
.on_messages(|msg| println!("Received: {}", String::from_utf8_lossy(&msg.message)))
.await;
}
Send and receive in different tasks
If you need to split the different actions of your client across different tasks, you can do so like this:
// Copyright 2023 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
use futures::StreamExt;
use nym_sdk::mixnet;
use nym_sdk::mixnet::MixnetMessageSender;
#[tokio::main]
async fn main() {
nym_bin_common::logging::setup_logging();
// Passing no config makes the client fire up an ephemeral session and figure stuff out on its own
let mut client = mixnet::MixnetClient::connect_new().await.unwrap();
// Be able to get our client address
let our_address = *client.nym_address();
println!("Our client nym address is: {our_address}");
let sender = client.split_sender();
// receiving task
let receiving_task_handle = tokio::spawn(async move {
if let Some(received) = client.next().await {
println!("Received: {}", String::from_utf8_lossy(&received.message));
}
client.disconnect().await;
});
// sending task
let sending_task_handle = tokio::spawn(async move {
sender
.send_plain_message(our_address, "hello from a different task!")
.await
.unwrap();
});
// wait for both tasks to be done
println!("waiting for shutdown");
sending_task_handle.await.unwrap();
receiving_task_handle.await.unwrap();
}
Socks client example
There is also the option to embed the socks5-client
into your app code (examples/socks5.rs
):
use nym_sdk::mixnet;
#[tokio::main]
async fn main() {
nym_bin_common::logging::setup_logging();
println!("Connecting receiver");
let mut receiving_client = mixnet::MixnetClient::connect_new().await.unwrap();
let socks5_config = mixnet::Socks5::new(receiving_client.nym_address().to_string());
let sending_client = mixnet::MixnetClientBuilder::new_ephemeral()
.socks5_config(socks5_config)
.build()
.await
.unwrap();
println!("Connecting sender");
let mut sending_client = sending_client.connect_to_mixnet_via_socks5().await.unwrap();
let proxy = reqwest::Proxy::all(sending_client.socks5_url()).unwrap();
let reqwest_client = reqwest::Client::builder().proxy(proxy).build().unwrap();
tokio::spawn(async move {
println!("Sending socks5-wrapped http request");
// Message should be sent through the mixnet, via socks5
// We don't expect to get anything, as there is no network requester on the other end
reqwest_client.get("https://nymtech.net").send().await.ok()
});
println!("Waiting for message");
if let Some(received) = receiving_client.wait_for_messages().await {
for r in received {
println!(
"Received socks5 message requesting for endpoint: {}",
String::from_utf8_lossy(&r.message[10..27])
);
}
}
receiving_client.disconnect().await;
sending_client.disconnect().await;
}
If you are looking at implementing Nym as a transport layer for a crypto wallet or desktop app, this is probably the best place to start.
Coconut credential generation
The following code shows how you can use the SDK to create and use a credential representing paid bandwidth on the Sandbox testnet.
use futures::StreamExt;
use nym_network_defaults::setup_env;
use nym_sdk::mixnet;
use nym_sdk::mixnet::MixnetMessageSender;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
nym_bin_common::logging::setup_logging();
// right now, only sandbox has coconut setup
// this should be run from the `sdk/rust/nym-sdk` directory
setup_env(Some("../../../envs/sandbox.env"));
let sandbox_network = mixnet::NymNetworkDetails::new_from_env();
let mnemonic = String::from("my super secret mnemonic");
let mixnet_client = mixnet::MixnetClientBuilder::new_ephemeral()
.network_details(sandbox_network)
.enable_credentials_mode()
.build()
.await?;
let bandwidth_client = mixnet_client.create_bandwidth_client(mnemonic)?;
// Get a bandwidth credential worth 1000000 unym for the mixnet_client
bandwidth_client.acquire(1000000).await?;
// Connect using paid bandwidth credential
let mut client = mixnet_client.connect_to_mixnet().await?;
let our_address = client.nym_address();
// Send a message throughout the mixnet to ourselves
client
.send_plain_message(*our_address, "hello there")
.await?;
println!("Waiting for message");
let received = client.next().await.unwrap();
println!("Received: {}", String::from_utf8_lossy(&received.message));
client.disconnect().await;
Ok(())
}
You can read more about Coconut credentials (also referred to as zk-Nym
) here.