Developer Quickstart

Your 0→1 guide for building a Clarity contract and app on Stacks.

Welcome to the Stacks Developer Quickstart Guide! This is your fast-track path for understanding what you'll need to become a Stacks developer. In this guide, you’ll build a real Clarity smart contract, wire up a functioning Stacks app, and pick up about 75% of the practical knowledge every Stacks builder needs. Whether you’re shipping your first project or leveling up your skills, this guide takes you from zero to deployed—quickly and confidently.

What you'll achieve

By the end of this quickstart, you’ll have built an onchain app by:

  • Building a Clarity smart contract with Clarinet

  • Utilize the 1:1 Bitcoin backed token, sBTC

  • Deploying your smart contract to Stacks' testnet

  • Interacting with your deployed contract from a frontend app

What You'll Build

The app you'll build will be a message board contract. Users can add a new message to store on-chain for a fee of 1 satoshi in sBTC. Other functionality to read data from the contract will also be handled. Besides sBTC, there will be other things that'll be introduced to you such as post-conditions, Bitcoin read access, unit testing, wallet connectivity, BNS, Hiro, and more. Hopefully all this will give you a good flavor of what you can expect in the Stacks builder ecosystem.

Let's start building on Bitcoin! 🟧

Prerequisites

  • Basic familiarity with web development

  • Basic familiarity with web3 concepts

  • A modern web browser

  • Node.js

  • Visual Studio Code or any other popular IDE

Set Up Your Developer Environment

1

Install Clarinet

Clarinet is the popular CLI tool to build, test, and deploy smart contracts on the Stacks blockchain.

Below are a few different ways to install Clarinet on your machine using your terminal. Refer to the dedicated installation guide in the 'Learn Clarinet' section for more information.

brew install clarinet
2

Install Clarity Extension

You'll also want to install the Clarity Extension for your code editor. The official one is 'Clarity - Stacks Labs' which is maintained by Stacks Labs.

What is Clarity?

Clarity is Stacks' smart contract language, designed for safety and predictability.

Clarity is inspired by LISP and uses a functional programming approach. Everything in Clarity is an expression wrapped in parentheses. This can be a bit overwhelming at first if you are used to languages like JavaScript or Solidity, but the learning curve is short and Clarity is a simple language to understand once you dive in and start using it.

Check out the Clarity Crash Course for a quick primer.

The 'Clarity - Stacks Labs' extension as it appears in Visual Studio Code.
3

Install a Stacks wallet

There are many Stacks supported wallets in the market. For this guide, we'll be using the Leather wallet. Leather supports Stacks, Bitcoin, and other Bitcoin related meta-protocols. Download and install its browser extension so you can interact with your smart contract later on in this guide. Make sure to switch to the Testnet network in your wallet settings. Later on, we'll show you how to get testnet STX and sBTC tokens that you'll use for contract interaction.

Create a Clarity smart contract

1

Create a new Clarinet project

Let's start by creating a new Clarinet project which will house our smart contract. The clarinet new command sets up everything you need for smart contract development, including a testing framework, deployment configurations, and a local development environment.

terminal
clarinet new my-stacks-contracts

A Clarinet project will be scaffolded with the below:

terminal
Created directory my-stacks-contracts
Created directory contracts
Created directory settings
Created directory tests
Created file Clarinet.toml
Created file settings/Mainnet.toml
Created file settings/Testnet.toml
Created file settings/Devnet.toml
Created directory .vscode
Created file .vscode/settings.json
Created file .vscode/tasks.json
Created file .gitignore
Created file .gitattributes
Created file package.json
Created file tsconfig.json
Created file vitest.config.ts

----------------------------
Hint: what's next?
Switch to the newly created directory with:

  $ cd my-stacks-contracts

Once you are ready to write your contracts, run the following commands:

  $ clarinet contract new <contract-name>
    Create new contract scaffolding, including test files.

  $ clarinet check
    Check contract syntax for all files in ./contracts.
2

Generate your contract

Now that we have our project structure, let's create a smart contract. Navigate into your project directory and use Clarinet's contract generator:

$ cd my-stacks-contracts
$ clarinet contract new message-board
Created file contracts/message-board.clar
Created file tests/message-board.test.ts
Updated Clarinet.toml with contract message-board

Clarinet automatically creates both your contract file and a corresponding test file.

Write your Clarity smart contract

1

Define constants

Open contracts/message-board.clar and remove its existing content. This is where we'll start writing our own Clarity smart contract.

Let's first define some constants:

  • contract owner to establish control access

  • custom error codes to handle errors in functions

message-board.clar
;; Simple Message Board Contract
;; This contract allows users to read and post messages for a fee in sBTC.

;; Define contract owner
(define-constant CONTRACT_OWNER tx-sender)

;; Define error codes
(define-constant ERR_NOT_ENOUGH_SBTC (err u1004))
(define-constant ERR_NOT_CONTRACT_OWNER (err u1005))
(define-constant ERR_BLOCK_NOT_FOUND (err u1003))

You'll notice in the CONTRACT_OWNER constant that tx-sender is set in place as the value. When this contract is deployed, the Clarity VM will determine who the tx-sender is based on who deployed the contract. This allows the hardcoded tx-sender to always point to the principal that deployed the contract.

2

Define data storage

We'll then need to define some data storage:

  • A map to store key-value pairs of the message id and it's related metadata

  • A data variable to count the total number of messages added

message-board.clar
;; Define a map to store messages
;; Each message has an ID, content, author, and Bitcoin block height timestamp
(define-map messages
  uint
  {
    message: (string-utf8 280),
    author: principal,
    time: uint,
  }
)

;; Counter for total messages
(define-data-var message-count uint u0)
3

Define an add message function

Next up is our main function of the contract. This function allows users to add a new message to the contract for a fee of 1 satoshi in sBTC. Invoking this function will change the state of our contract and update the data storage pieces we setup before.

message-board.clar
;; Public function to add a new message for 1 satoshi of sBTC
;; @format-ignore
(define-public (add-message (content (string-utf8 280)))
  (let ((id (+ (var-get message-count) u1)))
    (try! (restrict-assets? contract-caller 
      ((with-ft 'SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token "sbtc-token" u1))
      (unwrap!
        ;; Charge 1 satoshi of sBTC from the caller
        (contract-call? 'SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token
          transfer u1 contract-caller current-contract none
        )
        ERR_NOT_ENOUGH_SBTC
      )
    ))
    ;; Store the message with current Bitcoin block height
    (map-set messages id {
      message: content,
      author: contract-caller,
      time: burn-block-height,
    })
    ;; Update message count
    (var-set message-count id)
    ;; Emit event for the new message
    (print {
      event: "[Stacks Dev Quickstart] New Message",
      message: content,
      id: id,
      author: contract-caller,
      time: burn-block-height,
    })
    ;; Return the message ID
    (ok id)
  )
)

There's quite a lot going on in this function above that covers in-contract post-conditions, calling the official sBTC token contract, reading Bitcoin state, emitting events, and etc. We'll break it down for you:

Define public function and params
(define-public (add-message (content (string-utf8 280)))
    ;; function body
)

By using the define-public function, we can literally create a public function where anyone can invoke.

  • (add-message ... ) : the custom name of the public function

  • (content (string-utf8 280)) : the custom paramater name and type

Create let variable binding for next message id
(let ((id (+ (var-get message-count) u1)))
    ;; body expressions
)

Creates a "local" variable that can be used inside the function body only. This id variable will be used to represent the new message id being added.

Transfer 1 satoshi of sBTC from user to the contract
(try! (restrict-assets? contract-caller 
  ((with-ft 'SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token "sbtc-token" u1))
  (unwrap!
    ;; Charge 1 satoshi of sBTC from the caller
    (contract-call? 'SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token
      transfer u1 contract-caller current-contract none
    )
    ERR_NOT_ENOUGH_SBTC
  )
))

This snippet calls the external .sbtc-token contract to transfer sBTC.

The restrict-assets? acts as an in-contract post-condition to protect user and contract funds when calling external contracts to transfer assets.

Store message data in mapping
(map-set messages id {
  message: content,
  author: contract-caller,
  time: burn-block-height,
})

The function map-set will allow the existing mapping of messages to add a new key-value pair consiting of the metadata of the new message.

We'll be using the current Bitcoin block height (via burn-block-height) as a way to capture the time of when this new message was added. Through burn-block-height , Clarity allows us to have read access into the Bitcoin state at anytime.

Update the message-count variable
(var-set message-count id)

Increments the existing data variable of message-count with the let id variable.

Emit an event to the network
(print {
  event: "[Stacks Dev Quickstart] New Message",
  message: content,
  id: id,
  author: contract-caller,
  time: burn-block-height,
})

The print function will allow us to emit a custom event to the Stacks network.

Emitting events on Stacks serves several critical purposes:

  1. Transparency: Events provide an on-chain record of actions and transactions, ensuring transparency.

  2. Notification: They serve as a signal mechanism for users and external applications, notifying them of specific occurrences on Stacks.

  3. State Tracking: Developers can use events to track changes in the state of smart contracts without querying the chain continuously.

  4. Efficient Data Handling: By emitting events, webhook services, such as Hiro's Chainhooks, can filter and handle relevant data efficiently, reducing the on-chain computation load.

Return final response
;; Return the message ID
(ok id)

Public functions must return a ResponseType (using either ok or err). In this case, we'll return a response type with an inner value of the new message id.

4

Add sBTC contract requirements

Since we're working with sBTC in our local developer environment, we'll need to make sure Clarinet can recognize this. Clarinet can automatically wire up the official sBTC contracts so you can build and test sBTC flows locally.

In our case, all we'll need to do is add the .sbtc-deposit contract as a project requirement.

terminal
clarinet requirements add SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-deposit

You'll notice in the add-message public function, we're making an external contract call to the .sbtc-token contract. This is the official sBTC token contract that contains the SIP-010 standard transfer function that we are invoking.

Check out the dedicated sBTC integration page to learn more.

5

Allow contract owner to withdraw funds

In the beginning of our contract, we defined a constant to store the Stacks principal of the contract owner. Having a contract owner allows for specific access control of the contract that is entitled to the owner. Let's allow the owner to be able to withdraw the accumulated sBTC fees that were sent by anyone who created a new message in the contract.

;; Withdraw function for contract owner to withdraw accumulated sBTC
(define-public (withdraw-funds)
  (begin
    (asserts! (is-eq tx-sender CONTRACT_OWNER) (err u1005))
    (let ((balance (unwrap-panic (contract-call? 'SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token
        get-balance current-contract
      ))))
      (if (> balance u0)
        (contract-call? 'SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token
          transfer balance current-contract CONTRACT_OWNER none
        )
        (ok false)
      )
    )
  )
)

You'll notice in the highlighted line that the function performs an asserts! check to confirm that the tx-sender calling the contract is in fact the CONTRACT_OWNER . If it is in fact the owner of the contract, the function body proceeds with transferring the balance of sBTC to the owner or else it'll throw an error that we defined earlier.

The usage of tx-sender versus another Clarity keyword, contract-caller , is always a tricky concept because it determines who actually initiated the transaction versus who invoked the current function. Both of them can have certain implications on security based on the context of your code. Check out the dedicated blog, written by community dev Setzeus, to learn when you should use either or.

6

Implement read only functions

We'll round out our contract with important read only functions that will return us needed data from the contract.

;; Read-only function to get a message by ID
(define-read-only (get-message (id uint))
  (map-get? messages id)
)

;; Read-only function to get message author
(define-read-only (get-message-author (id uint))
  (get author (map-get? messages id))
)

;; Read-only function to get message count at a specific Stacks block height
(define-read-only (get-message-count-at-block (block uint))
  (ok (at-block
    (unwrap! (get-stacks-block-info? id-header-hash block) ERR_BLOCK_NOT_FOUND)
    (var-get message-count)
  ))
)

You'll notice the usage of a at-block function in the highlighted line of code. The at-block function evaluates the inner expression as if it were evaluated at the end of a specific Stacks block.

7

Test your contract

Now with the actual writing of your contract complete, we now need to test its functionality. There's a few different ways we can go about iterating and testing the functionality of your contract.

We'll go with unit testing for now. In your tests folder, open up the related message-board.test.ts file and let's use the unit test written below.

tests/message-board.test.ts
import { Cl, ClarityType } from "@stacks/transactions";
import { describe, expect, it } from "vitest";

const accounts = simnet.getAccounts();
const deployer = accounts.get("deployer")!;
const address1 = accounts.get("wallet_1")!;

describe("example tests", () => {
  let content = "Hello Stacks Devs!"

  it("allows user to add a new message", () => {
    let currentBurnBlockHeight = simnet.burnBlockHeight;

    let confirmation = simnet.callPublicFn(
      "stacks-dev-quickstart-message-board",
      "add-message",
      [Cl.stringUtf8(content)],
      address1
    )

    const messageCount = simnet.getDataVar("stacks-dev-quickstart-message-board", "message-count");
    
    expect(confirmation.result).toHaveClarityType(ClarityType.ResponseOk);
    expect(confirmation.result).toBeOk(messageCount);    
    expect(confirmation.events[1].data.value).toBeTuple({
      author: Cl.standardPrincipal(address1),
      event: Cl.stringAscii("[Stacks Dev Quickstart] New Message"),
      id: messageCount,
      message: Cl.stringUtf8(content),
      time: Cl.uint(currentBurnBlockHeight),
    });
  });

  it("allows contract owner to withdraw funds", () => {
    simnet.callPublicFn(
      "stacks-dev-quickstart-message-board",
      "add-message",
      [Cl.stringUtf8(content)],
      address1
    )
    
    simnet.mineEmptyBurnBlocks(2);

    let confirmation = simnet.callPublicFn(
      "stacks-dev-quickstart-message-board",
      "withdraw-funds",
      [],
      deployer
    )
    
    expect(confirmation.result).toBeOk(Cl.bool(true));
    expect(confirmation.events[0].event).toBe("ft_transfer_event")
    expect(confirmation.events[0].data).toMatchObject({
      amount: '1',
      asset_identifier: 'SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token::sbtc-token',
      recipient: deployer,
      sender: `${deployer}${".stacks-dev-quickstart-message-board"}`,
    })
  })
});

You'll notice we have two it blocks setup to test out 2 different scenarios:

  1. Allows user to add a new message

  2. Allows owner to withdraw sBTC funds

Run the test via npm run test to confirm that the two scenarios are functioning as intended.

terminal
$ npm run test

 ✓ tests/message-board.test.ts (2 tests) 46ms
   ✓ message board tests (2)
     ✓ allows user to add a new message 26ms
     ✓ allows contract owner to withdraw funds 19ms

 Test Files  1 passed (1)
      Tests  2 passed (2)
   Start at  14:05:07
   Duration  886ms (transform 40ms, setup 42ms, collect 8ms, tests 46ms, environment 699ms, prepare 4ms)

Great! Now that your contract is working as intended, let's deploy the contract to testnet.

Get testnet faucet tokens

1

Navigate to the Hiro Platform faucet

Hiro is a platform to build and scale Bitcoin apps, including custom data streams, onchain alerts, API key management, and more. Create an account and navigate to the top tab of 'Faucet'. On the Faucet page, you can request testnet STX and/or sBTC. We'll be needing both so fund your Leather wallet account with both.

Grab the testnet Stacks address from your Leather wallet and paste it in the recipient field.

2

Confirm testnet tokens in your wallet

Open up your Leather extension to confirm that you've received testnet STX and sBTC. You might need to enable the viewing of the sBTC token in your wallet under 'Manage tokens'.

With both testnet STX and sBTC, you're ready to deploy your contract and interact with it from a front-end client.

Deploy your Clarity smart contract

1

Generate testnet deployment plan

You'll first want to input a mnemonic seed phrase in the settings/Testnet.toml file and specify the account derivation path that you want to use for deploying the contract. The account should be the same one you used to request testnet STX to. This will be the account that actually deploys the contract and becomes the contract owner.

settings/Testnet.toml
[network]
name = "testnet"
stacks_node_rpc_address = "https://api.testnet.hiro.so"
deployment_fee_rate = 10

[accounts.deployer]
mnemonic = "<YOUR TESTNET MNEMONIC>"
derivation = "m/44'/5757'/0'/0/0"

Then generate a deployment plan for the testnet network. Deployment plans are YAML files that describe how contracts are published or called.

terminal
$ clarinet deployments generate --testnet --medium-cost
Analyzing contracts...
Calculating deployment costs...
Generating deployment plan
Created file deployments/default.testnet-plan.yaml
2

Deploy contract to testnet

Once your deployment plan is generated and configured properly, go ahead and deploy the contract to testnet.

terminal
clarinet deployments apply --testnet

If the contract was successfully deployed, you should see the below confirmation:

Broadcasting transactions to https://api.testnet.hiro.so
Publish ST11V9ZN6E6VG72SHMAVM9GDE30VD3VGW5Q1W9WX3.message-board Transaction confirmed

A sample of the contract we just created above is already deployed to testnet here. Check out its contract page on the Stacks Explorer and directly interact with its functions.

Use stacks.js on the frontend

1

Connect wallet

Using stacks.js packages on the frontend will allow our frontend app to authenticate wallets, call our contract functions, and interact with the Stacks network.

We'll first want to connect and authenticate our Leather wallet extension with our frontend app. The stacks.js monorepo contains several underlying packages specific to different use cases. The package @stacks/connect is the main connectivity package used in Stacks.

In the snippet below, you'll notice we have 3 functions setup to handle connectWallet , disconnectWallet, and for getBns . All 3 functions will be integral in how we want to display the 'Connect' and 'Disconnect' button in the UI.

Retrieving a wallet account's associated BNS is a staple of Stacks and for web3 identity. Check out BNSv2 for more information and for availably public API endpoints you could use.

src/App.tsx
import { connect, disconnect } from '@stacks/connect'
import type { GetAddressesResult } from '@stacks/connect/dist/types/methods'
import { useState } from 'react'

function App() {
  let [isConnected, setIsConnected] = useState<boolean>(false)
  let [walletInfo, setWalletInfo] = useState<any>(null)
  let [bns, setBns] = useState<string>('')

  async function connectWallet() {
    let connectionResponse: GetAddressesResult = await connect()
    let bnsName = await getBns(connectionResponse.addresses[2].address)

    setIsConnected(true)
    setWalletInfo(connectionResponse)
    setBns(bnsName)
  }

  async function disconnectWallet() {
    disconnect();
  }
  
  async function getBns(stxAddress: string) {
    let response = await fetch(`https://api.bnsv2.com/testnet/names/address/${stxAddress}/valid`)
    let data = await response.json()

    return data.names[0].full_name
  }
  
  return (
    <>
      <h3>Stacks Dev Quickstart Message Board</h3>
      {isConnected ? (
        <button onClick={disconnectWallet}>{
          bns ? bns : walletInfo.addresses[2].address
        }</button>
      ) : (
        <button onClick={connectWallet}>connect wallet</button>
      )}
    </>
  )
}

The connect() method comes with ability to configure how you want the wallet selector modal to appear for your app. You can decide which wallets to have only appear as an option or allow any wallet that follows the SIP-030 standard to appear as an available Stacks wallet.

The Stacks Connect wallet selector modal
2

Call `add-message` public function

Next, we'll setup a stx_callContract to invoke the add-message public function of our contract. This function will accept a string content to be passed into our contract call.

import { request } from '@stacks/connect'
import type { TransactionResult } from '@stacks/connect/dist/types/methods'
import { Cl, Pc } from '@stacks/transactions'
import { useState } from 'react'

function App() {
  // ...
  let [content, setContent] = useState<string>('')

  async function addMessage() {
    let postCond_1 = Pc.principal('ST11V9ZN6E6VG72SHMAVM9GDE30VD3VGW5Q1W9WX3')
      .willSendEq(1)
      .ft('ST1F7QA2MDF17S807EPA36TSS8AMEFY4KA9TVGWXT.sbtc-token', 'sbtc-token')
  
    let result: TransactionResult = await request('stx_callContract', {
      contract: 'ST11V9ZN6E6VG72SHMAVM9GDE30VD3VGW5Q1W9WX3.stacks-dev-quickstart-message-board',
      functionName: 'add-message',
      functionArgs: [Cl.stringUtf8(content)],
      network: 'testnet',
      postConditions: [postCond_1],
      postConditionMode: 'deny',
      sponsored: false
    })
  
    setContent('')
  }

  return (
    <>
      // ...
      <span className='input-container'>
        <button onClick={addMessage}>add-message</button>
        <input type="text" onChange={e => setContent(e.target.value)}/>
      </span>
    </>
  )
}

You'll notice in the transaction data object that we pass into our string literal method of stx_callContract, that we're setting up post-conditions. Post-Conditions for the frontend are declared to protect user assets. The Pc helper from @stacks/transactions helps us to declare post-condition statements for any type of asset and equality operator.

Invoking our addMessage function will prompt the user's connected wallet to prompt a transaction confirmation popup. This popup will display all of the relevant information of the transaction as well as the post-condition statements that we've declared.

3

Call read-only function

As how we've created a few read-only functions in our contract, we'll also want to call these from the frontend to retrieve certain contract data.

Let's setup a fetchCallReadOnlyFunction to invoke our contract's get-message-count-at-block read-only function. For this, we'll fetch the current Stacks block height from the Hiro API endpoint and pass that returned value into our read-only function.

// ...
import type { ClarityValue } from '@stacks/connect/dist/types/methods'
import { Cl, fetchCallReadOnlyFunction } from '@stacks/transactions'

function App() {
  // ...  
  async function getMessageCountAtBlock() {
    let response = await fetch('https://api.testnet.hiro.so/v2/info', {
      headers: {
        "x-api-key": "<HIRO_API_KEY>"
      }
    })
    let data = await response.json()
    let stacksBlockHeight = data.stacks_tip_height

    let result: ClarityValue = await fetchCallReadOnlyFunction({
      contractAddress: 'ST11V9ZN6E6VG72SHMAVM9GDE30VD3VGW5Q1W9WX3',
      contractName: 'stacks-dev-quickstart-message-board',
      functionName: 'get-message-count-at-block',
      functionArgs: [Cl.uint(stacksBlockHeight)],
      network: 'testnet',
      senderAddress: 'ST11V9ZN6E6VG72SHMAVM9GDE30VD3VGW5Q1W9WX3',
    })
  }
  
  // ...

For the complete set of available API endpoints for the Stacks network, check out the Hiro docs. But first create an API key from the Hiro Platform to determine your API rate plan.

And that's it, you've successfully created an sBTC powered Clarity smart contract which can be interacted with from a frontend app. There's obviously much more you can do to complete this but you've got some of the basics down pat now. Go ahead and finish creating the frontend functions to call on the other contract functions we have.


Further Improvements

This is just the beginning. There are many ways we can improve upon this app. Here are some suggestions for you to extend the functionality of this app:

  • Deploy to mainnet and share your project with the community

  • Use Chainhooks to index emitted events from the contract

  • Integrate the sbtc library so users can directly bridge their BTC to sBTC in-app

  • Utilize SIP-009 NFTs to uniquely identify each message for each author


Next Steps

Now that you have the basics down, here are some ways to continue your Stacks development journey:

Learn More About Clarity

Development Tools

Community Resources

Last updated

Was this helpful?