Tokens
The token example demonstrates how to write a token contract that implements the Token Interface.
Run the Example
First go through the Setup process to get your development environment configured, then clone the v22.0.1
tag of soroban-examples
repository:
git clone -b v22.0.1 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 6 tests
test test::decimal_is_over_eighteen - should panic ... ok
test test::transfer_insufficient_balance - should panic ... ok
test test::test_zero_allowance ... ok
test test::transfer_from_insufficient_allowance - should panic ... ok
test test::test_burn ... ok
test test::test ... ok
Code
The source code for this token example is broken into several smaller modules. This is a common design pattern for more complex smart contracts.
- lib
- admin
- allowance
- balance
- contract
- metadata
- storage_types
#![no_std]
mod admin;
mod allowance;
mod balance;
mod contract;
mod metadata;
mod storage_types;
mod test;
pub use crate::contract::TokenClient;
use soroban_sdk::{Address, Env};
use crate::storage_types::DataKey;
pub fn read_administrator(e: &Env) -> Address {
let key = DataKey::Admin;
e.storage().instance().get(&key).unwrap()
}
pub fn write_administrator(e: &Env, id: &Address) {
let key = DataKey::Admin;
e.storage().instance().set(&key, id);
}
use crate::storage_types::{AllowanceDataKey, AllowanceValue, DataKey};
use soroban_sdk::{Address, Env};
pub fn read_allowance(e: &Env, from: Address, spender: Address) -> AllowanceValue {
let key = DataKey::Allowance(AllowanceDataKey { from, spender });
if let Some(allowance) = e.storage().temporary().get::<_, AllowanceValue>(&key) {
if allowance.expiration_ledger < e.ledger().sequence() {
AllowanceValue {
amount: 0,
expiration_ledger: allowance.expiration_ledger,
}
} else {
allowance
}
} else {
AllowanceValue {
amount: 0,
expiration_ledger: 0,
}
}
}
pub fn write_allowance(
e: &Env,
from: Address,
spender: Address,
amount: i128,
expiration_ledger: u32,
) {
let allowance = AllowanceValue {
amount,
expiration_ledger,
};
if amount > 0 && expiration_ledger < e.ledger().sequence() {
panic!("expiration_ledger is less than ledger seq when amount > 0")
}
let key = DataKey::Allowance(AllowanceDataKey { from, spender });
e.storage().temporary().set(&key.clone(), &allowance);
if amount > 0 {
e.storage().temporary().bump(
&key,
expiration_ledger
.checked_sub(e.ledger().sequence())
.unwrap(),
)
}
}
pub fn spend_allowance(e: &Env, from: Address, spender: Address, amount: i128) {
let allowance = read_allowance(e, from.clone(), spender.clone());
if allowance.amount < amount {
panic!("insufficient allowance");
}
write_allowance(
e,
from,
spender,
allowance.amount - amount,
allowance.expiration_ledger,
);
}
use crate::storage_types::{DataKey, BALANCE_BUMP_AMOUNT, BALANCE_LIFETIME_THRESHOLD};
use soroban_sdk::{Address, Env};
pub fn read_balance(e: &Env, addr: Address) -> i128 {
let key = DataKey::Balance(addr);
if let Some(balance) = e.storage().persistent().get::<DataKey, i128>(&key) {
e.storage()
.persistent()
.extend_ttl(&key, BALANCE_LIFETIME_THRESHOLD, BALANCE_BUMP_AMOUNT);
balance
} else {
0
}
}
fn write_balance(e: &Env, addr: Address, amount: i128) {
let key = DataKey::Balance(addr);
e.storage().persistent().set(&key, &amount);
e.storage()
.persistent()
.extend_ttl(&key, BALANCE_LIFETIME_THRESHOLD, BALANCE_BUMP_AMOUNT);
}
pub fn receive_balance(e: &Env, addr: Address, amount: i128) {
let balance = read_balance(e, addr.clone());
write_balance(e, addr, balance + amount);
}
pub fn spend_balance(e: &Env, addr: Address, amount: i128) {
let balance = read_balance(e, addr.clone());
if balance < amount {
panic!("insufficient balance");
}
write_balance(e, addr, balance - amount);
}
//! This contract demonstrates a sample implementation of the Soroban token
//! interface.
use crate::admin::{read_administrator, write_administrator};
use crate::allowance::{read_allowance, spend_allowance, write_allowance};
use crate::balance::{read_balance, receive_balance, spend_balance};
use crate::metadata::{read_decimal, read_name, read_symbol, write_metadata};
#[cfg(test)]
use crate::storage_types::{AllowanceDataKey, AllowanceValue, DataKey};
use crate::storage_types::{INSTANCE_BUMP_AMOUNT, INSTANCE_LIFETIME_THRESHOLD};
use soroban_sdk::token::{self, Interface as _};
use soroban_sdk::{contract, contractimpl, Address, Env, String};
use soroban_token_sdk::metadata::TokenMetadata;
use soroban_token_sdk::TokenUtils;
fn check_nonnegative_amount(amount: i128) {
if amount < 0 {
panic!("negative amount is not allowed: {}", amount)
}
}
#[contract]
pub struct Token;
#[contractimpl]
impl Token {
pub fn __constructor(e: Env, admin: Address, decimal: u32, name: String, symbol: String) {
if decimal > 18 {
panic!("Decimal must not be greater than 18");
}
write_administrator(&e, &admin);
write_metadata(
&e,
TokenMetadata {
decimal,
name,
symbol,
},
)
}
pub fn mint(e: Env, to: Address, amount: i128) {
check_nonnegative_amount(amount);
let admin = read_administrator(&e);
admin.require_auth();
e.storage()
.instance()
.extend_ttl(INSTANCE_LIFETIME_THRESHOLD, INSTANCE_BUMP_AMOUNT);
receive_balance(&e, to.clone(), amount);
TokenUtils::new(&e).events().mint(admin, to, amount);
}
pub fn set_admin(e: Env, new_admin: Address) {
let admin = read_administrator(&e);
admin.require_auth();
e.storage()
.instance()
.extend_ttl(INSTANCE_LIFETIME_THRESHOLD, INSTANCE_BUMP_AMOUNT);
write_administrator(&e, &new_admin);
TokenUtils::new(&e).events().set_admin(admin, new_admin);
}
#[cfg(test)]
pub fn get_allowance(e: Env, from: Address, spender: Address) -> Option<AllowanceValue> {
let key = DataKey::Allowance(AllowanceDataKey { from, spender });
let allowance = e.storage().temporary().get::<_, AllowanceValue>(&key);
allowance
}
}
#[contractimpl]
impl token::Interface for Token {
fn allowance(e: Env, from: Address, spender: Address) -> i128 {
e.storage()
.instance()
.extend_ttl(INSTANCE_LIFETIME_THRESHOLD, INSTANCE_BUMP_AMOUNT);
read_allowance(&e, from, spender).amount
}
fn approve(e: Env, from: Address, spender: Address, amount: i128, expiration_ledger: u32) {
from.require_auth();
check_nonnegative_amount(amount);
e.storage()
.instance()
.extend_ttl(INSTANCE_LIFETIME_THRESHOLD, INSTANCE_BUMP_AMOUNT);
write_allowance(&e, from.clone(), spender.clone(), amount, expiration_ledger);
TokenUtils::new(&e)
.events()
.approve(from, spender, amount, expiration_ledger);
}
fn balance(e: Env, id: Address) -> i128 {
e.storage()
.instance()
.extend_ttl(INSTANCE_LIFETIME_THRESHOLD, INSTANCE_BUMP_AMOUNT);
read_balance(&e, id)
}
fn transfer(e: Env, from: Address, to: Address, amount: i128) {
from.require_auth();
check_nonnegative_amount(amount);
e.storage()
.instance()
.extend_ttl(INSTANCE_LIFETIME_THRESHOLD, INSTANCE_BUMP_AMOUNT);
spend_balance(&e, from.clone(), amount);
receive_balance(&e, to.clone(), amount);
TokenUtils::new(&e).events().transfer(from, to, amount);
}
fn transfer_from(e: Env, spender: Address, from: Address, to: Address, amount: i128) {
spender.require_auth();
check_nonnegative_amount(amount);
e.storage()
.instance()
.extend_ttl(INSTANCE_LIFETIME_THRESHOLD, INSTANCE_BUMP_AMOUNT);
spend_allowance(&e, from.clone(), spender, amount);
spend_balance(&e, from.clone(), amount);
receive_balance(&e, to.clone(), amount);
TokenUtils::new(&e).events().transfer(from, to, amount)
}
fn burn(e: Env, from: Address, amount: i128) {
from.require_auth();
check_nonnegative_amount(amount);
e.storage()
.instance()
.extend_ttl(INSTANCE_LIFETIME_THRESHOLD, INSTANCE_BUMP_AMOUNT);
spend_balance(&e, from.clone(), amount);
TokenUtils::new(&e).events().burn(from, amount);
}
fn burn_from(e: Env, spender: Address, from: Address, amount: i128) {
spender.require_auth();
check_nonnegative_amount(amount);
e.storage()
.instance()
.extend_ttl(INSTANCE_LIFETIME_THRESHOLD, INSTANCE_BUMP_AMOUNT);
spend_allowance(&e, from.clone(), spender, amount);
spend_balance(&e, from.clone(), amount);
TokenUtils::new(&e).events().burn(from, amount)
}
fn decimals(e: Env) -> u32 {
read_decimal(&e)
}
fn name(e: Env) -> String {
read_name(&e)
}
fn symbol(e: Env) -> String {
read_symbol(&e)
}
}
use soroban_sdk::{Bytes, Env};
use soroban_token_sdk::{TokenMetadata, TokenUtils};
pub fn read_decimal(e: &Env) -> u32 {
let util = TokenUtils::new(e);
util.get_metadata_unchecked().unwrap().decimal
}
pub fn read_name(e: &Env) -> Bytes {
let util = TokenUtils::new(e);
util.get_metadata_unchecked().unwrap().name
}
pub fn read_symbol(e: &Env) -> Bytes {
let util = TokenUtils::new(e);
util.get_metadata_unchecked().unwrap().symbol
}
pub fn write_metadata(e: &Env, metadata: TokenMetadata) {
let util = TokenUtils::new(e);
util.set_metadata(&metadata);
}
use soroban_sdk::{contracttype, Address};
#[derive(Clone)]
#[contracttype]
pub struct AllowanceDataKey {
pub from: Address,
pub spender: Address,
}
#[contracttype]
pub struct AllowanceValue {
pub amount: i128,
pub expiration_ledger: u32,
}
#[derive(Clone)]
#[contracttype]
pub enum DataKey {
Allowance(AllowanceDataKey),
Balance(Address),
Nonce(Address),
State(Address),
Admin,
}
Ref: https://github.com/stellar/soroban-examples/tree/v22.0.1/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 soroban_token_sdk::TokenUtils
module that takes away all the headache of emitting events when other functions run. Here is the event emitted when a token is minted:
TokenUtils::new(&e).events().mint(admin, to, 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);
TokenUtils::new(&e).events().mint(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.
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.
#![cfg(test)]
extern crate std;
use crate::{contract::Token, TokenClient};
use soroban_sdk::{
symbol_short,
testutils::{Address as _, AuthorizedFunction, AuthorizedInvocation},
Address, Env, FromVal, IntoVal, String, Symbol,
};
fn create_token<'a>(e: &Env, admin: &Address) -> TokenClient<'a> {
let token_contract = e.register(
Token,
(
admin,
7_u32,
String::from_val(e, &"name"),
String::from_val(e, &"symbol"),
),
);
TokenClient::new(e, &token_contract)
}
#[test]
fn test() {
let e = Env::default();
e.mock_all_auths();
let admin1 = Address::generate(&e);
let admin2 = Address::generate(&e);
let user1 = Address::generate(&e);
let user2 = Address::generate(&e);
let user3 = Address::generate(&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![]
}
)]
);
// Increase 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::generate(&e);
let user1 = Address::generate(&e);
let user2 = Address::generate(&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::generate(&e);
let user1 = Address::generate(&e);
let user2 = Address::generate(&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 = "insufficient allowance")]
fn transfer_from_insufficient_allowance() {
let e = Env::default();
e.mock_all_auths();
let admin = Address::generate(&e);
let user1 = Address::generate(&e);
let user2 = Address::generate(&e);
let user3 = Address::generate(&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 = "Decimal must not be greater than 18")]
fn decimal_is_over_eighteen() {
let e = Env::default();
let admin = Address::generate(&e);
let _ = TokenClient::new(
&e,
&e.register(
Token,
(
admin,
19_u32,
String::from_val(&e, &"name"),
String::from_val(&e, &"symbol"),
),
),
);
}
#[test]
fn test_zero_allowance() {
// Here we test that transfer_from with a 0 amount does not create an empty allowance
let e = Env::default();
e.mock_all_auths();
let admin = Address::generate(&e);
let spender = Address::generate(&e);
let from = Address::generate(&e);
let token = create_token(&e, &admin);
token.transfer_from(&spender, &from, &spender, &0);
assert!(token.get_allowance(&from, &spender).is_none());
}
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 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_contract = e.register(
Token,
(
admin,
7_u32,
String::from_val(e, &"name"),
String::from_val(e, &"symbol"),
),
);
TokenClient::new(e, &token_contract)
}
// ... 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 six 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 aburn()
invocation decreases a user's balance, and that aburn_from()
invocation decreases a user's balance as well as consuming another user's allowance of that balance.test_zero_allowance()
- This function makes sure that atransfer_from()
with an zero balance doesn't create an empty allowance.transfer_insufficient_balance()
- This function ensures atransfer()
invocation panics when thefrom
user doesn't have the balance to cover it.transfer_from_insufficient_allowance()
- This function ensures a user with an existing allowance for someone else's balance cannot make atransfer()
greater than that allowance.decimal_is_over_eighteen()
- This function tests that constructing a token with too high of a decimal precision will not succeed.
Build the Contract
To build the contract, use the stellar contract build
command.
stellar 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 stellar-cli
installed, you can invoke contract functions using it.
- macOS/Linux
- Windows (PowerShell)
stellar contract invoke \
--wasm target/wasm32-unknown-unknown/release/soroban_token_contract.wasm \
--id 1 \
-- \
balance \
--id GBZV3NONYSUDVTEHATQO4BCJVFXJO3XQU5K32X3XREVZKSMMOZFO4ZXR
stellar contract invoke `
--wasm target/wasm32-unknown-unknown/release/soroban_token_contract.wasm `
--id 1 `
-- `
balance `
--id GBZV3NONYSUDVTEHATQO4BCJVFXJO3XQU5K32X3XREVZKSMMOZFO4ZXR