Smart contract development is a high stakes game. We are writing code that can be seen by anyone, called by anyone, is immutable once deployed, and handles peoples’ real world financial assets.
Over the last few years, we have seen tens of millions of dollars in value destroyed due to security vulnerabilities, social engineering scams, bugs, phishing websites, the list goes on.
If we want the vision and ethos of web3 to be a reality, it is essential that we take steps to improve the user experience; security and safety being a key piece of that.
As web3 developers, it’s our job to make sure we write smart contracts using best security practices and to ensure our code does what it is supposed to do, no more and no less.
But if you have any experience writing smart contracts, you know that sometimes that can be easier said than done.
If you’ve been around the web3 world for any amount of time, either as a participant writing code or as a spectator watching curiously, trying to figure out if this new ecosystem is legitimate or not, you’ve surely seen how dangerous smart contracts can be when they are exploited.
As the decentralized Internet grows and new chains, tools, and languages emerge, we’ll start to see different tools built for different purposes. With Ethereum and Solidity being the pioneers, leading the charge in discovering this new frontier of development, we’re starting to see specialized tools be created that are designed with very particular goals in mind.
A Brief Intro to Stacks
One of these newer ecosystems, mainnet having launched at the beginning of 2021, is Stacks. Stacks, and its smart contract programming language, Clarity, were specifically created and built with maximum security, safety, and transparency in mind.
Stacks is an L1 chain that is anchored to Bitcoin through its unique consensus mechanism, Proof of Transfer (PoX). For most of the life of web3, Bitcoin has been the OG blockchain that pioneered the technology but didn’t do much else.
Historically, developers have seen it as relatively boring because you can’t write smart contracts for it, it doesn’t change much, and there isn’t much room for building tools with it.
Then Ethereum came along and took the ideas that Satoshi pioneered with Bitcoin and expanded them with the goal of creating a global supercomputer that could host and execute decentralized programs.
Stacks takes the same idea: that immutable, transparent code running on a distributed network is a good and powerful thing, and created a system that allows developers to build that functionality on Bitcoin, and here’s the kicker, without modifying Bitcoin itself.
The resistance to allowing smart contracts on Bitcoin has always come from a place of choosing extreme security and robustness over availability of features.
Adding smart contracts introduces new complexity, which introduces new potential for security issues and attacks. Bitcoin is designed to be the world’s best form of hard money. It’s designed to be simple, designed to be boring.
Those aren’t bugs, they are features.
But there is a second major use case for this boring money layer that just keeps on chugging along, doing its thing: a global settlement layer.
Just like TCP/IP serves as the foundation for everything we do on the Internet, Stacks envisions Bitcoin as being a similar settlement layer for the decentralized web.
So you have the super simple money/settlement layer that doesn’t do much but is extremely resistant to attacks on one side.
And then on the other side you have Stacks, which leverages some of that security while unlocking additional functionality with smart contracts.
How does it do that?
Proof of Transfer
Proof of Transfer is the consensus mechanism of the Stacks network. It works according to a similar concept as proof of work.
In proof of work, Bitcoin miners spend energy in order for a chance to mine Bitcoin. In proof of transfer, Stacks miners spend Bitcoin in order for a chance to mine Stacks, and earn a coinbase reward in Stacks.
You can learn more about proof of transfer in the Stacks docs, but the takeaway here is that it allows the Stacks network to be uniquely connected to Bitcoin and to inherit some of its security properties.
There are two primary benefits to this:
- You can’t change the history of the Stacks chain without also changing the history of the Bitcoin chain, since every Stacks block is written, as a hash, to every Bitcoin block
- This block-for-block connection gives Stacks a unique ability to read and respond to changes in the Bitcoin network via its smart contract language, Clarity
Clarity
Clarity is a smart contract programming language that was purpose-built for writing safer, more secure code.
As we mentioned above, smart contract programming is a high-stakes business, and it’s important to make sure that we write secure smart contracts.
While a good portion of this responsibility lies with the developer to write good, secure code with best practices, there are numerous things we can do at the language design level to help make this process easier on developers, which in turn ensures safer code, which helps people hold on to their assets.
There are a few unique aspects of Clarity that are likely different than you are used to, which are design to enhance security, but are worth pointing out since they can sometimes throw people off.
First thing to note about Clarity that is likely different than other languages you’ve used is that it uses composition instead of inheritance. This means there are no classes in Clarity. Rather, Clarity contracts can implement traits, which serve as templates to help you implement certain functionality in your contracts.
We’ll dive into this a bit more when we write our contract.
In addition, Clarity is not Turing complete. This has security benefits but also means that there are no unbounded loops in Clarity. So instead of writing loops, you need to iterate over defined data sources.
Finally, rather than pulling in external libraries, Clarity has built in functions for creating custom token types, we’ll use one of these today.
This can take some getting used to as it requires shifting your thinking a bit.
Building an NFT Smart Contract
With that high-level description of Clarity, let’s get into how to actually create a smart contract with it.
Clarity Basics
Clarity will likely take some getting used to, especially if you, like me, come from a JavaScript or Solidity background, but the more you use it, the more it will click and you’ll start to love the simplicity and succinctness.
First, Clarity is heavily inspired by LISP, which is a list-based language that makes heavy use of parentheses.
You can think of Clarity as being composed of lists inside other lists, or expressions inside expressions.
For example, in JS, we would write 4 + 5
and would be returned 9
.
In Clarity, that same expression would be written as (+ 4 5)
.
What’s happening here is that we are calling a +
function, that takes in 4
and 5
as parameters. All of Clarity functions this way, with everything being basically a function call with passed parameters.
We’ll cover more of this as we build out our NFT smart contract.
Project Setup with Clarinet
The first thing we need to do is get our development environment set up. To do that, we can use Clarinet. If you’ve done any Solidity development, this will fill a similar role to Hardhat.
Follow the Clarinet installation instructions for your system, then come back here and we’ll get our project created.
Change into the directory you want your project to live in and run clarinet new amazing-aardvarks
.
Switch into that directory and let’s get building.
Traits and Adding Our NFT Trait
The first thing we want to do is tell Clarinet that we are going to be using a trait in our contract, and that Clarinet should make that trait accessible to our local mocknet environment.
Let’s add that, and then I’ll explain a bit more what it means.
clarinet requirements add SP2PABAF9FTAJYNFZH93XENAJ8FVY99RRM50D2JG9.nft-trait
What we are doing here is telling Clarinet that we need to use the contract with the following principal and name as our trait.
Principals are basically addresses in Stacks, and a deployed contract has both a principal and a corresponding human-readable name.
This particular contract can be seen in the Stacks explorer, but it looks like this:
(define-trait nft-trait
(
;; Last token ID, limited to uint range
(get-last-token-id () (response uint uint))
;; URI for metadata associated with the token
(get-token-uri (uint) (response (optional (string-ascii 256)) uint))
;; Owner of a given token identifier
(get-owner (uint) (response (optional principal) uint))
;; Transfer from the sender to a new principal
(transfer (uint principal principal) (response bool uint))
)
)
This is what we call a trait in Stacks. A trait can be thought of as a smart contract template. When we were talking about how Clarity uses composition instead of inheritance, this is what we were referring to.
If you want to learn more about NFT traits and how to create your own collection, check out this article by our friends from Surge.
Rather than inheriting from a class like you might in JavaScript or Solidity, and gaining certain functionalities that way, with traits, you are telling your contract that it needs to adhere to a certain structure and implement certain functions.
This way, the ecosystem has a standardized way to interact with certain types of contracts and various entities can use the same functions.
For example, an NFT marketplace can interact with a SIP-009 token (the name of the process for making changes to Stacks, you can think of SIP-009 as analogous to ERC721) knowing that it will implement this structure, so their frontend can be consistent.
It’s important to note that traits don’t implement any functionality for us, like we might get if we inherit from something like an OpenZeppelin library.
So, taking the first definition, get-last-token-id
as an example, this trait is saying that any smart contract that utilizes this trait needs to implement a function call get-last-token-id
and it takes no parameters, and needs to return a response
that will return a uint
based on success or failure. In Clarity, something can return a response type of ok
or err
. In either case, we want the value of the success message or error message to be a uint
type.
You can learn more about types (and all other things Clarity) in the book, Clarity of Mind.
Traits only define the template, it’s up to us write the actual implementation.
This brings up one of the tradeoffs that are core to the ethos of Stacks and Clarity: security over convenience.
It takes more work on the part of the developer to have to implement the functionality of their NFT, rather than getting to inherit functionality from an external library.
The benefit we get from this is clarity and transparency of code, since all functionality has to be explicitly written out. This has the added benefit of making code audits easier to perform.
So what we’ve done here is told Clarinet that we want to use this community-standard NFT trait in our local dev environment, without needing to explicitly reference the version deployed to mainnet.
Creating the Contract
Now that we have our trait pulled in, let’s start creating our actual contract.
First let’s create it with Clarinet using clarinet contract new amazing-aardvarks
.
The amazing-aardvarks.clar
file that gets created will be the beginning of the greatest aardvark-themed NFT project the world has ever seen.
The default code provides us a template we can use to structure our code. Everything in Clarity lives at the top level of the contract. So we can define all of our functions, constants, and variables at the top level.
Let’s actually wipe out this file and get started by enforcing implementation of the trait we created.
(impl-trait 'SP2PABAF9FTAJYNFZH93XENAJ8FVY99RRM50D2JG9.nft-trait.nft-trait)
This is telling our contract that it should not be considered valid unless in explicitly conforms to this trait. That means we can’t deploy it until we meet this requirement.
And if you run clarinet check
after saving, you’ll notice it gives you an error.
ℹ️ Sidenote: There is a VS code extension that makes writing Clarity much easier, providing syntax highlighting, autocomplete, and inline error reporting.
Now that we have our trait enforcement in place, let’s start by defining our token, variables, and constants.
Constants and Variables
Remember that Clarity has built-in support for both non-fungible and fungible custom tokens.
So all we need to do to define our NFT is to add the following line right below the trait implementation:
(define-non-fungible-token amazing-aardvarks uint)
Here we are calling the define-non-fungible-token
function, creating our NFT with a name of amazing-aardvarks
and an identifier of uint
, which is an unsigned integer.
With this one line, we can use the functions nft-mint?
, nft-transfer?
, nft-get-owner?
, and nft-burn?
, which are all built-in Clarity functions we can use to manage our NFTs.
You can see a complete list of Clarity functions in the docs.
Next we’ll set up a variable for tracking the last token ID.
(define-data-var last-token-id uint u0)
This defines a variable (define-data-var
) with a name of last-token-id
, a type of unit
, and an initial value of u0
. Note the u
prefixing the 0, indicating this a uint
type, not just an int
type.
Next we’ll set up some constants that will handle a few pieces of functionality.
(define-constant contract-owner tx-sender)
(define-constant err-owner-only (err u100))
(define-constant err-not-token-owner (err u101))
Constants in Clarity are similar to other languages, data constructs that can’t be changed once they are define.
This first one is a common pattern you’ll see in Clarity contracts. The tx-sender
is a Clarity keyword that represents the principal sending the transaction. When we deploy this contract, that will be equal to the contract deployer.
So we set the contract-owner
constant to whichever principal deployed the contract. We then have that value to use in the contract from then on.
The next two are also common patterns you’ll see in Clarity for defining error codes. We’re assigning a certain error code to a specific constant name.
In our functions, when we need to trigger an error, we can simply return err-owner-only
, which will return u100
. Then, as the developer, we can check which constant that is to see what error our application is throwing.
Adding Our Functions
Alright we’ve got the basic data defined, let’s implement the functionality we need.
We’ll start by implementing the functions we need in order to conform to our NFT trait.
First up is get-last-token-id
. Here’s what that will look like.
(define-read-only (get-last-token-id)
(ok (var-get last-token-id))
)
This one is pretty simple, and is basically just a wrapper for a getter function that retrieves the last-token-id
variable we created above.
We are returning a successful response (ok
) with a value of whatever last-token-id
is currently set to using the var-get
function.
Next up we have get-token-uri
.
(define-read-only (get-token-uri (token-id uint))
(ok none)
)
This one is basically just a placeholder right now, because our smart contract doesn’t have a corresponding web interface. In a real dapp, this function would be in charge of retrieving the URL of where are NFT data was hosted.
In this case, we are passing in a parameter of the token-id
in order to look up the URI. We don’t actually need it in this case because we aren’t returning any real data.
This might on IPFS, Arweave, or something else.
get-owner
is next, which is also a simple wrapper.
(define-read-only (get-owner (token-id uint))
(ok (nft-get-owner? amazing-aardvarks token-id))
)
We are passing in another parameter this time, the token-id
that we are looking up. This just wraps the built-in nft-get-owner?
function in order to return the principal of the owner of the given NFT ID.
Next up we need to implement the transfer
function.
(define-public (transfer (token-id uint) (sender principal) (recipient principal))
(begin
(asserts! (is-eq tx-sender sender) err-not-token-owner)
(nft-transfer? amazing-aardvarks token-id sender recipient)
)
)
If you have the Clarity extension get an inline warning here, ignore it for now. We’ll address this a bit further down.
This introduces a couple new constructs: begin
and asserts!
.
Remember when we were talking about how everything in Clarity can be thought of as a list within a list? Or an expression within another expression? Functions are no different.
The body of our function is just another argument that we are passing to the function. Because of that, if we have a function body that consists of more than one expression, we need to wrap it in a begin
block.
The begin
block takes our function body and executes it line-by-line, but wraps it in a singular expression for the purposes of passing it to the function declaration.
Next we have the asserts!
statement. This is one of a few different control flow functions we have in Clarity. These are guards meant to check for a certain condition. If the condition is true, the function continues. If it is false, the function aborts.
It’s important to note that everything that happened in the function is reverted if an error is thrown.
So in this case, we are checking to see that the sender of the transaction is the same as the sender
parameter being passed to the function, otherwise we return the err-not-token-owner
error we defined above.
Without this, anybody would be able to transfer an NFT from anybody else’s wallet.
If the check passes, then we move on to transfer the actual NFT using the nft-transfer?
function.
Finally, we’re going to add a mint
function for our NFT collection. This is not required by the trait, but we’re going to add it as a convenience function to introduce a couple more concepts and serve as a wrapper for the built-in mint function with a couple of extra features.
(define-public (mint (recipient principal))
(let
(
(token-id (+ (var-get last-token-id) u1))
)
(asserts! (is-eq tx-sender contract-owner) err-owner-only)
(try! (nft-mint? amazing-aardvarks token-id recipient))
(var-set last-token-id token-id)
(ok token-id)
)
)
This is the most complex function we’ve seen so far and introduces a few new concepts.
Let’s look at this let
function first.
let
is similar to begin
, with one additional step.
The first argument passed to the function is a set of variables that get defined and set in the context of this function.
So the first line of the let
function is creating a new variable (scoped only for this function call) called token-id
and assigning it to the result of adding the last-token-id
and u1
.
Then, using that variable, it moves on to execute the rest of the function in order.
So it will first perform a similar check as the last function, checking to make sure that the contract owner is the only address that can call the nft-mint?
function.
Next up we have another new function, try!
.
The try!
function will take an optional
or response
type and attempt to unwrap it, returning either the inner value or an error if it cannot unwrap it.
That last sentence introduces many new topics on its own. First we have the new types of optional
and response
.
An optional
is a type that can either contain a value or none
, which is Clarity’s keyword for representing nothing. This is what we return when a function call might not return anything.
We’ve actually seen a few response
types already: ok
and err
.
The built in nft-mint?
function will either return (ok true)
or (err u1)
depending on if it was successful or not.
The act of unwrapping attempts to extract the inner value contained in these and return it. If it returns the true
value, then function execution continues, otherwise it exits and returns the error code, u1
in this case.
Finally, if everything works, we return (ok token-id)
.
Checking Our Contract
Before we test out the functionality and explore some of the things Clarinet can do.
First, let’s look at the entire contract:
(impl-trait 'SP2PABAF9FTAJYNFZH93XENAJ8FVY99RRM50D2JG9.nft-trait.nft-trait)
(define-non-fungible-token amazing-aardvarks uint)
(define-data-var last-token-id uint u0)
(define-constant contract-owner tx-sender)
(define-constant err-owner-only (err u100))
(define-constant err-not-token-owner (err u101))
(define-read-only (get-last-token-id)
(ok (var-get last-token-id))
)
(define-read-only (get-token-uri (token-id uint))
(ok none)
)
(define-read-only (get-owner (token-id uint))
(ok (nft-get-owner? amazing-aardvarks token-id))
)
(define-public (transfer (token-id uint) (sender principal) (recipient principal))
(begin
(asserts! (is-eq tx-sender sender) err-not-token-owner)
(nft-transfer? amazing-aardvarks token-id sender recipient)
)
)
(define-public (mint (recipient principal))
(let
(
(token-id (+ (var-get last-token-id) u1))
)
(asserts! (is-eq tx-sender contract-owner) err-owner-only)
(try! (nft-mint? amazing-aardvarks token-id recipient))
(var-set last-token-id token-id)
(ok token-id)
)
)
Now save this file if you haven’t yet, and run clarinet check
.
What are these warnings we’re getting?
Clarinet has a built in check checker that will check for any unchecked inputs. Unchecked user input is the cause of many bugs and security vulnerabilities, and this serves as a simple reminder for developers to check their inputs.
You can read more about this feature in the Clarinet README.
So how do we get rid of these errors? Clarinet provides some annotations we can add in order to silence these errors.
In this case we can use filters to tell Clarinet that certain pieces of data are safe.
For our transfer
function, unchecked data is what we want, since we are checking the sender
, the recipient should be allowed to be whatever we want. Same with the token-id
, we should be able to choose whatever token we want, the asserts!
statement will ensure that we own it.
So we can add the filter line right above where we use that parameter. With that, our function looks like this now.
(define-public (transfer (token-id uint) (sender principal) (recipient principal))
(begin
(asserts! (is-eq tx-sender sender) err-not-token-owner)
;; #[filter(token-id, recipient)]
(nft-transfer? amazing-aardvarks token-id sender recipient)
)
)
And we can do something similar for our mint
function.
(define-public (mint (recipient principal))
(let
(
(token-id (+ (var-get last-token-id) u1))
)
(asserts! (is-eq tx-sender contract-owner) err-owner-only)
;; #[filter(recipient)]
(try! (nft-mint? amazing-aardvarks token-id recipient))
(var-set last-token-id token-id)
(ok token-id)
)
)
And with that we get a passing clarinet check
.
Testing with Clarinet Console
Now let’s dive into the console and interact with our NFT contract.
Start by entering the console with clarinet console
. This will drop us into an interactive shell with some mock data where we can interact with our contract.
First let’s test minting a new token:
(contract-call? .amazing-aardvarks mint tx-sender)
That should return something similar to the following:
Events emitted
{"type":"nft_mint_event","nft_mint_event":{"asset_identifier":"ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM.amazing-aardvarks::amazing-aardvarks","recipient":"ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM","value":"u1"}}
(ok u1)
Then we can run the special Clarinet function ::get_assets_maps
in order to see what just happened.
This allows us to see what assets each mock address in our console has. We can see that the first address, the deployer, now has one amazing-aardvark
NFT.
Let’s transfer that to another address:
(contract-call? .amazing-aardvarks transfer u1 tx-sender 'ST2CY5V39NHDPWSXMW9QDT3HC3GD6Q6XX4CFRK9AG)
Now we can run ::get_assets_maps
again to see if it worked:
Wrapping Up and Next Steps
Congratulations! You just coded up a complete (although basic) NFT smart contract on Stacks and Clarity. If you were to deploy this contract, every transaction would ultimately settle on the Bitcoin network via proof of transfer.
I’m Kenny, the developer advocate for the Stacks Foundation. You can find my writing, all my socials, and a Stacks learning roadmap on my Arweave permapage at kenny.arweave.dev. Feel free to reach out if you have any questions or if there is anything I can help with.