Skip to main content

Stardust Exchange Integration Guide

This guide explains how to integrate the IOTA SDK into your exchange, custody solution, or product.

Features of the IOTA SDK:

  • Secure seed management.
  • Account management with multiple accounts and multiple addresses.
  • Confirmation monitoring.
  • Deposit address monitoring.
  • Backup and restore functionality.
Stardust Update

The Stardust update is a set of protocol changes that were first deployed to the Shimmer network. See What is Stardust? for an overview of its features or TIPs flagged Stardust for a deeper understanding of the protocol changes.

How Does It Work?

The IOTA SDK is a stateful library which provides an API for developers to build value transaction applications. It offers abstractions to handle payments, and can optionally interact with Stronghold for seed handling, seed storage, and state backup.

You can use the following examples as a guide to implement the multi-account model.

If you were already supporting Shimmer with wallet.rs

The IOTA SDK is a brand new project which is a combination and improvement of both iota.rs and wallet.rs. If you were already supporting Shimmer with wallet.rs, we encourage you to switch to the SDK as wallet.rs will be deprecated and not receive any updates anymore.

Most of the APIs are the same and the changes are fairly minimal, here are some of the major changes:

1. Set Up the IOTA SDK

Install the IOTA SDK

First, you should install the components needed to use the IOTA SDK and the binding of your choice; it may vary a bit from language to language.

You can read more about backup and security in this guide.

Check out our Getting Started with Rust guide for more detailed instructions.

To start using the IOTA SDK in your Rust project, you can include it as a dependencies in your project's Cargo.toml by running:

cargo add iota-sdk

Set Up you .env File

danger

This guide uses dotenv to share user-defined constants, like database paths, node URLs, and wallet credentials across the code examples. This is for convenience only and shouldn't be done in production.

You can create a .env by running the following command:

touch .env

The following examples will need these variables to exist, here are some default values you can use but also change:

WALLET_DB_PATH="./wallet-database"
NODE_URL="https://api.testnet.shimmer.network"
STRONGHOLD_SNAPSHOT_PATH="./wallet.stronghold"
STRONGHOLD_PASSWORD="*K6%79#yeFn7AMQhMQ"
EXPLORER_URL="https://explorer.shimmer.network/testnet"

2. Generate a Mnemonic

After you have created the .env file, you can initialize the Wallet instance with a secret manager (Stronghold by default) and client options.

By default, the Stronghold file will be called wallet.stronghold. It will store the seed (derived from the mnemonic) that serves as a cryptographic key from which all accounts and related addresses are generated.

One of the key principles behind Stronghold is that is impossible to extract a mnemonic from it ever again, that is, recover it from the .stronghold file. That's why you should always back up your 24-word mnemonic and have it stored in a safe place. You deal with accounts using the Wallet instance exclusively, which abstracts away all internal complexities and makes transacting in a network very easy and convenient.

Back up and Security

It is best practice to keep the stronghold password and the stronghold database on separate devices.

sdk/examples/how_tos/accounts_and_addresses/create_mnemonic.rs
use iota_sdk::client::{Client, Result};

#[tokio::main]
async fn main() -> Result<()> {
let mnemonic = Client::generate_mnemonic()?;

println!("Generated mnemonic:\n{}", mnemonic.as_ref());

Ok(())
}

3. Create an Account for Each User

You can import the IOTA SDK and create a wallet using the following example:

sdk/examples/how_tos/accounts_and_addresses/create_account.rs
use iota_sdk::{
client::{
constants::SHIMMER_COIN_TYPE,
secret::{SecretManager, stronghold::StrongholdSecretManager},
},
crypto::keys::bip39::Mnemonic,
wallet::{ClientOptions, Result, Wallet},
};

#[tokio::main]
async fn main() -> Result<()> {
// This example uses secrets in environment variables for simplicity which should not be done in production.
dotenvy::dotenv().ok();

for var in [
"STRONGHOLD_PASSWORD",
"STRONGHOLD_SNAPSHOT_PATH",
"MNEMONIC",
"NODE_URL",
"WALLET_DB_PATH",
] {
std::env::var(var).expect(&format!(".env variable '{var}' is undefined, see .env.example"));
}

// Setup Stronghold secret_manager
let secret_manager = StrongholdSecretManager::builder()
.password(std::env::var("STRONGHOLD_PASSWORD").unwrap())
.build(std::env::var("STRONGHOLD_SNAPSHOT_PATH").unwrap())?;

// Only required the first time, can also be generated with `manager.generate_mnemonic()?`
let mnemonic = Mnemonic::from(std::env::var("MNEMONIC").unwrap());

// The mnemonic only needs to be stored the first time
secret_manager.store_mnemonic(mnemonic).await?;

let client_options = ClientOptions::new().with_node(&std::env::var("NODE_URL").unwrap())?;

// Create the wallet
let wallet = Wallet::builder()
.with_secret_manager(SecretManager::Stronghold(secret_manager))
.with_storage_path(std::env::var("WALLET_DB_PATH").unwrap())
.with_client_options(client_options)
.with_coin_type(SHIMMER_COIN_TYPE)
.finish()
.await?;

// Create a new account
let account = wallet.create_account().with_alias("Alice").finish().await?;

println!("Generated new account: '{}'", account.alias().await);

Ok(())
}

The Alias must be unique and can be whatever fits your use case. The Alias is typically used to identify an account later on. Each account is also represented by an index, which is incremented by one every time a new account is created. You can refer to any account via its index or its alias.

You get an instance of any created account using Wallet.get_account(accountId|alias) or get all accounts with Wallet.get_accounts().

Common methods of an Account instance include:

4. Generate a User Address to Deposit Funds

The wallet module of the IOTA SDK is a stateful library. This means it caches all relevant information in storage to provide performance benefits while dealing with, potentially, many accounts and addresses.

sdk/examples/how_tos/accounts_and_addresses/create_address.rs
use iota_sdk::{Wallet, wallet::Result};

// The number of addresses to generate
const NUM_ADDRESSES_TO_GENERATE: u32 = 5;

#[tokio::main]
async fn main() -> Result<()> {
// This example uses secrets in environment variables for simplicity which should not be done in production.
dotenvy::dotenv().ok();

for var in ["WALLET_DB_PATH", "EXPLORER_URL", "STRONGHOLD_PASSWORD"] {
std::env::var(var).expect(&format!(".env variable '{var}' is undefined, see .env.example"));
}

let wallet = Wallet::builder()
.with_storage_path(std::env::var("WALLET_DB_PATH").unwrap())
.finish()
.await?;
let account = wallet.get_account("Alice").await?;

// Provide the stronghold password
wallet
.set_stronghold_password(std::env::var("STRONGHOLD_PASSWORD").unwrap())
.await?;

let explorer_url = std::env::var("EXPLORER_URL").ok();
let address_url = explorer_url.map(|url| format!("{url}/addr/")).unwrap_or_default();

println!("Current addresses:");
for address in account.addresses().await? {
println!(" - {address_url}{}", address.address());
}

// Generate some addresses
let new_addresses = account
.generate_ed25519_addresses(NUM_ADDRESSES_TO_GENERATE, None)
.await?;
println!("Generated {} new addresses:", new_addresses.len());
let account_addresses = account.addresses().await?;
for new_address in new_addresses.iter() {
assert!(account_addresses.contains(new_address));
println!(" - {address_url}{}", new_address.address());
}
Ok(())
}

Every account can have multiple addresses. Addresses are represented by an index which is incremented by one every time a new address is created. You can access the addresses using the account.addresses() method:

for address in account.addresses().await? {
println!("{}", address.address());
}

You can use the Faucet to request test tokens and test your account.

There are two types of addresses, internal and public (external). This approach is known as a BIP32 Hierarchical Deterministic wallet (HD Wallet).

  • Each set of addresses is independent of each other and has an independent index id.
  • Addresses that are created by account.generateEd25519Addresses() are indicated as internal=false (public).
  • Internal addresses (internal=true) are called change addresses and are used to send the excess funds to them.

5. Check the Account Balance

Unlock Conditions

Outputs may have multiple UnlockConditions, which may require returning some or all of the transferred amount. The outputs could also expire if not claimed in time, or may not be unlockable for a predefined period.

To get outputs with only the AddressUnlockCondition, you should synchronize with the option syncOnlyMostBasicOutputs: true.

If you are synchronizing outputs with other unlock conditions, you should check the unlock conditions carefully before crediting users any balance.

You can find an example illustrating how to check if an output has only the address unlock condition, where the address belongs to the account in the Check Unlock Conditions how-to guide.

You can get the available account balance across all addresses of the given account using the following example:

sdk/examples/how_tos/accounts_and_addresses/check_balance.rs
use iota_sdk::{Wallet, wallet::Result};

#[tokio::main]
async fn main() -> Result<()> {
// This example uses secrets in environment variables for simplicity which should not be done in production.
dotenvy::dotenv().ok();

for var in ["WALLET_DB_PATH", "EXPLORER_URL"] {
std::env::var(var).expect(&format!(".env variable '{var}' is undefined, see .env.example"));
}

let wallet = Wallet::builder()
.with_storage_path(std::env::var("WALLET_DB_PATH").unwrap())
.finish()
.await?;
let account = wallet.get_account("Alice").await?;

// Sync and get the balance
let balance = account.sync(None).await?;
println!("{balance:#?}");

println!("ADDRESSES:");
let explorer_url = std::env::var("EXPLORER_URL").ok();
let prepended = explorer_url.map(|url| format!("{url}/addr/")).unwrap_or_default();
for address in account.addresses().await? {
println!(" - {prepended}{}", address.address());
}

Ok(())
}

6. Listen to Events

The IOTA SDK supports several events for listening. A provided callback is triggered as soon as an event occurs (which usually happens during syncingA process when a node downloads and verifies the entire history of the Tangle corresponding to a slot commitment chain. This allows to ensure that it has an up-to-date and accurate copy of the ledger.).

You can use the following example to listen to new output events:

sdk/examples/wallet/events.rs
use iota_sdk::{
client::{
constants::SHIMMER_COIN_TYPE,
secret::{SecretManager, mnemonic::MnemonicSecretManager},
},
types::block::{
address::Address,
output::{BasicOutputBuilder, unlock_condition::AddressUnlockCondition},
},
wallet::{ClientOptions, Result, Wallet},
};

// The amount of base coins we'll send
const SEND_AMOUNT: u64 = 1_000_000;
// The address we'll be sending coins to
const RECV_ADDRESS: &str = "rms1qpszqzadsym6wpppd6z037dvlejmjuke7s24hm95s9fg9vpua7vluaw60xu";

#[tokio::main]
async fn main() -> Result<()> {
// This example uses secrets in environment variables for simplicity which should not be done in production.
dotenvy::dotenv().ok();

for var in ["NODE_URL", "MNEMONIC", "WALLET_DB_PATH", "EXPLORER_URL"] {
std::env::var(var).expect(&format!(".env variable '{var}' is undefined, see .env.example"));
}

let client_options = ClientOptions::new().with_node(&std::env::var("NODE_URL").unwrap())?;

let secret_manager = MnemonicSecretManager::try_from_mnemonic(std::env::var("MNEMONIC").unwrap())?;

let wallet = Wallet::builder()
.with_secret_manager(SecretManager::Mnemonic(secret_manager))
.with_storage_path(std::env::var("WALLET_DB_PATH").unwrap())
.with_client_options(client_options)
.with_coin_type(SHIMMER_COIN_TYPE)
.finish()
.await?;

wallet
.listen([], move |event| {
println!("RECEIVED AN EVENT:\n{:?}", event.event);
})
.await;

// Get or create an account
let account = wallet.get_or_create_account("Alice").await?;

let balance = account.sync(None).await?;
println!("Balance BEFORE:\n{:#?}", balance.base_coin());

// send transaction
let outputs = [BasicOutputBuilder::new_with_amount(SEND_AMOUNT)
.add_unlock_condition(AddressUnlockCondition::new(Address::try_from_bech32(RECV_ADDRESS)?))
.finish_output(account.client().get_token_supply().await?)?];

let transaction = account.send_outputs(outputs, None).await?;
println!("Transaction sent: {}", transaction.transaction_id);

let block_id = account
.retry_transaction_until_included(&transaction.transaction_id, None, None)
.await?;

println!(
"Block included: {}/block/{}",
std::env::var("EXPLORER_URL").unwrap(),
block_id
);

let balance = account.sync(None).await?;
println!("Balance AFTER:\n{:#?}", balance.base_coin());

Ok(())
}

Example output:

NewOutput: {
output: {
outputId: '0x2df0120a5e0ff2b941ec72dff3464a5b2c3ad8a0c96fe4c87243e4425b9a3fe30000',
metadata: [Object],
output: [Object],
isSpent: false,
address: [Object],
networkId: '1862946857608115868',
remainder: false,
chain: [Array]
},
transaction: null,
transactionInputs: null
}

Alternatively you can use account.outputs() to get all the outputs that are stored in the account, or account.unspent_outputs() , to get the unspent outputs only.

7. Enable Withdrawals

You can use the following example to send tokens to an address.

Dust Protection

When sending tokens, you should consider a dust protection mechanism.

sdk/examples/how_tos/simple_transaction/simple_transaction.rs
loading...

The full function signature is Account.send(&self, amount: u64, address: impl ConvertTo<Bech32Address>,options: impl Into<Option <TransactionOptions>>).

The default TransactionOptions are fine and successful. However, you can provide additional options, such as RemainderValueStrategy, which can have the following values:

  • ChangeAddress: Send the remainder value to an internal address.
  • ReuseAddress: Send the remainder value back to its original address.
  • CustomAddress: Send the remainder value back to a provided account address.

The account.send() function returns a Transaction with its id. You can use the blockId to check the confirmation status. You can obtain individual transactions related to the given account using the account.transactions() function.

You can use the account.send_with_params() to send to multiple addresses in a single transaction.