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

Send transactions to the smart contract

At this stage, the web app can read data by calling read-only functions. The next step is to write data through public functions. In this article, we'll create the voting form and send the voters' choices to the contract vote function.

Here is what we are building in this article:

Vote Form UI Screenshot

Call the Smart Contract in JS / TS

Remember our readOnlyRequest abstraction in data/stacks.js? We'll create a similar one to call public functions that write data.
Since the TS and JS versions are quite the same, here is directly the TS version.

./src/data/stacks.ts

// add this imports
import type { ClarityValue } from 'micro-stacks/clarity'
import {
  makeContractCallToken,
  openTransactionPopup,
} from 'micro-stacks/connect'
// ...
// app details is required for the popup
import { useAuth, appDetails } from '../stores/useAuth'

export async function callContract(name: string, args: ClarityValue[] = []) {
  const key = useAuth.getState().session?.appPrivateKey
  const address = useAuth.getState().session?.addresses.testnet
  if (!key || !address) return Promise.reject(new Error())

  const token = await makeContractCallToken({
    appDetails,
    network,
    contractAddress: ADDRESS,
    contractName: CONTRACT,
    functionName: name,
    functionArgs: args,
    privateKey: key,
    stxAddress: address,
  })

  return new Promise<string>((resolve, reject) =>
    openTransactionPopup({
      token,
      onFinish: (payload) => resolve(payload.txId),
    }),
  )
}

As you can see, makeContractCallToken is quite similar to callReadOnlyFunction. It takes mostly the same arguments. You also have to add appDetails that will be displayed in the transaction popup. It also takes a private key retrieved in the user session. In a future article, we will explore more complex calls with arguments and post-conditions (useful for transactions validation).

makeContractCallToken won't perform any action on its own. It will generate a token – as the name suggests – that can be passed to openTransactionPopup.

πŸ’‘ The token is a JWT. it's a way to encode JSON data into a string and sign it with a secret key. Have a look at this package if you want to know more about it.

We will now go back to the useColorVote store and make use of this new function. The basic implementation will be super simple. I'll also provide a more complete one with better typing.

To handle the sender's vote values, we'll add a vote Map to store the value of each color. Maps are simple key-value stores and the key can be anything. In our case, it will be the IDs of the vote options (which are BigInts).

./src/stores/useColorVote.ts

import { cvToTrueValue, uintCV } from 'micro-stacks/clarity'

const ids = [0n, 1n, 2n, 3n] as const
const getInitialVote = () => new Map(ids.map((id) => [id, undefined]))

export const useColorVote = create<ColorStore>((set, get) => ({
  vote: getInitialVote(),
  // ...
  async sendVote() {
    const { vote } = get()
    const senderVote = ids.map((id) => vote.get(id))

    // the array of numbers is converted into an array of clarity values
    await callContract('vote', senderVote.map(uintCV))
  },
}))

πŸ’‘ uintCV converts a number into an unsigned integer Clarity Values uintCV(42) => { type: 1, value: 42n }

TypeScript and safer version

The big difference here is the isValueValid method and the strict typing of a valid vote value.

./src/stores/useColorVote.ts

import { cvToTrueValue, uintCV } from 'micro-stacks/clarity'

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

type ValidValue = 0 | 1 | 2 | 3 | 4 | 5
type Vote = undefined | ValidValue

interface ColorStore {
  colors: Color[]
  vote: Map<BigInt, Vote>
  updateVote: (id: bigint, vote: number) => void
  fetchColors: () => Promise<void>
  sendVote: () => Promise<void>
}

const ids = [0n, 1n, 2n, 3n] as const
const getInitialVote = () => new Map(ids.map((id) => [id, undefined]))

function isValueValid(value: unknown): value is ValidValue {
  if (value === undefined || isNaN(Number(value))) return false
  return Number(value) >= 0 && Number(value) <= 5
}

export const useColorVote = create<ColorStore>((set, get) => ({
  // ...
  vote: getInitialVote(),

  async sendVote() {
    const { vote } = get()
    const senderVote = ids.map((id) => vote.get(id))
    if (!senderVote.every(isValueValid)) return

    await callContract('vote', senderVote.map(uintCV))
  },
}))

One last method is needed in this store to update the vote values. I named it updateVote, it takes two arguments, the color ID and the value of the vote. The method makes sure that the value is between 0 and 5 and then updates the votes map.

./src/stores/useColorVote.ts

export const useColorVote = create<ColorStore>((set, get) => ({
  // ...
  updateVote(id, inputValue) {
    const value = inputValue > 5 ? 5 : inputValue < 0 ? 0 : inputValue

    // Map.protype.set returns
    set((state) => ({ vote: state.vote.set(id, value) }))
  },
}))

πŸ‘‰ In the TS version you also need to call isValueValid function to ensure type safety (or use as Vote).

At this stage, you can check the useColorVote.ts file on the repo if you want to see the sendVote and updateVote.

The vote store is now complete and ready to write data on the blockchain.

Build the UI and the form

For completeness, let's take some time to see how to create the voting form. To keep things clean I made a VoteInput component that you can build or get on GitHub. Open Vote.tsx and import the VoteInput in it as well as the Button component.

./src/pages/Vote.tsx

// <imports>

export const Vote = () => {
  const { colors, vote, updateVote, sendVote } = useColorVote()

  const handleSubmit = (e) => {
    e.preventDefault()
    sendVote()
  }

  return colors ? (
    <form onSubmit={handleSubmit}>
      <div className="flex">
        {colors.map((c) => (
          <div key={c.id} className="w-full">
            <Circle hex={c.value} />
            <VoteInput
              id={c.id.toString()}
              value={vote.get(c.id)}
              onInput={(e) => updateVote(c.id, e.currentTarget.valueAsNumber)}
            />
          </div>
        ))}
      </div>
      <Button type="submit">Vote</Button>
    </form>
  ) : null
}

The vote input directly calls updateVote, while the form calls an intermediary function on submit that calls sendVote. To improve the form, we could add a reset button, only make the submit button active if the vote is valid, add a loading state, and other things that you can come up with.
You can see these improvements on the GitHub repo. Some UI improvements would be more than welcome as well.

Display the transaction status

If a person casts a vote and immediately refreshes the page, they won't see that they already vote. It's because transactions can take a few minutes. Hopefully, we can retrieve the transaction and get its status, which can be success, in progress or failed.

Each transaction has a unique ID, which is returned by the contractCall function. We will update the sendVote function to save the txId in the local storage to retrieve it on page refresh.

./src/stores/useColorVote.ts

import { fetchTransaction } from 'micro-stacks/api'
// you'll need to export `network` from stacks.ts and import here
import { callContract, network, readOnlyRequest } from '../data/stacks'

export const useColorVote = create((set, get) => ({
  colors: null,
  txId: localStorage.getItem('txId'),
  lastTx: null,

  // ...

  async sendVote() {
    const { vote } = get()
    const senderVote = ids.map((id) => vote.get(id))

    const txId = await callContract('vote', senderVote.map(uintCV))

    // save the txId in `sendVote`
    localStorage.setItem('txId', txId)
    set({ txId })
  },

  async fetchLastTx() {
    const { txId } = get()
    if (!txId) return

    const tx = await fetchTransaction({
      url: network.getCoreApiUrl(),
      txid: txId,
    })
    set({ lastTx: tx })
  },
}))
Again, here the full TS version with error handling

You'll need to install the @stacks/stacks-blockchain-api-types package because micro-stacks does not expose some types at the moment.

./src/stores/useColorVote.ts

import { fetchTransaction } from 'micro-stacks/api'
import type {
  ContractCallTransaction,
  MempoolContractCallTransaction,
} from '@stacks/stacks-blockchain-api-types'

import { callContract, network, readOnlyRequest } from '../data/stacks'
// ...

type VoteTx = ContractCallTransaction | MempoolContractCallTransaction

interface ColorStore {
  // ...
  txId: string | null
  lastTx: VoteTx | null
  fetchLastTx: () => Promise<void>
}
//...
export const useColorVote = create<ColorStore>((set, get) => ({
  txId: localStorage.getItem('txId'),
  lastTx: null,
  // ...
  async sendVote() {
    const { vote } = get()
    const senderVote = ids.map((id) => vote.get(id))
    if (!senderVote.every(isValueValid)) return

    const txId = await callContract('vote', senderVote.map(uintCV))
    localStorage.setItem('txId', txId)
    set({ txId })
  },

  async fetchLastTx() {
    const { txId } = get()
    if (!txId) return

    try {
      const tx = (await fetchTransaction({
        url: network.getCoreApiUrl(),
        txid: txId,
      })) as VoteTx | { error: string }
      if ('error' in tx) throw new Error('tx error')
      set({ lastTx: tx })
    } catch (err) {
      set({ txId: null })
      localStorage.removeItem('txId')
    }
  },
}))

Some useful data can be retrieved in the transactions (or "tx"). Especially the status and the arguments.
Create a file LastVote.tsx in componenents. We'll call fetchLastTx inside a useEffect to retrieve the transaction and some relevant informations. You can add this components at this end of the Vote.tsx page.

./src/components/LastVote.tsx

import { useEffect } from 'preact/hooks'

import { useColorVote } from '../stores/useColorVote'
import { H2 } from './UI/Typography'

export function LastVote() {
  const { txId, lastTx, fetchLastTx } = useColorVote()
  useEffect(() => {
    fetchLastTx()
  }, [txId])

  return lastTx ? (
    <div>
      <H2>Previous vote</H2>
      <p>
        <b>Status</b>: <span className="capitalize">{lastTx.tx_status}</span>
      </p>
      <ul className="flex gap-3">
        {lastTx.contract_call.function_args?.map((v) => (
          <li>
            <span className="font-bold capitalize">{v.name}</span>:{' '}
            {v.repr.replace('u', '')}
          </li>
        ))}
      </ul>
    </div>
  ) : null
}

Conclusion

You now know how to call read-only/public functions on the Stacks blockchain and to fetch transactions data, which already opens a lot of possibilities.
We've also seen how to pass an array of arguments when making a call. Don't forget to transform the JS values into Clarity values.

In the next article, we'll bring some last improvements such as retrieving a vote and canceling or updating it. There will be nothing new but a good opportunity to review what you learned.

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