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!
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!
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.
- An underlying Soroban smart contract
- An interface for easily interacting with the contract
- 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.
#![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()]
)
}
}
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.
- Rather than using a second user-modified contract to utilize the minter contract we’ll use a frontend interface.
- 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.
- 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 struct
ure 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 enum
erated 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 impl
ement our Contract
interface. Finally right!? We’ll have a number of pub
lic f
un
ctions. 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 initialenv
arg here.name
is a simpleString
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 theAddress
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. WhyBytesN<100>
vsString
? Good question with a pretty long answer but boiled down toString
currently doesn’t have a clean way to get at the underlying data and- 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
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.
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
}
};
Next up is this gorgeous plop of match
code which allows an artist to update their previously submitted artwork. The basic flow is
- Hello, it’s me, and I’m curious if I’ve ever left some of my stuff at your place before
- If so would you please give it back
- If not add me to your long list and give me the next (or first if I should be so lucky) value
- 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 picturenumber
and that thisnumber
is owned by me theAuthor
. A kind of vise versa reverse lookup table switchero. - 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. - 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.
- Last but not least, after all we’ve been through, just give me your number, and let’s move on.
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
}
};
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.
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.
{
"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"
}
}
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.
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')
}
}
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
.
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)
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!