Skip to main content

Tokens

The token example demonstrates how to write a token contract that implements the Token Interface.

Open in Gitpod

Run the Example

First go through the Setup process to get your development environment configured, then clone the v20.0.0 tag of soroban-examples repository:

git clone -b v20.0.0 https://github.com/stellar/soroban-examples

Or, skip the development environment setup and open this example in Gitpod.

To run the tests for the example, navigate to the hello_world directory, and use cargo test.

cd token
cargo test

You should see the output:

running 8 tests
test test::initialize_already_initialized - should panic ... ok
test test::transfer_spend_deauthorized - should panic ... ok
test test::decimal_is_over_max - should panic ... ok
test test::test_burn ... ok
test test::transfer_receive_deauthorized - should panic ... ok
test test::transfer_from_insufficient_allowance - should panic ... ok
test test::transfer_insufficient_balance - should panic ... ok
test test::test ... ok

Code

note

The source code for this token example is broken into several smaller modules. This is a common design pattern for more complex smart contracts.

token/src/lib.rs
#![no_std]

mod admin;
mod allowance;
mod balance;
mod contract;
mod event;
mod metadata;
mod storage_types;
mod test;

pub use crate::contract::TokenClient;

Ref: https://github.com/stellar/soroban-examples/tree/v20.0.0/token

How it Works

Tokens created on a smart contract platform can take many different forms, include a variety of different functionalities, and meet very different needs or use-cases. While each token can fulfill a unique niche, there are some "normal" features that almost all tokens will need to make use of (e.g., payments, transfers, balance queries, etc.). In an effort to minimize repetition and streamline token deployments, Soroban implements the Token Interface, which provides a uniform, predictable interface for developers and users.

Creating a Soroban token compatible contract from an existing Stellar asset is very easy, it requires deploying the built-in Stellar Asset Contract.

This example contract, however, demonstrates how a smart contract token might be constructed that doesn't take advantage of the Stellar Asset Contract, but does still satisfy the commonly used Token Interface to maximize interoperability.

Separation of Functionality

You have likely noticed that this example contract is broken into discrete modules, with each one responsible for a siloed set of functionality. This common practice helps to organize the code and make it more maintainable.

For example, most of the token logic exists in the contract.rs module. Functions like mint, burn, transfer, etc. are written and programmed in that file. The Token Interface describes how some of these functions should emit events when they occur. However, keeping all that event-emitting logic bundled in with the rest of the contract code could make it harder to track what is happening in the code, and that confusion could ultimately lead to errors.

Instead, we have a separate events.rs module that takes away all the headache of emitting events when other functions run. Here is the function to emit an event whenever the token is minted:

pub(crate) fn mint(e: &Env, admin: Address, to: Address, amount: i128) {
let topics = (symbol_short!("mint"), admin, to);
e.events().publish(topics, amount);
}

Admittedly, this is a simple example, but constructing the contract this way makes it very clear to the developer what is happening and where. This function is then used by the contract.rs module whenever the mint function is invoked:

// earlier in `contract.rs`
use crate::event;

fn mint(e: Env, to: Address, amount: i128) {
check_nonnegative_amount(amount);
let admin = read_administrator(&e);
admin.require_auth();
receive_balance(&e, to.clone(), amount);
event::mint(&e, admin, to, amount);
}

This same convention is used to separate from the "main" contract code the metadata for the token, the storage type definitions, etc.

Standardized Interface, Customized Behavior

This example contract follows the standardized Token Interface, implementing all of the same functions as the Stellar Asset Contract. This gives wallets, users, developers, etc. a predictable interface to interact with the token. Even though we are implementing the same interface of functions, that doesn't mean we have to implement the same behavior inside those functions. While this example contract doesn't actually modify any of the functions that would be present in a deployed instance of the Stellar Asset Contract, that possibility remains open to the contract developer.

By way of example, perhaps you have an NFT project, and the artist wants to have a small royalty paid every time their token transfers hands:

// This is mainly the `transfer` function from `src/contract.rs`
fn transfer(e: Env, from: Address, to: Address, amount: i128) {
from.require_auth();

check_nonnegative_amount(amount);
spend_balance(&e, from.clone(), amount);
// We calculate some new amounts for payment and royalty
let payment = (amount * 997) / 1000;
let royalty = amount - payment
receive_balance(&e, artist.clone(), royalty);
receive_balance(&e, to.clone(), payment);
event::transfer(&e, from, to, amount);
}

The transfer interface is still in use, and is still the same as other tokens, but we've customized the behavior to address a specific need. Another use-case might be a tightly controlled token that requires authentication from an admin before any transfer, allowance, etc. function could be invoked.

tip

Of course, you will want your token to behave in an intuitive and transparent manner. If a user is invoking a transfer, they will expect tokens to move. If an asset issuer needs to invoke a clawback they will likely require the right kind of behavior to take place.

Tests

Open the token/src/test.rs file to follow along.

token/src/test.rs
#![cfg(test)]
extern crate std;

use crate::{contract::Token, TokenClient};
use soroban_sdk::{testutils::Address as _, Address, Env, IntoVal, Symbol};

fn create_token<'a>(e: &Env, admin: &Address) -> TokenClient<'a> {
let token = TokenClient::new(e, &e.register_contract(None, Token {}));
token.initialize(admin, &7, &"name".into_val(e), &"symbol".into_val(e));
token
}

#[test]
fn test() {
let e = Env::default();
e.mock_all_auths();

let admin1 = Address::random(&e);
let admin2 = Address::random(&e);
let user1 = Address::random(&e);
let user2 = Address::random(&e);
let user3 = Address::random(&e);
let token = create_token(&e, &admin1);

token.mint(&user1, &1000);
assert_eq!(
e.auths(),
std::vec![(
admin1.clone(),
AuthorizedInvocation {
function: AuthorizedFunction::Contract((
token.address.clone(),
symbol_short!("mint"),
(&user1, 1000_i128).into_val(&e),
)),
sub_invocations: std::vec![]
}
)]
);
assert_eq!(token.balance(&user1), 1000);

token.approve(&user2, &user3, &500, &200);
assert_eq!(
e.auths(),
std::vec![(
user2.clone(),
AuthorizedInvocation {
function: AuthorizedFunction::Contract((
token.address.clone(),
symbol_short!("approve"),
(&user2, &user3, 500_i128, 200_u32).into_val(&e),
)),
sub_invocations: std::vec![]
}
)]
);
assert_eq!(token.allowance(&user2, &user3), 500);

token.transfer(&user1, &user2, &600);
assert_eq!(
e.auths(),
std::vec![(
user1.clone(),
AuthorizedInvocation {
function: AuthorizedFunction::Contract((
token.address.clone(),
symbol_short!("transfer"),
(&user1, &user2, 600_i128).into_val(&e),
)),
sub_invocations: std::vec![]
}
)]
);
assert_eq!(token.balance(&user1), 400);
assert_eq!(token.balance(&user2), 600);

token.transfer_from(&user3, &user2, &user1, &400);
assert_eq!(
e.auths(),
std::vec![(
user3.clone(),
AuthorizedInvocation {
function: AuthorizedFunction::Contract((
token.address.clone(),
Symbol::new(&e, "transfer_from"),
(&user3, &user2, &user1, 400_i128).into_val(&e),
)),
sub_invocations: std::vec![]
}
)]
);
assert_eq!(token.balance(&user1), 800);
assert_eq!(token.balance(&user2), 200);

token.transfer(&user1, &user3, &300);
assert_eq!(token.balance(&user1), 500);
assert_eq!(token.balance(&user3), 300);

token.set_admin(&admin2);
assert_eq!(
e.auths(),
std::vec![(
admin1.clone(),
AuthorizedInvocation {
function: AuthorizedFunction::Contract((
token.address.clone(),
symbol_short!("set_admin"),
(&admin2,).into_val(&e),
)),
sub_invocations: std::vec![]
}
)]
);

token.set_authorized(&user2, &false);
assert_eq!(
e.auths(),
std::vec![(
admin2.clone(),
AuthorizedInvocation {
function: AuthorizedFunction::Contract((
token.address.clone(),
Symbol::new(&e, "set_authorized"),
(&user2, false).into_val(&e),
)),
sub_invocations: std::vec![]
}
)]
);
assert_eq!(token.authorized(&user2), false);

token.set_authorized(&user3, &true);
assert_eq!(token.authorized(&user3), true);

token.clawback(&user3, &100);
assert_eq!(
e.auths(),
std::vec![(
admin2.clone(),
AuthorizedInvocation {
function: AuthorizedFunction::Contract((
token.address.clone(),
symbol_short!("clawback"),
(&user3, 100_i128).into_val(&e),
)),
sub_invocations: std::vec![]
}
)]
);
assert_eq!(token.balance(&user3), 200);

// Set allowance to 500
token.approve(&user2, &user3, &500, &200);
assert_eq!(token.allowance(&user2, &user3), 500);
token.approve(&user2, &user3, &0, &200);
assert_eq!(
e.auths(),
std::vec![(
user2.clone(),
AuthorizedInvocation {
function: AuthorizedFunction::Contract((
token.address.clone(),
symbol_short!("approve"),
(&user2, &user3, 0_i128, 200_u32).into_val(&e),
)),
sub_invocations: std::vec![]
}
)]
);
assert_eq!(token.allowance(&user2, &user3), 0);
}

#[test]
fn test_burn() {
let e = Env::default();
e.mock_all_auths();

let admin = Address::random(&e);
let user1 = Address::random(&e);
let user2 = Address::random(&e);
let token = create_token(&e, &admin);

token.mint(&user1, &1000);
assert_eq!(token.balance(&user1), 1000);

token.approve(&user1, &user2, &500, &200);
assert_eq!(token.allowance(&user1, &user2), 500);

token.burn_from(&user2, &user1, &500);
assert_eq!(
e.auths(),
std::vec![(
user2.clone(),
AuthorizedInvocation {
function: AuthorizedFunction::Contract((
token.address.clone(),
symbol_short!("burn_from"),
(&user2, &user1, 500_i128).into_val(&e),
)),
sub_invocations: std::vec![]
}
)]
);

assert_eq!(token.allowance(&user1, &user2), 0);
assert_eq!(token.balance(&user1), 500);
assert_eq!(token.balance(&user2), 0);

token.burn(&user1, &500);
assert_eq!(
e.auths(),
std::vec![(
user1.clone(),
AuthorizedInvocation {
function: AuthorizedFunction::Contract((
token.address.clone(),
symbol_short!("burn"),
(&user1, 500_i128).into_val(&e),
)),
sub_invocations: std::vec![]
}
)]
);

assert_eq!(token.balance(&user1), 0);
assert_eq!(token.balance(&user2), 0);
}

#[test]
#[should_panic(expected = "insufficient balance")]
fn transfer_insufficient_balance() {
let e = Env::default();
e.mock_all_auths();

let admin = Address::random(&e);
let user1 = Address::random(&e);
let user2 = Address::random(&e);
let token = create_token(&e, &admin);

token.mint(&user1, &1000);
assert_eq!(token.balance(&user1), 1000);

token.transfer(&user1, &user2, &1001);
}

#[test]
#[should_panic(expected = "can't receive when deauthorized")]
fn transfer_receive_deauthorized() {
let e = Env::default();
e.mock_all_auths();

let admin = Address::random(&e);
let user1 = Address::random(&e);
let user2 = Address::random(&e);
let token = create_token(&e, &admin);

token.mint(&user1, &1000);
assert_eq!(token.balance(&user1), 1000);

token.set_authorized(&user2, &false);
token.transfer(&user1, &user2, &1);
}

#[test]
#[should_panic(expected = "can't spend when deauthorized")]
fn transfer_spend_deauthorized() {
let e = Env::default();
e.mock_all_auths();

let admin = Address::random(&e);
let user1 = Address::random(&e);
let user2 = Address::random(&e);
let token = create_token(&e, &admin);

token.mint(&user1, &1000);
assert_eq!(token.balance(&user1), 1000);

token.set_authorized(&user1, &false);
token.transfer(&user1, &user2, &1);
}

#[test]
#[should_panic(expected = "insufficient allowance")]
fn transfer_from_insufficient_allowance() {
let e = Env::default();
e.mock_all_auths();

let admin = Address::random(&e);
let user1 = Address::random(&e);
let user2 = Address::random(&e);
let user3 = Address::random(&e);
let token = create_token(&e, &admin);

token.mint(&user1, &1000);
assert_eq!(token.balance(&user1), 1000);

token.approve(&user1, &user3, &100, &200);
assert_eq!(token.allowance(&user1, &user3), 100);

token.transfer_from(&user3, &user1, &user2, &101);
}

#[test]
#[should_panic(expected = "already initialized")]
fn initialize_already_initialized() {
let e = Env::default();
let admin = Address::random(&e);
let token = create_token(&e, &admin);

token.initialize(&admin, &10, &"name".into_val(&e), &"symbol".into_val(&e));
}

#[test]
#[should_panic(expected = "Decimal must fit in a u8")]
fn decimal_is_over_max() {
let e = Env::default();
let admin = Address::random(&e);
let token = TokenClient::new(&e, &e.register_contract(None, Token {}));
token.initialize(
&admin,
&(u32::from(u8::MAX) + 1),
&"name".into_val(&e),
&"symbol".into_val(&e),
);
}

The token example implements eight different tests to cover a wide array of potential behaviors and problems. However, all of the tests start with a few common pieces. In any test, the first thing that is always required is an Env, which is the Soroban environment that the contract will run in.

let e = Env::default();

We mock authentication checks in the tests, which allows the tests to proceed as if all users/addresses/contracts/etc. had successfully authenticated.

e.mock_all_auths();

We're also using a create_token function to ease the repetition of having to register and initialize our token contract. The resulting token client is then used to invoke the contract during each test.

// It is defined at the top of the file...
fn create_token<'a>(e: &Env, admin: &Address) -> TokenClient<'a> {
let token = TokenClient::new(e, &e.register_contract(None, Token {}));
token.initialize(admin, &7, &"name".into_val(e), &"symbol".into_val(e));
token
}

// ... and it is used inside each test
let token = create_token(&e, &admin);

All public functions within an impl block that has been annotated with the #[contractimpl] attribute will have a corresponding function in the test's generated client type. The client type will be named the same as the contract type with Client appended. For example, in our contract, the contract type is named Token, and the client type is named TokenClient.

The eight tests created for this example contract test a range of possible conditions and ensure the contract responds appropriately to each one:

  • test() - This function makes use of a variety of the built-in token functions to test the "predictable" way an asset might be interacted with by a user, as well as an administrator.
  • test_burn() - This function ensures a burn() invocation decreases a user's balance, and that a burn_from() invocation decreases a user's balance as well as consuming another user's allowance of that balance.
  • transfer_insufficient_balance() - This function ensures a transfer() invocation panics when the from user doesn't have the balance to cover it.
  • transfer_receive_deauthorized() - This function ensures a user who is specifically de-authorized to hold the token cannot be the beneficiary of a transfer() invocation.
  • transfer_spend_deauthorized() - This function ensures a user with a token balance, who is subsequently de-authorized cannot be the source of a transfer() invocation.
  • transfer_from_insufficient_allowance() - This function ensures a user with an existing allowance for someone else's balance cannot make a transfer() greater than that allowance.
  • initialize_already_initialized() - This function checks that the contract cannot have it's initialize() function invoked a second time.
  • decimal_is_over_max() - This function tests that invoking initialize() with too high of a decimal precision will not succeed.

Build the Contract

To build the contract, use the soroban contract build command.

soroban contract build

A .wasm file should be outputted in the target directory:

target/wasm32-unknown-unknown/release/soroban_token_contract.wasm

Run the Contract

If you have soroban-cli installed, you can invoke contract functions using it.

soroban contract invoke \
--wasm target/wasm32-unknown-unknown/release/soroban_token_contract.wasm \
--id 1 \
-- \
balance \
--id GBZV3NONYSUDVTEHATQO4BCJVFXJO3XQU5K32X3XREVZKSMMOZFO4ZXR