In this tutorial I’ll be going through everything I learned while creating a Soroban smart contract for a call option.
There are some interesting code snippets and gotcha’s for Soroban developers below but let’s start with the basics, Options 101.

What Are DeFi Options?
Financial options are contracts that give the holder the right, but not the obligation, to buy or sell an underlying asset at a predetermined price within a specific time frame. These assets can include stocks, commodities, currencies, or indexes. Options are commonly used by investors to manage risk, generate income, or speculate on market movements.
There are two main types of options: call options and put options. A call option gives the buyer the right to purchase the underlying asset at a set price (called the strike price), while a put option gives the buyer the right to sell it at the strike price. The buyer pays a fee known as a premium to the seller (or writer) of the option for this right.
Options can be a flexible tool in financial strategies. For example, investors can use them to hedge against potential losses in other investments or to leverage smaller amounts of capital for potentially higher returns. However, trading options also involves significant risk, especially for sellers, and requires a good understanding of how they work.
In DeFi the smart contract can be used to hold an underlying digital asset. This creates a very effective method of creating financial derivatives. The seller deposits the underlying asset and prices the option. The buyer pays a premium and exercises their option paying a strike price to release the underlying asset.

Soroban Call Option Contract
The full code for this is open source on Github: https://github.com/jamesbachini/Soroban-Call-Option/
This contract is a clean example of implementing a covered call option on Soroban using SEP41 tokens (USDC and XLM).
- The seller locks 1 XLM as collateral
- A buyer pays a premium (0.10 USDC) to purchase the option
- The buyer can later exercise the option by paying the strike (0.50 USDC) before the expiry date to receive 1 XLM
- If the buyer doesn’t exercise, the seller can reclaim the locked XLM after expiry
The contract uses the ledger timestamp to enforce expiry logic:
assert!(env.ledger().timestamp() < EXPIRY, "Option expired");
All token transfers are handled using the Sep41 token client that Blend used in their latest contracts:
TokenClient::new(&env, &usdc).transfer_from(
&env.current_contract_address(),
&buyer,
&seller,
&PREMIUM,
);
The &usdc variable above is just a reference to the token contract address or id.
Note that transfer_from has 4 inputs in SEP-41 tokens:
- Invoker, in this case the contract itself
- From address
- To address
- Amount
This contract stores state using a store variable to keep the code tidy:
let store = env.storage().persistent();
store.set(&symbol_short!("purchased"), &true);
let purchased: bool = store.get(&symbol_short!("purchased")).unwrap();
There’s also a way to check an address hadn’t been set yet which I had to lookup:
if store.get::<_, Address>(&symbol_short!("seller")).is_some() {
panic!("Option already sold");
}
In the test suite there are a couple of interesting unit test features as well.
The first is the use of the sep_41_token testutils to create mock tokens for testing:
use sep_41_token::testutils::{MockTokenClient, MockTokenWASM};
fn create_token_contract(env: &Env) -> (Address, MockTokenClient) {
let admin = Address::generate(&env);
let token_id = env.register_contract_wasm(None, MockTokenWASM);
let token_client = MockTokenClient::new(&env, &token_id);
token_client.initialize(
&admin,
&7,
&String::from_str(&env, "Name"),
&String::from_str(&env, "Symbol"),
);
(token_id, token_client)
}
fn setup<'a>(env: &'a Env) -> (XlmCallOptionClient<'a>, Address, Address, Address, Address) {
let seller = Address::generate(&env);
let buyer = Address::generate(&env);
let loads = &999999999999999_i128;
let (usdc_id, usdc_token) = create_token_contract(&env);
let (xlm_id, xlm_token) = create_token_contract(&env);
let contract_id = env.register_contract(None, XlmCallOption);
let client = XlmCallOptionClient::new(&env, &contract_id);
env.mock_all_auths();
let mock_xlm = MockTokenClient::new(&env, &xlm_id);
let mock_usdc = MockTokenClient::new(&env, &usdc_id);
mock_xlm.mint(&seller, &loads);
xlm_token.approve(&seller, &contract_id, &loads, &0_u32);
mock_usdc.mint(&buyer, &loads);
usdc_token.approve(&buyer, &contract_id, &loads, &0_u32);
client.sell_option(&seller, &xlm_id, &usdc_id);
(client, seller, buyer, usdc_id, xlm_id)
}
The second is the ability to create a unit test time machine to progress the blockchain forward to a set date.
First we import testutils::Ledger then we can move to a set timestamp like this:
env.ledger().with_mut(|li| {
li.timestamp = EXPIRY + 1;
});
To build and deploy the contract I hit a couple of roadblocks.
The first problem I had was I was getting this error:
message: "reference-types not enabled: zero byte expected"
The issue is related to the version of Rust I was using and I needed to downgrade to version 1.81
rustup show
rustup install 1.81.0
rustup default 1.81.0
rustc --version
The second issue was related to errors when building a release version related to testing which I got around simply by commenting out the mod test; in the lib.rs file while building.
Once those minor details were sorted I built and deployed to testnet.
