Today I’m going to show you how I deployed a simple NFT contract to Soroban, Stellar’s smart contract platform. Whether you’re an artist, a developer, or just curious about blockchain, this tutorial will guide you through the process step-by-step. Let’s get started.
You’ll need a few things to get started:-
- An image to use for the artwork
- A free account with an IPFS provider like Pinata
- A development environment with Rust/Stellar-cli installed
basic instructions here

The Soroban NFT Contract
Note that there aren’t any standards in place for Soroban NFT contracts currently so I’ve based this contract loosely on the ERC-721 standard alongside the SEP-0039 proposal.
The code for this is open source on Github at: https://github.com/jamesbachini/Soroban-NFT
#![no_std]
/*
___ _ _ _ ___ _____
/ __| ___ _ _ ___| |__ __ _ _ _ | \| | __|_ _|
\__ \/ _ \ '_/ _ \ '_ \/ _` | ' \| .` | _| | |
|___/\___/_| \___/_.__/\__,_|_||_|_|\_|_| |_|
- Released under 3N consideration -
- Not Tested
- Not Audited
- Not Safe For Production
*/
use soroban_sdk::{contract, contractimpl, contracttype, symbol_short, Address, Bytes, String, Env, Vec};
#[contract]
pub struct SorobanNFT;
#[contracttype]
pub enum DataKey {
Owner(i128),
TokenCount,
Approvals(i128),
}
#[contractimpl]
impl SorobanNFT {
const SUPPLY: i128 = 1000;
const NAME: &'static str = "SorobanNFT";
const SYMBOL: &'static str = "SBN";
const METADATA: &'static str = "https://ipfs.io/ipfs/QmegWR31kiQcD9S2katTXKxracbAgLs2QLBRGruFW3NhXC";
const IMAGE: &'static str = "https://ipfs.io/ipfs/QmeRHSYkR4aGRLQXaLmZiccwHw7cvctrB211DzxzuRiqW6";
pub fn owner_of(env: Env, token_id: i128) -> Address {
env.storage().persistent().get(&DataKey::Owner(token_id)).unwrap_or_else(|| {
Address::from_string_bytes(&Bytes::from_slice(&env, &[0; 32]))
})
}
pub fn name(env: Env) -> String {
String::from_str(&env, Self::NAME)
}
pub fn symbol(env: Env) -> String {
String::from_str(&env, Self::SYMBOL)
}
pub fn token_uri(env: Env) -> String {
String::from_str(&env, Self::METADATA)
}
pub fn token_image(env: Env) -> String {
String::from_str(&env, Self::IMAGE)
}
pub fn is_approved(env: Env, operator: Address, token_id: i128) -> bool {
let key = DataKey::Approvals(token_id);
let approvals = env.storage().persistent().get::<DataKey, Vec<Address>>(&key).unwrap_or_else(|| Vec::new(&env));
approvals.contains(&operator)
}
pub fn transfer(env: Env, owner: Address, to: Address, token_id: i128) {
owner.require_auth();
let actual_owner = Self::owner_of(env.clone(), token_id);
if owner == actual_owner {
env.storage().persistent().set(&DataKey::Owner(token_id), &to);
env.storage().persistent().remove(&DataKey::Approvals(token_id));
env.events().publish((symbol_short!("Transfer"),), (owner, to, token_id));
} else {
panic!("Not the token owner");
}
}
pub fn mint(env: Env, to: Address) {
let mut token_count: i128 = env.storage().persistent().get(&DataKey::TokenCount).unwrap_or(0);
assert!(token_count < Self::SUPPLY, "Maximum token supply reached");
token_count += 1;
env.storage().persistent().set(&DataKey::TokenCount, &token_count);
env.storage().persistent().set(&DataKey::Owner(token_count), &to);
env.events().publish((symbol_short!("Mint"),), (to, token_count));
}
pub fn approve(env: Env, owner: Address, to: Address, token_id: i128) {
owner.require_auth();
let actual_owner = Self::owner_of(env.clone(), token_id);
if owner == actual_owner {
let key = DataKey::Approvals(token_id);
let mut approvals = env.storage().persistent().get::<DataKey, Vec<Address>>(&key).unwrap_or_else(|| Vec::new(&env));
if !approvals.contains(&to) {
approvals.push_back(to.clone());
env.storage().persistent().set(&key, &approvals);
env.events().publish((symbol_short!("Approval"),), (owner, to, token_id));
}
} else {
panic!("Not the token owner");
}
}
pub fn transfer_from(env: Env, spender: Address, from: Address, to: Address, token_id: i128) {
spender.require_auth();
let actual_owner = Self::owner_of(env.clone(), token_id);
if from != actual_owner {
panic!("From not owner");
}
let key = DataKey::Approvals(token_id);
let approvals = env.storage().persistent().get::<DataKey, Vec<Address>>(&key).unwrap_or_else(|| Vec::new(&env));
if !approvals.contains(&spender) {
panic!("Spender is not approved for this token");
}
env.storage().persistent().set(&DataKey::Owner(token_id), &to);
env.storage().persistent().remove(&DataKey::Approvals(token_id));
env.events().publish((symbol_short!("Transfer"),), (from, to, token_id));
}
}
mod test;
The smart contract implements a basic non-fungible token (NFT) system with key features:
Includes a fixed name (“SorobanNFT”), symbol (“SBN”), and links to token metadata and image on IPFS for every NFT minted. Every image is the same in this contract, although this could be altered fairly easily if you wanted to do a PFP drop or something along those lines.
Anyone can mint new tokens up to the limit which is set in the contract at 1000 NFT’s. There is no sybil checks, whitelists or restrictions on who can mint or how many each user can mint.
Emits Transfer, Mint, and Approval events to track on-chain activity.
It provides standard NFT functionality like transfer, approve, transfer_from, and metadata retrieval (name, symbol, token_uri, token_image).
There’s a basic unit test suite here: https://github.com/jamesbachini/Soroban-NFT/blob/main/src/test.rs
IPFS Metadata
We are going to be storing the image and metadata on IPFS.
Here is an example metadata in JSON format:
{
"name": "SorobanNFT",
"description": "A prototype Soroban NFT contract",
"url": "ipfs://QmeRHSYkR4aGRLQXaLmZiccwHw7cvctrB211DzxzuRiqW6",
"issuer": "GB2QDUX7OJZ64BBG2PIFIY3WKUCOSFQSP6QJ7MZ32NOYAJJJ3FBOXA36",
"code": "SBN"
}
Note that this is based on the SEP-0039 proposal standards.
Let’s upload the image first and then we can pass in the IPFS hash to the metadata and upload that as well using Pinata.

We can then update the values in the src/soroban_nft.rs contract.
Note that you can either use ipfs:// protocol or you can use an access point by replacing this with something like https://ipfs.io/ipfs/
So your finished code should look something like this on lines 30 & 31:
const METADATA: &'static str = "https://ipfs.io/ipfs/QmegWR31kiQcD9S2katTXKxracbAgLs2QLBRGruFW3NhXC";
const IMAGE: &'static str = "https://ipfs.io/ipfs/QmeRHSYkR4aGRLQXaLmZiccwHw7cvctrB211DzxzuRiqW6";
Deploying Your Soroban NFT
To compile your smart contract, run:
cargo build --target wasm32-unknown-unknown --release
This will generate a soroban_nft.wasm
file in the target
directory. This file is what we’ll deploy to the Soroban network.”
If you are deploying to testnet you can just generate a new address and fund the account like so:
stellar keys generate --global james --network testnet --fund
If you are deploying to mainnet you’ll need to send that address some real XLM to pay for the transaction fee.
Now, let’s deploy the contract.
stellar contract deploy --wasm target/wasm32-unknown-unknown/release/soroban_nft.wasm --source james --network testnet
You’ll get a contract ID in response which will be a long alphanumeric string starting with C. Make sure to save this ID, you’ll need it to interact with the NFT.
Mint A Soroban NFT
Let’s go ahead an mint a new NFT.
stellar contract invoke --id CONTRACT_ID --source james --network testnet -- mint --
to YOUR_ADDRESS
If you want to mint one of my NFT’s you can do so using this mainnet contract address:
Testnet: CCOMN26TIW2LJW4A7NWY5UF47X6HNYAF44ZFA2GIIGCSMBEIWVIH2DMA
Mainnet: CDA5FGE4LZP4S45LP6AJLWMLKWHVWMKFSIKVYEBSIYOB25NWLKCLL7RY

And that’s it! You’ve just deployed an NFT using Soroban.
You can extend this contract to include any business logic you wish such as royalties, whitelists or even integrate it within a dApp.
If you found this tutorial helpful, please share it on social media.
Note the contract is barely tested and unaudited, so please consider it unsafe for production use.