Logo
  • kalepail
Farm 🄬
kalepail
kalepail
Code Your First Soroban NFT Project

Code Your First Soroban NFT Project

Date
Feb 14, 2024
Category
Blockchain

Earlier this year Nicole on our DevRel team kicked off a fun internal challenge called the ā€œOkashi Cakewalkā€ as a brave attempt to get every single person at the Stellar Development Foundation to use and interact with the upcoming smart contract protocol ā€œSorobanā€. It’s a fun NFT game where you build a 5 x 5 ascii graphic and then mint it to the Stellar Futurenet. There have been some really creative pieces that have come out of it and it’s been neat to see folks from all over the org participate and learn!

image

You know I had to participate however I quickly realized I actually had no idea the difference between ascii and unicode characters. I had my graphic all ready to go only to be met with a Panic! when trying to submit. The reason? I was just a little too creative.

ā”Œā”¬ā”¬ā”¬ā”
││▀▄│
ā”‚ā–ˆā”‚ā–€ā”‚
│▄▀││
┓┓┓┓┓

Isn’t it adorable! It’s a little Soroban! Well it’s also filled with invalid ascii characters, and so I had to settle for something far less creative. As all good disappointments are it was the beginning of a micro entrepreneurial arc where I set out to create a riff on the underlying project which would support unicode as a valid input for my 5 x 5 NFT. I’d call it ā€œkalewalkā€!

tl;dr I did it!

image

And so can you!

Simple and fun! I’ll be honest though getting here was anything but. Well okay it was fun, but it definitely wasn’t simple.

There are a bunch of components at play which makes the above possible.

  1. An underlying Soroban smart contract
  2. An interface for easily interacting with the contract
  3. A frontend service for users to design and mint NFTs

All the code is available here in this repo. Lots of good meat and potatoes in there. Enjoy!

Let’s take each one of those pieces on their own and walk it through:

1. The Contract

The original cakewalk was actually a combination of two contracts. A CakewalkMinter contract CB4W5563H62YDM27BK5AVBYVPTFXHRPPMCYIMYHT3CWITCZSY34IO2RJ which was then intended to be utilized by a user-modified Cakewalk contract like this one.

There’s a lot going on in the CB4W5...IO2RJ contract and it’s not my goal here to explain it. Suffice it to say it’s hard coded to only allow ascii characters which is what I aim to solve without modifying too much else.

For our purposes though we will be changing a few key things.

  1. Rather than using a second user-modified contract to utilize the minter contract we’ll use a frontend interface.
  2. Rather than wrapping the submitted art into a frame from within the contract I’ll ā€œprettifyā€ it off-chain leaving the on-chain art to be only the user submitted characters.
  3. We’ll allow users to submit updates to their art

Here’s the contract in it’s entirety

‣
lib.rs

Let’s walk through it block by block.

#![no_std]

use soroban_sdk::{
    contract, contracterror, contractimpl, contracttype, Address, BytesN, Env, Map, String,
};

Soroban smart contracts must be small. The standard library is not small. So we must exclude it. To make a standardless life more tolerable we’re gifted the soroban_sdk which we will make use of heavily.

#[contracterror]
#[derive(Copy, Clone)]
#[repr(u32)]
pub enum Error {
    NotFound = 1,
    NotEmpty = 2,
    TooLong = 3,
}

Soroban runs cleaner than the chicken wing bone my Oma’s sneaky Schnauzer stole from me last Christmas. By default it won’t give you a whole lot to go off of by way of errors. However there is a nice mechanic for assigning numbers to error codes which you can then sprinkle throughout your code to help downstream devs understand the contract logic flow in case of errors. You’ll see me reference these elsewhere in the contract by way of Err(Error::TooLong); with a function return type akin to ) -> Result<u32, Error> {.

#[contracttype]
pub struct Picture {
    name: String,
    canvas: BytesN<100>,
    ledger: u32,
    timestamp: u64,
    version: u32,
    network: BytesN<32>,
}

We’ll be saving pictures in our contract store and I want to ensure we’ve got a nice structure for those objects.

#[contracttype]
pub enum Key {
    Cursor,
    Picture(u32),
    Author(u32),
    Number(Address),
    Authors(u32),
}

We’ll be storing a lot of plain key values like u32 numbers. We need to add some ā€œwrappersā€ around these otherwise plain numbers so picture 1 stores a different value than author 1. We achieve this via an enumerated list of Key names. So Key::Picture(1) will be an obviously different reference than Key::Author(1).

#[contract]
pub struct Contract;

Let’s give our contract a name. Something smart, something fun, something memorable, Contract, yeah that’ll do nicely!

#[contractimpl]
impl Contract {
    pub fn mint(
        env: Env,
        name: String,
        author: Address,
        canvas: BytesN<100>,
    ) -> Result<u32, Error> {

Next we implement our Contract interface. Finally right!? We’ll have a number of public functions. Let’s start with the meaty one, mint. It takes an automatically provided default env argument alongside a name author and canvas.

  • env will be the current runtime context for the invocation and will include a bunch of helpful methods and information. You can read up on this automatically provided initial env arg here.
  • name is a simple String for NFT authors to title their brilliant works of art. Further down the contract I limit this input to 15 chars because I like telling people what they can’t do.
  • author is the Address associated as the owner and creator of this piece of work. As such we’ll need to ensure this value is authenticated to avoid Picasso impersonators.
  • canvas is the encoded data containing actual artwork. Why BytesN<100> vs String? Good question with a pretty long answer but boiled down to
    1. String currently doesn’t have a clean way to get at the underlying data and
    2. Unicode is a wild standard with a outrageous number of implementation nuances and disagreements. At the floor of all upper level arguments however are the bedrock agreements that a single unicode character can be up to 4 bytes. So I remove any chance for getting things wrong by just ensuring my 5 x 5 artwork accounts for 4 bytes per character. 4 bytes * 5 chars * 5 chars = 100 bytes
    3. ‣
      Don’t believe me? Look how emojis are made.

Finally we set our returned Result to be either the happy u32 or the sad Error.

author.require_auth();

Next we invoke the almighty require_auth to ensure our invocation includes a valid signature from the author Address for the function’s argument values. If I wanted to be more nuanced than that default for the substance of what’s being signed for I’d need to use the require_auth_for_args and manually craft what values I wanted to see signed for. Soroban’s auth is it’s silver bullet albeit cloaked in potential confusion but those who know know. So learn.

if name.len() > 15 {
    return Err(Error::TooLong);
}
Constraints are the whetstone to the chisel of creativity ensuring what is crafted is defined, expert and intentional.
‣
🤫

Next up is this gorgeous plop of match code which allows an artist to update their previously submitted artwork. The basic flow is

  1. Hello, it’s me, and I’m curious if I’ve ever left some of my stuff at your place before
  2. let number = match env.storage().persistent().get(&Key::Number(author.clone())) {
  3. If so would you please give it back
  4. Some(number) => number, 
  5. If not add me to your long list and give me the next (or first if I should be so lucky) value
  6. None => {
    		let number = env.storage().instance().get(&Key::Cursor).unwrap_or(1);
  7. Cool now since I’m new here let’s file away some stuff for future reference so you don’t forget about me. Specifically that I’m the Author of this picture number and that this number is owned by me the Author. A kind of vise versa reverse lookup table switchero.
  8. env.storage()
        .persistent()
        .set(&Key::Author(number), &author);
    env.storage()
        .persistent()
        .set(&Key::Number(author.clone()), &number);
  9. Next, if you would, actually add me to your list of Authors but do it in a scaleable way by including me in a brilliant pseudo paginated way.
  10. let page = number / 20;
    let mut authors = env
        .storage()
        .persistent()
        .get(&Key::Authors(page))
        .unwrap_or(Map::new(&env));
    authors.set(number, author);
    env.storage()
        .persistent()
        .set(&Key::Authors(page), &authors);
  11. Look at us taking things to the next level. Speaking of, let’s go ahead and increase that cursor number to ensure future bozos will take next slots vs my slot.
  12. env.storage().instance().set(&Key::Cursor, &(number + 1));
  13. Last but not least, after all we’ve been through, just give me your number, and let’s move on.
  14.         number
        }
    };
let picture = Picture { 
    name, 
    canvas,
    ledger: env.ledger().sequence(),
    timestamp: env.ledger().timestamp(),
    version: env.ledger().protocol_version(),
    network: env.ledger().network_id(),
};

env.storage()
    .persistent()
    .set(&Key::Picture(number), &picture);

With number in hand let’s move on to saving our Picture on-chain, immutable, eternal, glorious. We set name and canvas which you’re familiar with but I’ve opted to also store some additional slightly extraneous information for the sake of beefing up the graphic I’ll be generating later with at least a timestamp. I won’t use it all but I’m nothing if not a little šŸ¦„Ā extra ✨

    Ok(number)
}

Last but not least for our incredibly fun mint function is returning the u32 Result number which will signal to the invoker what NFT number they just minted. It’s an Ok because this is a Result and you’re either Err or Ok.

We’ll go through the rest of these functions a bit faster as they’re far simpler and just return the picture or list of pictures under a few different conditions or filters.

pub fn get_list(env: Env, page: u32) -> Map<u32, Address> {
    env.storage()
        .persistent()
        .get(&Key::Authors(page))
        .unwrap_or(Map::new(&env))
}

Get’s a Map list of number: author pictures off a paginated filter. Will be helpful for showing a long list of pictures or surfacing what’s actually inside the metaphorical NFT vault of this contract.

pub fn get_author(env: Env, number: u32) -> Result<Address, Error> {
    match env.storage().persistent().get(&Key::Author(number)) {
        Some(owner) => owner,
        None => Err(Error::NotFound),
    }
}

Given a number argument this function will return that picture’s author.

pub fn get_picture(env: Env, number: u32) -> Result<Picture, Error> {
    match env.storage().persistent().get(&Key::Picture(number)) {
        Some(picture) => picture,
        None => Err(Error::NotFound),
    }
}

Given a number this function will return its Picture object.

pub fn get_picture_by(env: Env, author: Address) -> Result<Picture, Error> {
    match env.storage().persistent().get(&Key::Number(author)) {
        Some(number) => Self::get_picture(env, number),
        None => Err(Error::NotFound),
    }
}

Given an author this function will initially look up the picture number for that author and then use that and call the previous get_picture function to ultimately return the Picture for the given author.

And there you have it! Roughly ~130 lines of code for a pretty fun NFT contract! Very noticeably this is missing any trading functionality so I’ll leave it to you to add maybe a transfer function along with the concept of picture ownership vs just authorship which would allow you to trade your pictures around the universe.

Using the Soroban CLI we can quite simply build and deploy this contract now to one of the Stellar networks. For now I’ll be using the Futurenet

soroban contract build
contract_id=$(soroban contract deploy --wasm target/wasm32-unknown-unknown/release/kalewalk.wasm --network futurenet --source-account default)

2. The Interface

The next step once we’ve got our contract online is to build an interface for interacting with it easily from a frontend application. Thankfully the Soroban CLI has our backs here as well.

soroban contract bindings typescript --wasm target/wasm32-unknown-unknown/release/kalewalk.wasm --output-dir kalewalk-sdk --contract-id $contract_id --network futurenet --overwrite

This will build a custom TypeScript bindings interface specifically for our freshly deployed contract allowing us to interact with it really simply and intuitively from within a JS runtime.

Something like

const mintRes = await contract.mint({
    name: `Soroban`,
    author: pubkey,
    canvas: Buffer.from(canvas),
})

There’s obviously a bit more to it than that both before and after but the ability to call contract functions in this fashion feels a bit like magic as you’re actually doing it.

Once we’ve got our TS client interface bindings all pretty and bundled up it’s time to make use of them in a frontend application. Let’s do it!

3. The Frontend

The kalewalk web interface is a Sveltekit app but for our purposes here I’ll be walking through how to get this running as a single file Bun app. To kick things off ensure you’ve got Bun installed.

curl -fsSL https://bun.sh/install | bash

Next let’s initialize a new Bun based TypeScript project.

mkdir kalewalk
cd kalewalk
bun init

This should give you something akin to

bun init helps you get started with a minimal project and tries to guess sensible defaults. Press ^C anytime to quit

package name (kalewalk): 
entry point (index.ts): 

Done! A package.json file was saved in the current directory.
 + index.ts
 + .gitignore
 + tsconfig.json (for editor auto-complete)
 + README.md

To get started, run:
  bun run index.ts

Next we’ll move into the index.ts file and begin changing things up. Here’s the code in it’s entirety.

‣
index.ts

As before let’s walk through it all to explain what’s going on.

import { Horizon, Keypair, Transaction, hash, scValToNative } from '@stellar/stellar-sdk';
import { Api } from '@stellar/stellar-sdk/lib/soroban';
import { parseArgs } from "util";
import { Contract, networks } from 'kalewalk-sdk'

This block contains all of our imports, none of which we’ve actually installed yet, so let’s do that first.

bun add @stellar/stellar-sdk

You’ll notice we omitted util. It’s included by default so no real reason to add it explicitly. For kalewalk-sdk that comes from our step 2 where we had the Soroban CLI build the TS interface. Depending on what you called this and where you placed it these next steps may be a little different but for me, who placed the kalewalk-sdk at the root of this kalewalk project this is what I did.

First you need to move into the kalewalk-sdk directory and link that package with Bun so it can be installed as a local dependency here in the index.ts file.

cd kalewalk-sdk
bun link

That should give you something like this

bun link v1.0.25 (a8ff7be6)
Success! Registered "kalewalk-sdk"

To use kalewalk-sdk in a project, run:
  bun link kalewalk-sdk

Or add it in dependencies in your package.json file:
  "kalewalk-sdk": "link:kalewalk-sdk"

You can see from the instructions there’s a couple different options. I prefer the second more explicit option of adding "kalewalk-sdk" to the package.json file. Let’s go ahead and do that.

āš ļø
Change back to the root of the project. You need to update the root package.json file not the one in the kalewalk-sdk directory!

Here’s what my project root package.json file looks like once I’ve made that change.

Depending on your version of Bun it may look a little different but the key change is the addition of the "kalewalk-sdk": "link:kalewalk-sdk" line. Great! Now let’s run a final force install to ensure everything is linked and ready to rock and roll.

bun install --force
const { values: { secret } } = parseArgs({
    args: Bun.argv,
    options: {
        secret: {
            type: 'string',
        },
    },
    strict: true,
    allowPositionals: true,
});

if (!secret)
    throw new Error('The --secret arg is required');

When running our script via bun run index.ts I want to enable the ability to pass a --secret flag which will be the Stellar secret key to use for the mint function. This will be the account that pays the fees for the transaction as well as the author Address for the picture.

This block of code just sets this functionality up by using Bun.argv and the parseArgs method from the util package.

If the --secret flag is missing our script will throw an error.

const keypair = Keypair.fromSecret(secret)
const pubkey = keypair.publicKey()

const horizon = new Horizon.Server('https://horizon-futurenet.stellar.org')

await horizon.loadAccount(pubkey)
    .catch(() => horizon.friendbot(pubkey).call())
    .catch(() => { throw new Error('Failed to fund account') })

The next block configures some Stellar related stuff and ensures our passed in Stellar account is funded. Obviously this will only work on a test network like Testnet or Futurenet as only those networks boast a friendbot but as long as we have access to it I’ll happily use it! Fwiw, as you can see, I’m using Futurenet. Why? Because who isn’t excited about the future?!

const contractId = 'CBJM4YYC6PQ3BEX4DHYI4HWSIF5UNV53ZNWXT7BDPO7TRB2Q5545VQ3N'

Here I’m hardcoding a contract id to be used elsewhere throughout the script. It’s not strictly required as this value will be included further on from the contract interface bindings but when you’re doing a lot of testing and tweaking you may find yourself changing things in your deployed contract that don’t actually affect the interface. So you’ll be updating and redeploying your contract a bunch but not necessarily rebuilding your interface bindings meaning your contract id would be out-of-date. For this reason I’ll often set my contract id outside my bindings at least while I’m under active development.

The TS contract interface requires a wallet parameter when instantiating a new Contract class. Normally in a typical web frontend this will come from a wallet like Freighter but for this script we can just hard code something up pulling in our passed in Stellar Keypair which we derived from the --secret arg we’ll pass in when calling this index.ts file.

Essentially all we’re doing is setting up a passable wallet interface with all the required checks and signing methods.

const contract = new Contract({
    ...networks.futurenet,
    contractId,
    rpcUrl: 'https://rpc-futurenet.stellar.org',
    wallet: new Wallet()
})

We’re finally ready to instantiate a kalewalk-sdk contract interface. We can make use of the networks object which will have a few prefilled values which we set when we were calling the soroban contract bindings command a bit ago. The contractId is also included in that object but as I mentioned prior I like to manually set this during development just to ensure I’m always using the right contract id in the case I’ve updated and redeployed my contract in a way that didn’t change the underlying interface. The rpcUrl is much like the horizon service just for a different set of Soroban specific services. The wallet class we just prepared so we’ll just spin up a new one and we’re off to the races!

const encoder = new TextEncoder()
const decoder = new TextDecoder()

const str = `
ā”Œā”¬ā”¬ā”¬ā”
││▀▄│
ā”‚ā–ˆā”‚ā–€ā”‚
│▄▀││
┓┓┓┓┓
`.replace(/\n/g, '')

const canvas = Array.from(str).map((char) => {
    const arr = new Uint8Array(4)
    encoder.encodeInto(char, arr)
    return [...arr]
}).flat()

This block of code is specific the the kalewalk project. It’s how we actually get from an input string or set of characters and turn it into that BytesN<100> argument that the contract is expecting. Essentially we’re taking a plain string and splitting it into 4 byte blocks and finally flattening it to ensure our output is a single array of number values which represent the original unicode string.

Now at long last we can finally make our first contract invocation. We pass in the arguments the function is expecting and then signAndSend the call. Why the double await? Good question. Invoking a contract is a two step process, simulation and submission. Simulation is what retrieves the form of the transaction, the fees, the ledger items you’ll be interacting with and the estimated resources that’ll be consumed during the actual invocation. Once you have this ā€œrecipeā€ you can move into the actual signing and sending of the transaction to the network.

Once that submission comes back we’ll check for a SUCCESS status and a returnValue which we’ll use the scValToNative helper to decode into a usable value, in our case that u32 picture number.

Finally we’ll log the number so we can reference it later.

const pictureRes = await contract.getPicture({
    number
})

const {
    name,
    canvas: picture
}: {
    name: string,
    canvas: Buffer,
} = scValToNative(pictureRes.simulationData.result.retval)

With the picture now successfully minted and a number assigned we can lookup that picture using the same contract interface using the getPicture method. You’ll notice there’s not a signAndSend method here and that’s because this is essentially just a read request so the simulation response will suffice to acquire the result value we’re after.

let pictureResult = ''

for (let i = 0; i < picture.length; i += 4) {
    const slice = picture.subarray(i, i + 4)

    pictureResult += decoder.decode(slice)

    if ((i + 4) % 20 === 0)
        pictureResult += '\n'
}

console.log(pictureResult.replace(/\n$/, ` ${name}`))

With the picture acquired we’ll decode it’s BytesN<100> value into a nice console printout. We use the modulo 20 to loop over each row of 5 characters. (remember each character consists of 4 bytes so 4 x 5 = 20)

Congrats! We did it. I’m so proud of you, of us! Let’s make a few more, just for fun!

kalepail
XGitHub
#![no_std]
use soroban_sdk::{contract, contractimpl, Env, String, Symbol, Address, vec, Map, Val};

#[contract]
pub struct Contract;

#[contractimpl]
impl Contract {
    pub fn draw(env: Env) -> String {
        let picture = String::from_str(&env, "\
            .....\n\
            .....\n\
            .....\n\
            .....\n\
            .....\n\
        ");

        picture
    }
    pub fn mint(env: Env, owner: Address, author: String) -> Option<String> {
        let canvas = Self::draw(env.clone());

        env.invoke_contract(
            &Address::from_string(
                &String::from_str(&env, "CB4W5563H62YDM27BK5AVBYVPTFXHRPPMCYIMYHT3CWITCZSY34IO2RJ")
            ),
            &Symbol::new(&env, "mint"),
            vec![&env, canvas.to_val(), owner.to_val(), author.to_val()]
        )
    }
}
let number = match env.storage().persistent().get(&Key::Number(author.clone())) {
    Some(number) => number,
    None => {
        let number = env.storage().instance().get(&Key::Cursor).unwrap_or(1);

        env.storage()
            .persistent()
            .set(&Key::Author(number), &author);
        env.storage()
            .persistent()
            .set(&Key::Number(author.clone()), &number);

        let page = number / 20;
        let mut authors = env
            .storage()
            .persistent()
            .get(&Key::Authors(page))
            .unwrap_or(Map::new(&env));
        authors.set(number, author);
        env.storage()
            .persistent()
            .set(&Key::Authors(page), &authors);

        env.storage().instance().set(&Key::Cursor, &(number + 1));

        number
    }
};
{
  "name": "kalewalk",
  "module": "index.ts",
  "type": "module",
  "devDependencies": {
    "@types/bun": "latest"
  },
  "peerDependencies": {
    "typescript": "^5.0.0"
  },
  "dependencies": {
    "@stellar/stellar-sdk": "^11.2.2",
    "open": "^10.0.3",
    "kalewalk-sdk": "link:kalewalk-sdk"
  }
}
class Wallet {
    async isConnected() {
        return true
    }
    async isAllowed() {
        return true
    }
    async getUserInfo() {
        return {
            publicKey: pubkey
        }
    }
    async signTransaction(xdr: string) {
        const transaction = new Transaction(xdr, networks.futurenet.networkPassphrase)

        transaction.sign(keypair)

        return transaction.toXDR()
    }
    async signAuthEntry(entryXdr: string) {
        return keypair
						.sign(hash(Buffer.from(entryXdr, 'base64')))
						.toString('base64')
    }
}
const mintRes = await contract.mint({
    name: `Soroban`,
    author: pubkey,
    canvas: Buffer.from(canvas),
})

const mintResult = await mintRes.signAndSend()

let number = 0

if (mintResult.getTransactionResponse?.status === Api.GetTransactionStatus.SUCCESS) {
    if (mintResult.getTransactionResponse.returnValue) {
        number = scValToNative(mintResult.getTransactionResponse.returnValue)
    }
}

console.log(number)