DEV Community

Mikołaj Bul
Mikołaj Bul

Posted on

From Zero to Smart Contract Developer: Soroban

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.

Visually speaking:
Fundraising app

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:
Soroban app architecture

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
Enter fullscreen mode Exit fullscreen mode

Defining a function:

fn sum(a: i32, b: i32) -> i32 {
    println!("Calculating sum");
    a + b
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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,
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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]
    }
}
Enter fullscreen mode Exit fullscreen mode

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 a Vec 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>;
}
Enter fullscreen mode Exit fullscreen mode

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,
}
Enter fullscreen mode Exit fullscreen mode

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>,
}
Enter fullscreen mode Exit fullscreen mode

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");
}
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

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!")
    }
}
Enter fullscreen mode Exit fullscreen mode

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());
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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"]'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Contract 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>
Enter fullscreen mode Exit fullscreen mode

Soroban events

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)