Build a web3 application on Stacks

Develop a Web3 Application to interact with our Voting Smart Contract from the browser

  1. Set up the web app and authenticate with a wallet
  2. Call the voting smart contract
  3. Send transactions to the smart contract
  4. Finalize the web3 app

Call the voting smart contract

In the previous article, we worked on some mandatory steps to get started. The people visiting our web app are now able to authenticate with their Stacks Wallet. Once logged in, they will be able to call the functions exposed by our contract.

πŸ’‘ Make sure that your code from the previous article is working. You can pull the code from the step-1 branch.

Read-only VS Public Functions

In the very first article of the voting smart contract series, I explained the difference between read-only and public functions. It will become more relevant now that will call the contract from our web app.

While these two types of functions are publicly exposed, they have a few fundamental differences.

  • πŸ‘‰ A call to a read-only function is an HTTP request to a stacks-api endpoint. So no fees are required.
  • πŸ‘‰ Calling a public function means creating a transaction that will be validated by a miner, who will be rewarded with the fees set by the caller. It requires opening a web wallet popup and waiting a few minutes for the transaction to be validated.

From a developer's point of view, it means that calling a read-only function or a public one will be different.

Fetch Color options

The "color-vote" contract is our source of truth, it exposes the vote options that can be fetched by calling the read-only function get-colors.
Create a file called ./src/data/stacks.ts in which we will abstract calls to our contract. First, we will require some of our dependencies, declare some functions and instantiate a "network" object:

./src/data/stacks.ts

import { StacksMocknet } from 'micro-stacks/network'
import { callReadOnlyFunction } from 'micro-stacks/transactions'

const network = new StacksMocknet({
  url: 'http://localhost:3999',
})

const ADDRESS = 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM'
const CONTRACT = 'color-vote'

The ADDRESS stores the Stacks address on which our contract is deployed. In development, the contract was deployed by running $ clarinet integrate. It uses the deployer address in the Devnet.toml file.

In the future, we will add some logic to handle calls to the testnet or the mainnet. For now, working with the devnet is enough.

☝️ "devnet" and "mocknet" are synonyms. It's your locally running Stacks network. The "mainnet" is the real Stacks network while the "testnet" is a similar one but where everything is fake.

The callReadOnlyFunction method is quite self-explanatory. As explained above, it will call a stacks API endpoint that will call a smart contract's read-only function. It accepts an options argument. Here is a description of the required options:

callReadOnlyFunction({
  contractAddress, // the address where the contract is deployed
  contractName, // the name of the contract
  functionName, // the read-only function to call
  functionArgs, // an array of arguments to pass to the read-only function
  senderAddress, // the address of the wallet making the call
  network, // the devnet, testnet, or mainnet object
})

Let's write a function called readOnlyRequest that will allow us to call read-only functions on the color-vote contract.

./src/data/stacks.ts

// add useAuth import
import { useAuth } from '../stores/useAuth'
// ...

export async function readOnlyRequest(name, args = []) {
  const address = useAuth.getState().session?.addresses.testnet

  const res = await callReadOnlyFunction({
    contractAddress: ADDRESS,
    contractName: CONTRACT,
    functionName: name,
    functionArgs: args,
    senderAddress: address,
    network,
  }))

  return res
}

πŸ’‘ We just used another great feature of zustand, we can call getState on a store hook outside of a React component. Here it allows us to get the address of the logged-in wallet.

This first implementation is quite simple, but not very secure.

Here is an extended version with TypeScript and error handling

./src/data/stacks.ts

import { StacksMocknet } from 'micro-stacks/network'
import { callReadOnlyFunction } from 'micro-stacks/transactions'
import { ClarityValue } from 'micro-stacks/clarity'

import { useAuth } from '../stores/useAuth'

const network = new StacksMocknet({
  url: 'http://localhost:3999',
})

const ADDRESS = 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM'
const CONTRACT = 'color-vote'

export async function readOnlyRequest<T extends ClarityValue>(
  name: string,
  args: (string | ClarityValue)[] = [],
) {
  const address = useAuth.getState().session?.addresses.testnet
  if (!address) {
    console.warn('missing address')
    return
  }

  try {
    const res = (await callReadOnlyFunction({
      network,
      contractAddress: ADDRESS,
      contractName: CONTRACT,
      functionName: name,
      functionArgs: args,
      senderAddress: address,
    })) as T

    return res
  } catch (err) {
    console.error(err)
    return null
  }
}

We will call this function in a new zustand store called useColorVote.ts. It will contain a fetchColors method. This function calls the cvToTrueValue helper. In this context: cv means Clarity Value.

Indeed, when fetching colors, the Stacks endpoint will return raw Clarity data. The callReadOnlyFunction will convert it into a understanble JS object but we still have to convert it into JS "true values". Here is a snippet to demonstrate this method:

async function clarityValueSnippet() {
  const rawColors = await readOnlyRequest('get-colors')
  deepEquals(rawColors, {
    type: 11,
    list: [
      {
        type: 7,
        value: {
          data: {
            id: { type: 1, value: 0n },
            score: { type: 1, value: 0n },
            value: { type: 13, data: 'F97316' },
          },
        },
      },
      // 3 more colors
    ],
  })

  const colors = cvToTrueValue(colors)
  deepEquals(colors, [
    {
      id: 0n, // bigint 0
      score: 0n,
      value: 'F97316',
    },
    // 3 more colors
  ])
}

As you can see, the "rawColors" object would be painful to use later in our code. While cvToTrueValue recursively browses these values and converts them to a usable JS object.

πŸ’‘ The type properties in rawColors tells what is the clarity type of a given value. You may guess what each type means. 11 represents lists, converted to arrays in JS. 7 is for "ok". 13 (which is 0d in hexadecimal) represents ascii-strings. The full list can be retrieved in micro-stacks source code.

Here is the store for ColorVote. Again, this is a pretty simple implementation, see below for a more complete one. This store only fetches colors but it will quickly have more method to cast a vote, cancel, or edit it.

./src/stores/useColorVote.ts

import create from 'zustand'
import { cvToTrueValue } from 'micro-stacks/clarity'

import { readOnlyRequest } from '../data/stacks'

export const useColorVote = create((set, get) => ({
  colors: [],

  async fetchColors() {
    const rawColors = await readOnlyRequest('get-colors')

    const colors = cvToTrueValue(rawColors)
    set({ colors })
  },
}))
Improved version with error handling a types safety

./src/stores/useColorVote.tsx

import create from 'zustand'
import { cvToTrueValue } from 'micro-stacks/clarity'

import { readOnlyRequest } from '../data/stacks'

export interface Color {
  id: bigint
  value: string
  score: bigint
}

interface ColorStore {
  colors: Color[]
  fetchColors: () => Promise<void>
}

function checkColors(colors: unknown): colors is Color[] {
  if (!Array.isArray(colors)) return false
  return colors.reduce((acc, c) => acc && c.value, true)
}

export const useColorVote = create<ColorStore>((set, get) => ({
  colors: [],

  async fetchColors() {
    const rawColors = await readOnlyRequest('get-colors')
    if (!rawColors) return

    const colors = cvToTrueValue(rawColors)
    if (checkColors(colors)) set({ colors })
  },
}))

It's looking pretty good like that. We could have written cvToTrueValue(rawColors) as Colors[] to make TypeScript happy. But it's good practice to check network responses and ensure types safety. Also, the checkColors method could be improved even further, along with error handling.

This store is now ready to be called. We want to fetch the colors early in our application but we've also seen that we need the user to be authenticated to have a valid senderAddress passed to callReadOnlyFunction.
As often, there are multiple ways to achieve it, here is one. We'll modify App.tsx to initiate the fetch.

./src/App.tsx

// add imports
import { useEffect } from 'preact/hooks'
import { useColorVote } from './stores/useColorVote'
//...

export function App() {
  const { session } = useAuth()
  useEffect(() => {
    // fetch colors without rerendering App
    if (session) useColorVote.getState().fetchColors()
  }, [session])

  // ...
}

πŸ’‘ As you can see, we are not directly calling useColorVote(). Instead, getState is called to access the fetchColors method. This way, the App component doesn't subscribe to the ColorVote hook and won't rerender on change.

We'll use SVGs to display the colors. I made a simple Circle component to which we'll pass the colors. The 4 colors options will be displayed in Vote.tsx:

./src/pages/Vote.tsx

// slightly simplified version
import { Circle } from '../components/UI/svg/Circle'
import { useColorVote } from '../stores/useColorVote'

export const Vote = () => {
  const { colors } = useColorVote()

  return colors ? (
    <div className="flex">
      {colors.map(({ id, value}) => (
        <Circle key={id} hex={value} />
      ))}
    </div>
  ) : null
}

Nothing fancy here, we invoke useColorVote to get the colors from our store and display them on our page with SVG. At this stage, you should see four circles of colors nicely aligned on the page thanks to flexbox.

To make the vote fairer, I'll randomize the order in which the colors are displayed. We won't get into details but you can check this commit to see the changes.
This way, the order of color is changed every time the page is refreshed.

Conclusion

We just learned how to fetch data from the smart contract and the web app is starting to take shape. Depending on your JS/TS level, the tutorial may have been more or less simple. I'm trying to keep it simple. You can see how important is JS in the Clarity ecosystem, whether you want to write tests for your smart contract or write a web app. Indeed, front-end skills are required in Web2 and Web3!

πŸ’» Read the code on GitHub. The source code of this article is on this branch.
There is a PR associated with this article.