DEV Community

Cover image for Soroban Series 1: Building a Payment Streaming App
Shada Tosin
Shada Tosin

Posted on

Soroban Series 1: Building a Payment Streaming App

Streaming payments are a convenient way for users to pay a beneficiary over a period of time while ensuring that at every point in time only the value accrued can be withdrawn by the beneficiary.

In this article I will be building a decentralized application on the Stellar Network with Soroban.

Here is the link to the complete source code: Github.

The Backend

The first step to creating a Soroban smart contract is to setup the environment as described here.
Once the environment is set up, you should use cargo to start a new project. You can also clone an existing repository like this one and use it as a starter project.

Nb: Given the fact that Soroban is still actively being developed with no stable release a lot of the patterns in this article might not be supported fully in the nearest future.

- Using the Latest Soroban CLI Version

In order to use the latest release of Soroban CLI with all the new features available we will add the below code to the config.toml file in the .cargo directory.

[alias] # command aliases
install_soroban = "install --git https://github.com/stellar/soroban-tools --rev 9b75c00fd19c0baf379c84668d90bfe444734173 --root ./target soroban-cli --debug"
Enter fullscreen mode Exit fullscreen mode

The '9b75c00fd19c0baf379c84668d90bfe444734173' is the git commit id of the latest release of Soroban CLI in the Github repository.
After making this modification, you can follow the steps outlined here to install the necessary dependencies.

- Building the Streaming Contract

The Payment Streaming contract will be centred around the Stream Struct that will hold important information about a payment stream

#[derive(Clone, Debug)]
#[contracttype]
pub struct Stream {
    pub id: u32,
    pub sender: Address,
    pub recipient: Address,
    pub start_time: u64,
    pub stop_time: u64,
    pub deposit: i128,
    pub rate_per_second: u64,
    pub remaining_balance: i128,
    pub token_address: Address,
    pub token_symbol: String,
    pub token_decimals: u32,
}
Enter fullscreen mode Exit fullscreen mode

We will then create the contract struct and implement it

#[contract]
struct StreamToken;

#[contractimpl]
#[allow(clippy::needless_pass_by_value)]
impl StreamToken {
    // Implement the contract
}
Enter fullscreen mode Exit fullscreen mode

Before implementing the contract, we will have to create DataKeys that will be used to store out states.

#[derive(Clone)]
#[contracttype]
pub enum DataKey {
    Token,
    NextStreamId,
    Streams(u32),
    UserStreams(Address),
}
Enter fullscreen mode Exit fullscreen mode

The token is the default token address supported by the contract, the Streams key is a mapping of the Streams to the Stream Id while the UserStreams key is a mapping of a user's address to a collection of streams.

Every smart contract in Soroban must be initialized with the pub fn initialize(e: Env) initialization function.

pub fn initialize(e: Env, token: Address, start_id: u32) {
    assert!(
        !e.storage().instance().has(&DataKey::NextStreamId),
        "already initialized"
    );
    e.storage()
        .instance()
        .set(&DataKey::NextStreamId, &start_id);
    e.storage().instance().set(&DataKey::Token, &token);
    e.storage().instance().bump(INSTANCE_BUMP_AMOUNT);
}
Enter fullscreen mode Exit fullscreen mode

In the above code, we are checking whether a stream Id exist which will signify that the contract has already been initialized. If not, we are going to set the token to be used in this streaming contract and the start id.
To better understand how states are stored, you can check out these Soroban articles.

We will then implement some get functions to get the stream by Id and the stream by User Address

fn get_stream(e: &Env, stream_id: &u32) -> Stream {
    e.storage()
        .persistent()
        .get::<_, Stream>(&DataKey::Streams(stream_id.clone()))
        .expect("invalid stream id")
}

fn get_streams_by_user(e: &Env, who: &Address) -> Vec<Stream> {
    e.storage()
        .persistent()
        .get::<_, Vec<Stream>>(&DataKey::UserStreams(who.clone()))
        .unwrap_or(Vec::new(&e))
}
Enter fullscreen mode Exit fullscreen mode

These functions will be exposed in the contract implementation as shown below

pub fn get_stream(e: Env, stream_id: u32) -> Stream {
        get_stream(&e, &stream_id)
    }

    pub fn get_streams_by_user(e: Env, caller: Address) -> Vec<Stream> {
        get_streams_by_user(&e, &caller)
    }
Enter fullscreen mode Exit fullscreen mode

One of the core ability of the streaming contract is the way the balance of a user is dynamically generated and changes at each point within the streaming interval.

fn get_balance_of(e: &Env, caller: &Address, stream: &Stream) -> i128 {
    let start_time = stream.start_time;
    let stop_time = stream.stop_time;
    let current_ledger_time = get_ledger_timestamp(&e);
    let delta = get_delta_of(&start_time, &stop_time, &current_ledger_time);

    let mut local_balance = LocalBalance {
        recipient_balance: 0,
        withdrawal_amount: 0,
        sender_balance: 0,
    };
    local_balance.recipient_balance = (delta * stream.rate_per_second) as i128;

    // If the stream `balance` does not equal `deposit`, it means there have been withdrawals.
    // We have to subtract the total amount withdrawn from the amount of money that has been streamed until now.
    if stream.deposit > stream.remaining_balance {
        local_balance.withdrawal_amount = stream.deposit - stream.remaining_balance;
        check_nonnegative_amount(local_balance.withdrawal_amount);
        local_balance.recipient_balance =
            local_balance.recipient_balance - local_balance.withdrawal_amount;
        check_nonnegative_amount(local_balance.recipient_balance);
    }

    if caller.clone() == stream.recipient {
        local_balance.recipient_balance
    } else if caller.clone() == stream.sender {
        local_balance.sender_balance = stream.remaining_balance - local_balance.recipient_balance;
        check_nonnegative_amount(local_balance.sender_balance);
        local_balance.sender_balance
    } else {
        0
    }
}
Enter fullscreen mode Exit fullscreen mode

This function will also be called in the contract implementation

    /// Returns the amount of tokens that have already been released to the recipient.
    /// Panics if the id does not point to a valid stream.
    /// @param stream_id The id of the stream
    /// @param who The address of the caller
    /// @return The amount of tokens that have already been released
    pub fn balance_of(e: Env, stream_id: u32, caller: Address) -> i128 {
        let stream = get_stream(&e, &stream_id);

        get_balance_of(&e, &caller, &stream)
    }
Enter fullscreen mode Exit fullscreen mode

To create the stream, we are going to make a bunch of assertions and panic if any of those assertions fails, then we are going to create a mutable struct to hold the duration and rate per second to be calculated from the provided start and end time.
There will also be a cross contract call to the token contract to get the token symbol and decimal. To learn more about cross contract calls in Soroban, you can check out this article.

Once all the required information have been gotten, we will then create the Stream struct and persist it in the Soroban storage.

We also have the capability to withdraw from the stream for both the sender and receiver as implemented below

pub fn withdraw_from_stream(e: Env, recipient: Address, stream_id: u32, amount: i128) {
        recipient.require_auth();
        assert!(amount > 0, "amount is zero or negative");
        assert!(
            recipient != e.current_contract_address(),
            "stream to the contract itself"
        );

        let mut stream = get_stream(&e, &stream_id);
        require_sender_or_recipient(&stream, &recipient);

        let balance = get_balance_of(&e, &recipient, &stream);
        assert!(balance >= amount, "amount exceeds the available balance");

        stream.remaining_balance = stream.remaining_balance - amount;

        if stream.remaining_balance == 0 {
            remove_stream(&e, &stream_id);
        } else {
            set_stream(&e, &stream_id, &stream);
        }

        transfer(&e, &recipient, &amount);

        events::withdraw_from_stream(&e, recipient, stream_id, amount);
    }
Enter fullscreen mode Exit fullscreen mode

The above code will allow the receiver to withdraw up to the amount that has been streamed and the sender to withdraw the balance remaining.

To cancel the stream, we have implemented a cancel_stream function that can only be called by either the sender or receiver

    pub fn cancel_stream(e: Env, caller: Address, stream_id: u32) {
        caller.require_auth();
        let stream = get_stream(&e, &stream_id);
        require_sender_or_recipient(&stream, &caller);

        let sender_balance = get_balance_of(&e, &stream.sender, &stream);
        let recipient_balance = get_balance_of(&e, &stream.recipient, &stream);

        remove_stream(&e, &stream_id);

        if recipient_balance > 0 {
            transfer(&e, &stream.recipient, &recipient_balance);
        }
        if sender_balance > 0 {
            transfer(&e, &stream.sender, &sender_balance);
        }

        events::cancel_stream(
            &e,
            stream_id,
            stream.sender,
            stream.recipient,
            sender_balance,
            recipient_balance,
        );
    }
Enter fullscreen mode Exit fullscreen mode

We will also implement some tests functions to confirm that our code is working as it should.

The Frontend

The frontend was put together with NextJS and Shadcn. While the main focus of the application was the backend, lets look at the hook that was used to show how someone can call the create stream function as well as get the streams mapped to his wallet account.

Wrapping Up

Soroban is a new smart contract platform that is built on a well tested blockchain infrastructure (Stellar). While the ecosystem is still new, some interesting projects are already springing up and I personally will be looking forward to a better developer experience especially with regards to querying on-chain data and listening to events emitted from the smart contracts.

Top comments (1)

Collapse
 
cryptopunkstar profile image
Cryptopunkstar

Hi, The button no available for connect wallet. Thank you !