James Bachini

Deploying An NFT Using Stellar Soroban

Soroban NFT

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
James On YouTube

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.

PInata IPFS Screenshot

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

Stellar Stroopy NFT

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.


Get The Blockchain Sector Newsletter, binge the YouTube channel and connect with me on Twitter

The Blockchain Sector newsletter goes out a few times a month when there is breaking news or interesting developments to discuss. All the content I produce is free, if you’d like to help please share this content on social media.

Thank you.

James Bachini

Disclaimer: Not a financial advisor, not financial advice. The content I create is to document my journey and for educational and entertainment purposes only. It is not under any circumstances investment advice. I am not an investment or trading professional and am learning myself while still making plenty of mistakes along the way. Any code published is experimental and not production ready to be used for financial transactions. Do your own research and do not play with funds you do not want to lose.


Posted

in

, , , , ,

by