Soroban is the smart contract platform that allows developers to write and deploy smart contracts on the Stellar network. This tutorial will guide you through the process of building a simple smart contract using Rust and deploying it to Soroban.
I recently took on the role of Developer in Residence with Stellar and this is the first in a series of posts where I’ll explore the developer ecosystem.
- Stellar Environment For Developers
- Deploying Hello World In Soroban
- Building With Rust Soroban SDK
- Soroban Unit Tests & Optimisation
- Connecting Frontend To Soroban Contracts
- Soroban Tips & Best Practices
All the code in this document is open sourced at: https://github.com/jamesbachini/Soroban-Hello-World
Stellar Environment For Developers
Stellar is a decentralized p2p network of nodes all running the Stellar core application. The nodes process transactions and achieve consensus via Proof-of-Agreement mechanism. 3rd party developers can take advantage of the application layer which lets us host smart contracts within the network.
At the data availability level we have RPC nodes and the Horizon API which provides an access point to this network. When you connect to an RPC node you are connecting to a device which is participating in the Stellar network. Indexers and Hubble are used to provide historical data for the network.
Developers would generally use a framework for building applications on Stellar. There is the Soroban-SDK which is widely used to develop Rust smart contracts. There is also the Stellar-SDK for javascript, python, AssemblyScript, iOS, Flutter, Java, Go, PHP etc. The Stellar-cli is used for deploying and interacting with contracts from the command line.
At the Application level we have wallets, which enable users to store funds and connect their accounts to dApps. Passkey is a authentication system using modern web standards. Anchor provides on/off ramps for connecting Stellar to traditional finance banking rails.
Deploying Hello World In Soroban
Let’s take a look at a simple Rust smart contract that uses the Soroban SDK
#![no_std]
use soroban_sdk::{contract, contractimpl, vec, Env, String, Vec};
#[contract]
pub struct HelloContract;
#[contractimpl]
impl HelloContract {
pub fn hello(env: Env, to: String) -> Vec<String> {
vec![&env, String::from_str(&env, "Hello"), to]
}
}
mod test;
The contract generates a greeting message by combining “Hello” with the name or identifier provided by the user.
We start by removing the Rust standard library #![no_std] as it’s too large not suitable for smart contracts. We then import the soroban_sdk, defining each module we actually need from the global library for efficiency. The contract defines a basic structure, HelloContract, and implements a single function using the fn keyword called hello. This function takes two arguments: the environment (env), which is required by the Soroban SDK to interact with the blockchain, and a symbol (to), which represents the recipient of the greeting.
When the hello function is called, it creates a vector containing two elements: the word “Hello” and the symbol representing the recipient. The function uses symbol_short! to create the “Hello” message and appends the provided to symbol. The vector, containing the greeting message, is then returned as the output of the function.
Deploying To Soroban
We can install the required applications and dependencies from the command line. Here are the commands to deploy the contract to Soroban.
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
rustup target add wasm32-unknown-unknown
cargo install --locked stellar-cli --features opt
stellar network add --global testnet --rpc-url --network-passphrase "Test SDF Network ; September 2015"
stellar keys generate --global james --network testnet
stellar keys address james
stellar contract init
cargo test
cargo build --target wasm32-unknown-unknown --release
stellar contract deploy --wasm target/wasm32-unknown-unknown/release/hello_world.wasm --source james --network testnet
stellar contract invoke --id CONTRACT_HERE --source james --network testnet -- hello --to RPC
- We start by installing Rust and the wasm target.
- Then install the stellar-cli crate using cargo package manager.
- Then add a testnet network https://soroban-testnet.stellar.org
- Generate a new wallet and store it as “james”
- Run the unit tests
- Compile and build the contracts to WASM
- Deploy the WASM using the stellar-cli
- Call the function using stellar contract invoke
Wallets & XLM
We need XLM to deploy contracts or call functions. For testnet there is a very useful and efficient URL you can call, just add your public key to the end and you’ll receive testnet funds.
https://friendbot.stellar.org?addr=
There is also a useful tool for creating accounts and funding them with testnet funds at: https://lab.stellar.org/account/create
To on/off ramp into mainnet XLM there is a directory of “anchors” which is a term used to describe the services that https://anchors.stellar.org
There is a familiar Chrome/Web/Mobile wallet at: https://rabet.io
Note that the native XLM token has different addresses on different networks:
- Public Network: CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA
- Testnet: CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC
- Futurenet: CB64D3G7SM2RTH6JSGG34DDTFTQ5CFDKVDZJZSODMCX4NJ2HV2KN7OHT
Building With Rust Soroban SDK
The first thing you’ll notice if coming from a web dev background is that we have to declare data types in Rust. In Soroban we tend to use custom data types over native rust types, where possible for efficiency. To import the required types we call the modules when importing the Soroban SDK
use soroban_sdk::{Env, Symbol, Vec};
Here are the 10 most widely used data types in Soroban contracts:
Data Types in Soroban Smart Contracts
1. Bool
The Bool type represents a boolean value, either true or false.
use soroban_sdk::Bool;
let flag: Bool = true.into();
2. Integer
Soroban provides several integer types:
U32
: Unsigned 32-bit integerI32
: Signed 32-bit integerU64
: Unsigned 64-bit integerI64
: Signed 64-bit integerU128
: Unsigned 128-bit integerI128
: Signed 128-bit integer
use soroban_sdk::{U32, I64};
let unsigned: U32 = 42u32.into();
let signed: I64 = (-100i64).into();
3. Symbol
The Symbol
type represents short string identifiers (up to 32 characters) that are cheaper to use than full strings. Symbols are used to represent strings in a more efficient way within the Soroban environment. The Symbol
type is optimized for smart contracts.
Instead of using standard Rust strings (String or &str), you use Symbol for better performance and lower transaction costs.
use soroban_sdk::{Symbol, symbol_short};
let sym: Symbol = symbol_short!("example");
4. Vec
Vec is a growable array type similar to Rust’s Vec, but optimized for Soroban. The Vec module is similar to Rust’s standard Vec and provides a vector data structure optimized for Soroban contracts, allowing you to store and manipulate sequences of items efficiently.
use soroban_sdk::{Vec, U32};
let mut numbers: Vec<U32> = Vec::new(&env);
numbers.push_back(1u32.into());
numbers.push_back(2u32.into());
5. Map
Map is an associative array type, similar to Rust’s HashMap. The Map module provides a key-value store and is useful for storing data that needs to be accessed via keys, like user balances or configurations.
use soroban_sdk::{Map, Symbol, U32};
let mut scores: Map<Symbol, U32> = Map::new(&env);
scores.set(symbol_short!("Alice"), 100u32.into());
scores.set(symbol_short!("Bob"), 85u32.into());
6. Bytes
Bytes
represents a sequence of bytes and is useful for handling raw binary data.
use soroban_sdk::Bytes;
let data: Bytes = Bytes::from_array(&env, &[0, 1, 2, 3]);
7. String
While Symbol
is preferred for short identifiers, String
can be used for longer text data.
use soroban_sdk::String;
let message: String = String::from_str(&env, "Hello, Soroban!");
8. Address
Address
represents a Stellar address and can be created from various formats.
use soroban_sdk::Address;
let addr: Address = Address::from_string(&env, "GBZX...");
9. Structs
You can define custom struct types using the contracterror macro:
use soroban_sdk::{contracttype, Address, U32};
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct UserInfo {
pub name: String,
pub age: U32,
pub address: Address,
}
10. Enums
Custom enum types can be defined using the contracterror macro:
use soroban_sdk::contracterror;
#[contracterror]
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Error {
AlreadyInitialized = 1,
NotInitialized = 2,
InsufficientFunds = 3,
}
Typecasting
When working with Soroban types, you often need to convert between Rust types and Soroban types. Traits like TryFromVal, TryIntoVal, FromVal, and IntoVal are used for converting between different data types.
pub trait IntoVal<E: Env, T> {
fn into_val(&self, e: &E) -> T;
}
RUST SYNTAX / StyleGuide
Here are some general Rust/Soroban syntax guides for clean code.
- Use snake_case for variables, functions, and file names.
- Use CamelCase for struct and enum names.
- Four spaces for indentation, not tabs. Don’t hate me for this.
- Use let for variables, with mut for mutability (let mut x = 5;).
- Prefer immutable variables; Rust encourages immutability by default.
- Use match for pattern matching, don’t create long if-else chains.
- Use :: to access items from modules or namespaces.
- Avoid unwrap() unless sure, use expect() with an error message for clarity.
- Use ? for error handling in functions that return Result or Option.
Soroban Environment
The Soroban SDK consists of several modules, each serving a specific purpose in smart contract development. The Env module provides access to the environment in which the smart contract operates. It allows you to interact with the blockchain’s state, access ledger entries, and perform operations like invoking other contracts.
You use Env to read and write data, emit events, and perform other actions that interact with the blockchain.
CRYPTOGRAPHY
The Soroban SDK also includes a Crypto module which provides access to the following:
- sha256 – Hashing algorithm used in Bitcoin
- keccak256 – Hashing algorithm used in Ethereum
- ed25519_verify – Verifies a ECDSA ed25519 signature
- secp256r1_recover – Returns the ECDSA public key from a signature
- secp256r1_verify – Verifies a ECDSA secp2561 signature
Events
Soroban events are a powerful feature in Stellar’s smart contract platform that allow off-chain applications to monitor and react to changes within on-chain contracts.
These events are emitted only when transactions succeed and are stored temporarily for about 24 hours. Each ContractEvent can include up to four topics of varying types and a data payload, enabling detailed state change notifications.
There are three types of events:
- CONTRACT events for state changes
- SYSTEM events for host-level actions
- DIAGNOSTIC events for debugging purposes
Developers can emit these events within their contracts to provide transparency and facilitate interaction with off-chain services.
use soroban_sdk::{contractimpl, Env, Symbol, Vec, BytesN};
pub struct MyContract;
#[contractimpl]
impl MyContract {
pub fn emit_event(env: Env, user: BytesN<32>, action: Symbol, value: i128) {
// Define topics
let topics = vec![
Symbol::from_str("UserAction"),
Symbol::from_slice(&user.to_bytes()),
action.clone(),
];
// Define data
let data = env.ledger().get(&value).unwrap_or_default();
// Emit the ContractEvent
env.emit_contract_event(topics, data);
}
}
In this example, the emit_event
function creates a CONTRACT
event with three topics: a static string "UserAction"
, the user’s ID, and the action symbol. It also includes a data payload derived from the value
parameter. Off-chain applications can listen for such events to respond to specific contract interactions.
Tokens & Assets
Tokens and assets in Soroban are managed through standardized smart contract interfaces that ensure interoperability and seamless integration across the Stellar network.
The Token Interface defines essential functions such as transfer
, approve
, balance
, and allowance
, enabling consistent interactions between different token contracts and decentralized applications. Tokens can emit events to signal state changes, handle authorization to secure operations, and manage metadata like name, symbol, and decimals to provide detailed information about the asset.
By adhering to Soroban’s SEP-41 Token Interface, developers can create custom tokens that are compatible with existing contracts and services. This should look familiar to anyone that has worked with tokens in the past…
pub trait TokenInterface {
fn allowance(env: Env, from: Address, spender: Address) -> i128;
fn approve(env: Env, from: Address, spender: Address, amount: i128, expiration_ledger: u32);
fn balance(env: Env, id: Address) -> i128;
fn transfer(env: Env, from: Address, to: Address, amount: i128);
fn transfer_from(env: Env, spender: Address, from: Address, to: Address, amount: i128);
fn burn(env: Env, from: Address, amount: i128);
fn burn_from(env: Env, spender: Address, from: Address, amount: i128);
fn decimals(env: Env) -> u32;
fn name(env: Env) -> String;
fn symbol(env: Env) -> String;
}
Soroban Unit Tests & Optimisation
Testing is a crucial part of smart contract development and until AI takes over we tend to spend more time writing unit tests than creative coding. Effective testing ensures that your contract behaves as expected before deploying it to the network.
It’s at this stage where you can also optimise your code to help reduce transaction costs and keeps your contract within the 64KB size limit.
Writing Unit Tests for Soroban Smart Contracts
Soroban SDK provides support for writing unit tests using Rust’s standard testing framework. Here’s how you can write unit tests for your smart contracts.
#[cfg(test)]
mod tests {
use super::*;
use soroban_sdk::{Env, Symbol, symbol_short, vec};
#[test]
fn test_hello() {
let env = Env::default();
let contract = HelloWorldContract;
let to = symbol_short!("World");
let result = contract.hello(env.clone(), to);
let expected = vec![&env, symbol_short!("Hello"), to];
assert_eq!(result, expected);
}
}
In this test we create a default mock Env
instance to simulate the blockchain environment. Define a test function annotated with #[test]
Instantiate our HelloWorldContract
, then call the hello
function and assert that the result matches our expectation.
Optimizing Code for Efficiency and Size
Soroban contracts have a maximum size of 64KB. The following command can attempt to optimize the build:
stellar contract optimize --wasm target/wasm32-unknown-unknown/release/hello_world.wasm
Quick Tips For Optimizing Code
- Minimize the use of heavy computations or loops.
- Limit the use of external crates. Only include what’s necessary for your contract’s functionality.
- Remove any unused code, including unused functions or variables.
- Use Rust’s #[inline] attribute to suggest inlining small functions.
- Use simple error types and avoid verbose error messages that increase code size.
- If your contract logic is extensive, consider splitting functionality into separate contracts.
- Reuse common functionality by linking to library contracts instead of duplicating code.
- Use compact data formats for any data that needs to be serialized.
Contract Storage
Contract storage in Soroban includes three types of storage with different characteristics.
- Temporary storage has the lowest fees, is deleted permanently once its Time-To-Live (TTL) expires (approximately 80 seconds), and is suitable for time-sensitive or easily recreated data like price oracles.
- Persistent storage has higher fees, allows data to be archived and restored using special operations, and is ideal for user data that needs to be preserved. Default TTL is 14 days.
- Instance storage, a form of persistent storage, ties its TTL to the contract’s lifespan, making it suitable for contract related data like metadata or admin accounts. Both persistent and instance storage are recoverable after archival but have higher costs. Default TTL is the same as persistent storage at 14 days.
It’s possible to extend the TTL of a contract from the command line or within soroban:
soroban contract extend --id C...YOURCONTRACTID... --key KeySymbol --durability temporary --ledgers-to-extend 10000
env.storage().temporary().extend_ttl(&user, LOW_TTL, HIGH_TTL);
If TTL is not extended any contracts or data that expire goes into archival state where it is recoverable.
Elegantly simple contracts are not only more efficient but they are easier to work on, debug and have less potential for security vulnerabilities.
Connecting Frontend To Soroban Contracts
Integrating your smart contract with a frontend or dApp allows users to interact with your contract through a user friendly interface. In this section, we’ll create a simple HTML page that calls the hello function of our Hello World Contract deployed on the Soroban testnet.
Setting Up the Frontend Environment
We’ll use plain HTML and JavaScript for simplicity. In a real-world scenario, you might use frameworks like React or Svelte.
Create an index.html file with the following content:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Stellar Hello World</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/stellar-sdk/12.2.0/stellar-sdk.js"></script>
</head>
<body>
<h1>Stellar Hello World</h1>
<input type="text" id="my-name" placeholder="Enter your name" maxlength="32" />
<button id="callContract">Call Contract</button>
<div id="result"></div>
<script>
const rpc = new StellarSdk.SorobanRpc.Server("https://soroban-testnet.stellar.org");
const contractId = 'CAAD24Y7OVOZQRFSVAW2Z4MA6JZEF6GPQYR2F2R3N7WTQLEHL2ZTEUBN';
const contract = new StellarSdk.Contract(contractId);
const secret = 'SBZZ6QJC7Y3ZIGZBSTRN3W6QNDWISVDWH3JHME7DKAXS7DXMYZY7LE4E';
const publicKey = 'GAAVHTZYE6O4BIKRVZCVQCEIKVVDR2ZIRPWGV6TVPB5UYTJR7HSKIKTK';
const networkPassphrase = StellarSdk.Networks.TESTNET;
async function callHelloWorld() {
const account = await rpc.getAccount(publicKey);
const myName = document.getElementById('my-name').value;
const input = StellarSdk.nativeToScVal(myName, { type: "string" })
console.log('input',input)
const tx = new StellarSdk.TransactionBuilder(account, {
fee: StellarSdk.BASE_FEE,
networkPassphrase: StellarSdk.Networks.TESTNET,
})
.addOperation(contract.call("hello", input))
.setTimeout(30)
.build();
const preparedTx = await rpc.prepareTransaction(tx);
preparedTx.sign(StellarSdk.Keypair.fromSecret(secret));
console.log('preparedTx', preparedTx);
const txResult = await rpc.sendTransaction(preparedTx);
console.log('txResult', txResult);
const hash = txResult.hash;
await new Promise(r => setTimeout(r, 10000));
let getResponse = await rpc.getTransaction(hash);
console.log('getResponse',getResponse)
console.log('getResponse.returnValue',getResponse.returnValue._value[0]._value)
const decoder = new TextDecoder();
const string1 = decoder.decode(getResponse.returnValue._value[0]._value);
const string2 = decoder.decode(getResponse.returnValue._value[1]._value);
document.getElementById('result').innerHTML = `${string1} ${string2}`;
}
document.getElementById('callContract').addEventListener('click', callHelloWorld);
</script>
</body>
</html>
Replace YOUR_CONTRACT_ID with the deployment ID of your contract. Note that the testnet get’s reset at regular intervals so it’s best to deploy your own version of it using the Soroban Hello World Tutorial above.
You can upload the index.html file to a web server or run it locally with various local servers, I use Nodejs http-server
npm install http-server
npx http-server ./
We can see get the transaction hash from the console.log and put this in a block explorer: https://stellar.expert/explorer/testnet/tx/6609924603797504
Horizon API vs Stellar SDK’s
The Stellar Horizon API and SDKs provide comprehensive access to the necessary endpoints and functions to query these specific operations. For example:
- Horizon API provides real-time querying of operations, such as liquidity pool trades and path payments. Using these endpoints allows you to track all operations that match the criteria needed for monitoring asset exchanges.
- Stellar SDKs (for JavaScript, Python, etc.) allow developers to write custom scripts to automate transaction monitoring. For instance, in the JavaScript SDK, the .operations() method can filter for LIQUIDITY_POOL_TRADE or PATH_PAYMENT operations to capture only the relevant exchanges.
The horizon API can be accessed like this using stellar-sdk.js
const horizon = new StellarSdk.Horizon.Server('https://horizon-testnet.stellar.org');
horizon.transactions()
.forAccount(publicKey)
.call()
.then(function (page) {
console.log('Page 1: ');
console.log(page.records);
return page.next();
})
.then(function (page) {
console.log('Page 2: ');
console.log(page.records);
})
.catch(function (err) {
console.log(err);
});
Here is a list of endpoints:-
- https://horizon-testnet.stellar.org/accounts/{account_id}
- https://horizon-testnet.stellar.org/fee_stat
- https://friendbot.stellar.org/{addr}
- https://horizon-testnet.stellar.org/ledgers/{sequence}
- https://horizon-testnet.stellar.org/operations/{id}
- https://horizon-testnet.stellar.org/transactions/{hash}
You can find a full list by calling: https://horizon-testnet.stellar.org
Soroban Tips & Best Practices
Here are some tips and best practices to help you write efficient and effective contracts with Soroban and Rust.
1. Understanding References and Borrowing
In Rust, the & symbol is used to create a reference to a value, allowing you to borrow it without taking ownership.
let value = 10; let reference = &value;
references avoids unnecessary data copying, which is crucial in a resource-constrained environment like smart contracts.
2. Working with the Environment (Env)
Always pass the Env instance (env: Env) to functions that require access to the contract environment. Use references to &env where possible to avoid unnecessary cloning.
3. Efficient Data Handling
Use Symbol instead of String or &str for string data. Symbols are more efficient and optimized for Soroban. They are particularly useful for short, fixed identifiers to save on costs.
Prefer Soroban’s built-in types over Rust’s standard types for better integration and gas efficiency.
When defining custom types, always use the contracttype or contracterror macros to ensure proper serialization.
Use the most appropriate integer type for your use case to optimize for space and gas costs. Unsigned integers can’t go negative, if you don’t need a gazillion decimals use a smaller U32.
All the code in this document is open sourced at: https://github.com/jamesbachini/Soroban-Hello-World