This part of the series covers the process of designing and implementing an example smart contract application on the Soroban network. It assumes that you're familiar with the basics of blockchain and you know how classic Stellar assets work.
Learning is key
As a software developer you've probably at least once had a conversation with one of your friends that went something like this:
- Hey man, I've got a great business idea but I need someone who knows programming. Are you in?
- Sounds cool, what is it about?
- A simple website like Google, white background and a search bar.
- Yeah, right...
This time I was the one with an idea but without necessary knowledge. Instead of finding myself a bored Soroban Developer I decided to figure it out myself.
General idea
Imagine a platform where content creators could raise money for specific goals they want to achieve. A creator would list a few goals, and optionally include some secret content they want to release as soon as a particular goal is met - it would act as a way to motivate viewers to take part in fundraising. Currently donations usually go through third party providers that apply a significant fee on each donation. By transferring it through blockchain those fees could be substantially lower.
Some assumptions:
- money can be withdrawn only after reaching a certain goal, otherwise there would be no fun and the actual goals would mean nothing
- viewers specify max amount they want to tip, if the last goal is going to be fulfilled with a lesser tip, then they are going to pay less
- our app will track total tips per user to later form a scoreboard
Built on Soroban
It's time to take into account some aspects of the technology we're going to use.
Soroban contracts are written in Rust. If you have never used Rust before, don't panic. About two weeks ago I also knew nothing about it. You'll figure it out.
As you know blockchain is like a public ledger and thus storing secrets isn't its primary use case. That's why extracting this feature to some backend app will probably be the easiest solution. We're only going to implement the core functionality of handling tips inside our smart contract. The contract however still needs a way to communicate with the outside world.
Storing data is another concern. It would probably be a bit cumbersome to handle all users and all their sets of goals in one place. That's why we're going to use Soroban's ability to dynamically deploy smart contracts. We'll deploy one contract responsible for deploying smaller contracts which in turn will handle only a particular set of goals for a single user.
More detailed visualization of our app:
Setting up the environment
Installing all the necessary things is pretty easy and already documented in Soroban's Getting Started guide, so I suggest you visit this site first and follow the setup instructions. Two main CLI tools you'll need later on are cargo
and soroban
. Cargo is a package manager for Rust and soroban
will help you interact with Soroban network. Execute soroban --help
to get a sneak-peek of what it can help you with.
Let's learn Rust
Rust is a language that has some unique features compared to other ones like C or Java. For me the one that distinguishes it the most is the idea of ownership. But first we're going to look at some code examples.
Constants and variables
let mut variable = 1;
variable += 1; // all good
let constant = 1;
constant += 1; // compile error
Defining a function:
fn sum(a: i32, b: i32) -> i32 {
println!("Calculating sum");
a + b
}
You don't have to use "return" keyword. If the last executed line inside a function doesn't end with a semicolon, then its result is going to be the returned value.
Structs
struct Pet {
name: String,
owner: String,
}
impl Pet {
fn greet_owner(self) {
println!("{}", format!("Hi {}!", self.owner))
}
}
fn main() {
let pet = Pet {
name: String::from("Garfield"),
owner: String::from("Jon"),
};
pet.greet_owner();
}
There are no classes in Rust, instead we define structs. However we can attach an impl block to a struct to extend it with some functions that can later be called in a handy method-call-like way.
Attributes
#[derive(Clone)]
struct Person {
name: String,
owner: String,
}
Attributes are pieces of metadata that can, among others, be used to automatically generate some functionality like a .clone()
function.
Modules
fn main() {
auth::authenticate();
println!("Hello world")
}
mod auth;
Rust allows you to organize your code into modules, stored in separate files, which can then be included in other files by using the mod keyword.
Ownership
Ownership is the mechanism that simplifies memory management in Rust. You don't have to handle it manually like in C, yet there is no garbage collector. The idea is that every variable lives only within a scope and has one owner. Variable can go out of scope when function returns or the ownership is passed to another scope. For example calling a function with a variable passes the ownership to that function. If we don't want to change the owner of the variable we can borrow it to the function.
fn takes_ownership(name: String) {
println!("{}", name);
}
// types prefixed with & are borrowed to the function
fn borrows(name: &String) {
println!("{}", name);
}
fn main() {
let name = String::from("John");
borrows(&name); // borrowed variables are prefixed with &
println!("{}", name); // name still valid here
takes_ownership(name);
println!("{}", name); // compile error, name already freed
}
In practice it's a bit more complicated than what I tried to explain here so I recommend you check out this chapter from Rust docs.
Soroban's architecture - deeper dive
Contract execution
Smart contracts on Soroban are written as libraries in Rust. Those libraries are then compiled into WASM that is executed inside Virtual Machines on the Hosts belonging to the network. Those VM instances are intended to be lightweight and short-lived. Their lifecycles span from the contract call to function exit.
Isolated, but connected
Functions inside the contract reach out of the VM by accessing the Host's API. Each function accepts an Env object that allows it to make those interactions (e.g. persist data, emit events that can later be polled from the Internet).
Optimizations
In order to optimize the binary we need to accept some tradeoffs:
- By default we exclude the standard Rust library as it causes the binaries to grow too much in size.
- Dynamic memory allocation withing guest's linear memory is expensive. Instead we can store dynamically allocated data inside Host objects.
Interoperability with classic Stellar assets
For a classic Stellar asset to be accessible on Soroban it needs to have a corresponding Stellar Asset Contract deployed. Token contracts generally implement the Token Interface. The XLM contract is already deployed on the Futurenet, so later on we'll only have to find its ID.
Contract lifecycle
For a contract to be deployed on the network it's code needs to be installed there first. The result of installing a contract with soroban
CLI is a wasm hash that identifies the location of contract's code.
Pulling the code
Go ahead and pull the project from my GitHub repo and switch to the chapter2
branch. But before you look at it I'll try to walk you through some examples.
Hello World
The Hello World example from Soroban's Getting Started guide shows clearly how the contract is built. Let's take a look at it.
#![no_std]
use soroban_sdk::{contractimpl, symbol, vec, Env, Symbol, Vec};
pub struct Contract;
#[contractimpl]
impl Contract {
pub fn hello(env: Env, to: Symbol) -> Vec<Symbol> {
vec![&env, symbol!("Hello"), to]
}
}
There are a few things to note here:
- Each contract starts with a
#![no_std]
attribute which excludes the Rust's standard library - We define a contract by creating an empty struct and attaching an
impl
block to it - The impl block needs to have a
#[contractimpl]
attribute attached - Contract functions which intend to be accessible by the caller need to be public and the function name must be at most 10 characters long
- Each public function accepts an Env object as the first argument, the following arguments are provided by the caller
-
vec!
is a macro that creates aVec
data structure from soroban_sdk. It is a growable vector stored in the host environment.
Public interface
The tip-contract is going to comprise four public functions described by the trait (interface) below. We're not actually going to use this trait but it describes well what we're trying to accomplish.
trait ContractTrait {
// initializes the contract with Goal Description
fn init(env: Env, goal_desc: GoalDesc);
// Function used by viewers, they specify their identity
// and the max amount they want to tip
fn tip(env: Env, tipper: Tipper, max_transfer: i128) -> i128;
// Pays out the tokens due to the creator for completed goals
fn withdraw(env: Env) -> i128;
// Returns a scoreboard with nicknames mapped to
// the sum of tips
fn scoreboard(env: Env) -> Map<Bytes, i128>;
}
Contract types
Fields present in custom contract types also need to be at most 10 characters long. Sometimes it is challenging to find a good name that is this short and still explains the meaning well.
Types exposed to the user:
#[contracttype]
pub struct GoalDesc {
pub creator: Address,
pub token: BytesN<32>,
pub goals: Vec<i128>,
}
#[contracttype]
#[derive(Clone)]
pub struct Tipper {
pub nickname: Bytes,
pub address: Address,
}
Things to note:
- Another important attribute is
#[contracttype]
. Each custom type we want to use in the contract needs to be annotated with it. - Address is a built-in type that identifies an account
- Our contract is also going to support tokens other than XLM, provided they implement the Token Interface
Contract types:
#[contracttype]
#[derive(Clone, Copy, PartialEq)]
// Order: Uninitialized -> Initialized -> Completed
pub enum Phase {
Uninitlzd,
Initlzd,
Completed,
}
#[contracttype]
#[derive(Clone)]
// used for storing things in the key-value store.
pub enum DataKey {
GoalDesc,
CurState,
}
#[contracttype]
#[derive(Clone)]
// describes the whole mutable state of the contract.
pub struct CurrentState {
pub phase: Phase,
// amount of money already tipped for the current goal
pub goal_money: i128,
// first unpaid goal
pub unpaid_idx: u32,
// index of the current goal
pub cur_goal: u32,
pub scoreboard: Map<Bytes, i128>,
}
Importing Soroban Token Spec
To make use of the token client we need to import the token specification in the form of a .wasm binary. At first it was a bit odd to me that there wasn't a dedicated package in the SDK that would handle standard token operations, but maybe there's a reason for that.
mod token {
soroban_sdk::contractimport!(file = "soroban_token_spec.wasm");
}
To import a binary into the contract we use contractimport!
macro from the SDK.
Storing data
Retrieving and storing data is pretty straightforward. Env.storage() object provides us with get
and set
functions which access the data by key. We can use DataKey type we've defined earlier for this purpose.
let cur_state = env.storage().get(&DataKey::CurState).unwrap().unwrap();
env.storage().set(&DataKey::CurState, &empty_state(&env, Phase::Initlzd));
Panicking
One way to stop the contract execution is to invoke the panic!
macro which causes the program to exit immediately. None of the changes made in the contract call which panicked are persisted to the network.
fn verify_init(env: &Env, goals: &Vec<i128>) {
if current_phase(&env) != Phase::Uninitlzd {
panic!("Contract already initialized!")
}
}
Emitting events
To emit events we use the Env.events().publish()
function which accepts a tuple of topics (up to 4) and a value that we want to publish to the topic.
const GOALS_TOPIC: Symbol = symbol!("goals");
fn emit_events(env: &Env, goals_met: Vec<i128>) {
for goal in goals_met {
env.events().publish((GOALS_TOPIC, ), goal.unwrap());
}
}
Transfering tokens
Finally we've made it to the point where we transfer the tokens. It can be achieved by creating the token client we've imported earlier and calling it's .xfer()
function. Below is an example of a transfer from the tipper to the contract.
let contract = env.current_contract_address();
let client = token::Client::new(&env, &goal_desc.token);
client.xfer(&tipper.address, &contract, &amount);
Contract-deployer
The code of contract deployer is relatively short but utilizes two important functions from the Env object.
#[contractimpl]
impl Deployer {
pub fn deploy(
env: Env,
salt: Bytes,
wasm_hash: BytesN<32>,
creator: Address,
token: BytesN<32>,
goals: Vec<i128>,
) -> BytesN<32> {
let id = env.deployer().with_current_contract(&salt).deploy(&wasm_hash);
let goal_desc = tip_contract::GoalDesc { creator, token, goals };
let _: () = env.invoke_contract(&id, &symbol!("init"), (goal_desc, ).into_val(&env));
id
}
}
Contract ids of dynamically deployed contracts are deterministic and generated based on the deploying contract's id and provided salt. That's why during each deployment we'll need to pass a unique salt.
To invoke a contract function we need to provide the contract id, the name of the fuction and its arguments.
Demo
All the commands described here are also present in the repo in the scripts
folder. You'll also find a template there that will help you manage the things you should note down to successfully go through the demo.
Creating necessary accounts
Generate 3 keypairs for accounts on the Futurenet and fund each of them using Friendbot. One of the accounts is going to act as the creator and two of them are going to be viewers.
Installing tip-contract
If we want to deploy the tip-contract multiple times with the contract-deployer, then we need to install the code of tip-contract on the network first. But before that let's build the project.
cargo build --target wasm32-unknown-unknown --release
Then we install the contract on the network. Replace <tags> with your generated credentials.
soroban contract install \
--wasm ../target/wasm32-unknown-unknown/release/tip_contract.wasm \
--secret-key <Creators private key> \
--rpc-url https://rpc-futurenet.stellar.org:443 \
--network-passphrase 'Test SDF Future Network ; October 2022'
The result of this call is a wasm hash that identifies the contract code on the network.
Deploying contract-deployer
The contract deployer needs to be deployed only once, so we can skip the step of separately installing the contract and instead use soroban contract deploy
command.
soroban contract deploy \
--wasm ../target/wasm32-unknown-unknown/release/contract_deployer.wasm \
--secret-key <Creator private key> \
--rpc-url https://rpc-futurenet.stellar.org:443 \
--network-passphrase 'Test SDF Future Network ; October 2022'
Deploying and initializing tip-contract
Deployment and initialization happens during one call to deploy()
in contract-deployer. That's why we need to specify the Goal Description together with deployment details i.e. wasm hash and salt.
# Goals are specified in stroops (1XLM = 10.000.000 Stroops)
soroban contract invoke \
--id <Contract deployers contract id> \
--secret-key <Creator private key> \
--rpc-url https://rpc-futurenet.stellar.org:443 \
--network-passphrase 'Test SDF Future Network ; October 2022' \
--fn deploy \
-- \
--salt 0000000000000000000000000000000000000000000000000000000000000000 \
--wasm_hash <Wasm hash> \
--creator <Creator public key> \
--token d93f5c7bb0ebc4a9c8f727c5cebc4e41194d38257e1d0d910356b43bfc528813 \
--goals '["10000000","100000000"]'
Value specified here for the token is the id of XLM Stellar Asset Contract.
After you executing the command above go to the Horizon endpoint and note down the core_latest_ledger
number.
Tipping
As an example we're going to send two tips from different accounts.
# First Tip
soroban contract invoke \
--id <Tip contract contract id> \
--secret-key <Tipper1 private key> \
--rpc-url https://rpc-futurenet.stellar.org:443 \
--network-passphrase 'Test SDF Future Network ; October 2022' \
--fn tip \
-- \
--tipper '{"nickname":"4A6F686E20446F64", "address": "<Tipper1 public key>"}' \
--max_transfer 10000000
# Second Tip
soroban contract invoke \
--id <Tip contract contract id> \
--secret-key <Tipper2 private key> \
--rpc-url https://rpc-futurenet.stellar.org:443 \
--network-passphrase 'Test SDF Future Network ; October 2022' \
--fn tip \
-- \
--tipper '{"nickname":"4A6F686E20446F65", "address": "<Tipper2 public key>"}' \
--max_transfer 100000000
Take a look at the way we pass custom types to the functions. We represent Tipper as a JSON object and since we've defined the nickname as Bytes, we need to pass it as a hex encoded string. More on custom types can be found in Soroban Quest chapter 5.
Withdrawing
In the previous step we've sent a total of 11 XLM and completed all the goals for the contract. Now it's time for the creator to withdraw the tokens.
soroban contract invoke \
--id <Tip contract contract id> \
--secret-key <Creator private key> \
--rpc-url https://rpc-futurenet.stellar.org:443 \
--network-passphrase 'Test SDF Future Network ; October 2022' \
--fn withdraw
Scoreboard
Let's fetch the scoreboard to see if the results are accurate.
soroban contract invoke \
--id <Tip contract contract id> \
--secret-key <Any private key> \
--rpc-url https://rpc-futurenet.stellar.org:443 \
--network-passphrase 'Test SDF Future Network ; October 2022' \
--fn scoreboard
Seems like the result is correct.
Events
The last thing we can check are the events emitted by our contract. Now you'll use the core_latest_ledger
number you've noted down earlier - it tells the API where to start looking for the events.
soroban events \
--rpc-url https://rpc-futurenet.stellar.org:443 \
--start-ledger <Start ledger> \
--id <Tip contract contract id>
Success! The contract emitted events that correspond to the goals that were completed.
Checking the balance
Head over to Stellar Lab and check the balances of your accounts. If everything went fine the tips should be transferred from viewers to the creator.
Summary
If you've come this far then congratulations! You now know the basics of developing Smart Contracts on Soroban. This chapter comes to an end without actually completing the rest of the app presented at the beginning, because this post is already a bit long. There are also few things that I would like to mention now.
- My implementation of the contract definitely has some shortcomings e.g. it lacks proper error handling. To see correct examples take a look at the docs.
- This guide doesn't cover the topic of testing. You can find some tests in the repo, but they only check one happy path needed for the Demo.
- This contract implements no authentication. I encourage you to poke around in the code to see if you'll be able to exploit it in some fun way.
Recommended sources
- Getting Started - official Soroban guide for beginners
- Soroban Learn - official docs explaining the architecture of Soroban
- Sorobandev blog - it explains the examples presented in Soroban docs but in greater detail
- Soroban Quest - an interactive quest where you can learn more about Soroban
Thanks for reading!
Top comments (0)