How To Use Stellar’s Soroban To Write a Bond Smart Contract

stellar sky with stellar and soroban logos

Stellar’s new smart contracts platform, Soroban, gives the Stellar network more flexibility to support various use cases. With it, it’s possible to implement custom business logic on-chain that was previously limited to classic operations.

In this post, we’ll show you how to create a Stellar smart contract and discuss how best to use Stellar’s Soroban and its flexible features.

Table of Contents

Bond tokens

In finance, bonds are securities in which the issuer owes the holder a debt. After a period of time, the bond issuer pays all holders the bond’s value and the interest rate.

This is beneficial for the users, who profit from the purchase of bonds, and for the issuer, who can conduct business using the money used to buy the bonds before they’re paid out.

For our use case, we will treat the bonds as tokens, which users can buy and trade.

ERC-20 Tokens

The ERC-20 is a popular standard of Fungible Tokens used to ensure that each token is created using the same methods as another token.

It is an essential tool for interoperating with other contracts because it allows integration with the token without needing to know the details of its implementation.

For our use case, it will represent our bond token and the payment token, which will be used to buy bonds and pay debts.

The Soroban interface based on the ERC-20 token standard can be found in the official Soroban documentation. We are using this compiled interface, which can be found in the Soroban examples repository.

Writing the smart contract

Before we begin, let’s take a look at the logic of our smart contract.

There are two types of users: the Admin, who manages the contract, and the Users, who can buy the bonds and cash out when allowed.

The administrator is responsible for managing the contract and determining when a bond can be bought, how long it will appreciate in value, and when users can withdraw their money.

Some extra management actions are also allowed, like managing which users can buy the bonds or pausing purchases temporarily. Finally, the admin can also withdraw the money received to pay the bonds.

The users can perform two actions — purchasing a bond and cashing out. They can only perform these actions can in the periods set by the admin and if they have the necessary authorization.

The appreciation of the bond occurs over a period determined by the administrator, bound by the start date and the end date. The purchase period of the bond is not directly linked to this period and can occur at different intervals.

Contract attributes like which tokens will be used, interest rate, interest type, and interest time interval are customizable and are set at initialization. We will discuss this in more detail in the following sections.

Initialization

The initialize function sets the contract attributes right after its deployment. We won’t go too deep into its implementation, but it is essential to understand the parameters it receives.

impl BondTrait for Bond {
	...
	fn initialize(
      e: Env,
      admin: Identifier,
      payment_token_id: BytesN<32>,
      bond_token_name: Bytes,
      bond_token_symbol: Bytes,
      bond_token_decimals: u32,
      price: i128,
      fee_rate: i128,
      fee_days_interval: u64,
      fee_type: InterestType,
      initial_amount: i128,
  ) {
		...
	}
}Code language: Rust (rust)

Some considerations about these parameters:

  • The payment_token_id represents the payment token contract address.
  • The bond_token_… parameters define the attributes of the bond token. We get this instead of an address because we will deploy this token during initialization. This way, the current contract is the initial administrator of the token contract and can perform privileged functions such as mint and burn.
  • The price determines how much each unit of bond token is worth in terms of payment tokens.
  • The fee_rate represents the valuation rate of the bond token. That is, the interest rate applied to each time interval. It is expected to receive this rate multiplied by 1000 to allow for greater granularity of this value since Soroban does not support decimal numbers. This means that for an interest rate of 15%, the fee_rate parameter should be 150 (0.15 * 1000).
  • The fee_days_interval represents the interval in days that the interest rate will be applied to the bond token value. Since we will use timestamps to calculate the time differences, we are converting and saving this interval in seconds to make the calculations easier.
  • The fee_type defines the type of interest that will be applied to each interval: simple or compound. This is an additional attribute to make the contract more flexible to different use cases and can be changed depending on the business logic.
  • The initial_amount defines how many bond tokens will be minted at that moment.

Next, we will dive a little deeper into some of the core components of the contract, starting with the buy.

Buy bonds

impl BondTrait for Bond {
	...
	fn buy(e: Env, amount: i128) {
	    if read_state(&e) != State::Available {
	        panic_with_error!(&e, Error::NotAvailable)
	    }
	    if !check_user(&e, &(e.invoker().into())) {
	        panic_with_error!(&e, Error::UserNotAllowed)
	    }
	    increase_supply(&e, amount);
	    // Total will be the Bond amount multiplied by Bond price
	    let total = current_price(&e) * amount;
	    ...
	}
}Code language: Rust (rust)

For a user to be able to buy a bond, the purchase must be available, and the administrator must authorize the user. If the user satisfies these requirements, the supply (the amount of bond sold) increases by the amount the user intends to buy.

After this, it is necessary to calculate the bond’s current price. This is because the bond starts at a certain price but gets more expensive as time passes. Let’s look at the current_price function that performs this calculation.

fn current_price(e: &Env) -> i128 {
    let mut end_time = read_end_time(&e);
    let now = e.ledger().timestamp();
    // If the end date has not passed yet
    if now < end_time {
        end_time = now;
    }
    let initial_price = read_price(&e);
    // Calculates the amount of time intervals that have passed
    let time = (end_time - read_init_time(&e)) / read_fee_interval(&e);
    // If no time interval has passed, the price does not change
    if time == 0 {
        return initial_price;
    }
    let fee_type = read_fee_type(e);
    match fee_type {
        InterestType::Simple => {
            return initial_price + (initial_price * (time as i128) * read_fee_rate(&e)) / 1000;
        }
        InterestType::Compound => {
            let fees = 1000 + read_fee_rate(&e);
            return initial_price * (fees.pow(time as u32)) / 1000_i128.pow(time as u32);
        }
    }
}Code language: Rust (rust)

Here we need to calculate how much time has elapsed between the bond’s start date and now unless the end date has already passed, in which case, we will calculate the time between the start date and the end date.

With the time calculated, we need to determine how many time intervals have passed. For example, if our interval is one week, and 40 days have passed since the starting date, this value will be equal to 40 mod 7 = 5. In Soroban, all divisions are integer divisions, so we don’t have to worry about the modulo.

Our contract gives the flexibility to apply simple or compound interest, depending on the choice made at initialization. The calculation for each type of interest follows the standard logic of the mathematical interest formula, except for the 1000 factor. The factor 1000 added in each of the operations neutralizes our interest rate, which is multiplied by this value.

impl BondTrait for Bond {
	...
	fn buy(e: Env, amount: i128) {
	    ...
	    // Total will be the Bond amount multiplied by Bond price
	    let total = current_price(&e) * amount;
	    let invoker: Identifier = e.invoker().into();
	    transfer_from_account_to_contract(&e, &read_payment_token(&e), &invoker.clone(), &total);
	    transfer_from_contract_to_account(&e, &read_bond_token_id(&e), &invoker.clone(), &amount);
	}
}Code language: Rust (rust)

Returning to the buy function, after calculating the bond’s current price and multiplying it by the amount the user wants to buy, we get the number of payment tokens the contract should receive from the user.

The function transfer_from_account_to_contract performs the transfer_from token function from the user account to the contract account.

This implies the user must have allowed the contract to take this payment token amount from them. Otherwise, an error will be issued, and the user will not succeed in buying the bonds.

After the contract receives the user’s payment tokens, it sends the purchased bonds from their balance to the user’s account using the function transfer_from_contract_to_account.

Withdraw

The contract allows the administrator to withdraw user payment tokens to their account. The admin can use these tokens as they wish while cash out is not enabled.

impl BondTrait for Bond {
	...
	fn withdraw(e: Env, amount: i128) {
	    check_admin(&e, &Signature::Invoker);
	    if read_state(&e) == State::CashOutEn {
	        panic_with_error!(&e, Error::AlreadyCashOutEn)
	    }
	    transfer_from_contract_to_account(
	        &e,
	        &read_payment_token(&e),
	        &e.invoker().clone().into(),
	        &amount,
	    );
	}
}Code language: Rust (rust)

Enable cash out

Certain state conditions must be met to enable cash out, and the bond’s valuation period must be over. But the most important requirement here is that the contract must have in its balance the payment tokens needed to pay all the users who have purchased bonds with the bond’s current price.

Therefore, the administrator must pay the contract the required amount before calling this function. This can be done via the transfer function of the payment token contract.

Once this state is enabled, the payment tokens in the contract can no longer be withdrawn by the admin.

impl BondTrait for Bond {
	...
	fn en_csh_out(e: Env) {
	    check_admin(&e, &Signature::Invoker);
	
	    let state = read_state(&e);
	    if state != State::Available && state != State::Paused {
	        panic_with_error!(&e, Error::NotAvailable)
	    }
	
	    // Check if end time has passed
	    if e.ledger().timestamp() < read_end_time(&e) {
	        panic_with_error!(&e, Error::EndTimeNotPassed)
	    }
	
	    // Check if the contract has the amount of payment tokens to
	    // pay the users
	    let amount_payment = current_price(&e) * read_supply(&e);
	    let contract_balance = token_balance(
	        &e,
	        &read_payment_token(&e),
	        &Identifier::Contract(e.current_contract()),
	    );
	
	    if contract_balance < amount_payment {
	        panic_with_error!(&e, Error::NotEnoughTokens)
	    }
	
	    write_state(&e, State::CashOutEn);
	}
}Code language: Rust (rust)

After the conditions are met, the contract state is changed to allow cash out.

Cash out

The cash out is performed by reading the user’s balance and checking how many bonds they have on their account. The amount of payment tokens transferred from the contract to the user’s account is the number of bonds they own multiplied by the bond’s current value.

impl BondTrait for Bond {
	...
	fn cash_out(e: Env) {
	    if read_state(&e) != State::CashOutEn {
	        panic_with_error!(&e, Error::NotCashOutEn)
	    }
	
	    let invoker: Identifier = e.invoker().into();
	
	    // Get the user Bond Token balance
	    let bond_balance = token_balance(&e, &read_bond_token_id(&e), &invoker.clone());
	    // Calculates amount of payment token
	    let total_payment = bond_balance * current_price(&e);
	    // Decrease supply
	    decrease_supply(&e, bond_balance);
	    // Transfer amount of payment tokens from contract to user
	    transfer_from_contract_to_account(
	        &e,
	        &&read_payment_token(&e),
	        &invoker.clone(),
	        &total_payment,
	    );
	    // Burn all the Bond tokens from user
	    burn_token(
	        &e,
	        &read_bond_token_id(&e),
	        &Signature::Invoker,
	        &invoker,
	        &bond_balance,
	    );
	}
}Code language: Rust (rust)

This implies that no matter how much the user paid for the bond, they will always get their money back at a higher price. For example, if user A bought 100 bonds and user B bought 100 bonds a few months later, both users will receive the same number of payment tokens at the cash-out.

The key difference is that the profit of user A will be higher as they paid less for the bond initially than user B.

After transferring the payment, the user’s bonds are burned. This transaction is only possible because the current contract is the admin of the bond token contract.

Learn more about the blockchain with Cheesecake Labs

This post has outlined the core components of a bond sales contract. If you would like to explore the rest of the functions in more depth, you can take a look at the source code.

You can learn more about the Stellar network, cryptocurrency wallet development, and the basics of blockchain on the Cheesecake Labs blog. And sign up for the Cheesecake Labs newsletter to get the latest updates delivered to your inbox.

As a Stellar Integration Partner, Cheesecake Labs is an excellent choice to help you bring your ideas for blockchain projects to life.

Get in touch and tell us about your project!

four men of ckl's team at the stellar annual conference

About the author.

Alessandra Carneiro
Alessandra Carneiro

Someone who has many hobbies and does them all in an average way. Loves drinking tea and dreams about petting all dogs around the world.