Creating a Staking Feature with React and TypeScript

Table of contents:

  1. Intro

  2. Video Demo

  3. Tech stack used

  4. The Staking flow - how it works

  5. Components and functions used

  6. How to build a modal with React

  7. Zustand store

  8. Wagmi hooks

  9. Controlling variables

  10. Transaction Tracker

  11. Action Buttons

  12. Summary

  13. Conclusion

Intro

This article is a technical write-up about the token staking feature for the AirSwap Member Dashboard app. The majority of my time working on this app was spent on this feature.

Users can stake tokens into the AirSwap Staking smart contract, which gives them access to voting on proposals, then claim rewards from the AirSwap Pool smart contract. This article will detail the logic on how I built the staking feature.

Video demo

Tech stack used

The app is built using React, TypeScript, and TailwindCSS. All smart contract interactions use a library called Wagmi. Here’s a brief overview:

  • React is a popular JavaScript library for building user interfaces. Docs.

  • TypeScript is a superset of JavaScript that adds static typing to the language. Docs.

  • TailwindCSS is a CSS framework that allows you to write CSS classes directly into HTML, or JSX in the case of React. Some love it, some hate. I’m among those who love it. Docs.

  • Wagmi: Wagmi is a collection of React hooks that make it simple to interact with Ethereum and EVM blockchains. Docs.

The Staking flow - how it works

The screen you'll see after successfully approving AST
The screen you'll see after successfully approving AST

If you hold AirSwap Token, (AST), you can use this app to stake your tokens. Here’s the flow of how staking works:

  1. Approve: To stake tokens, first you have to approve the AirSwap Staking contract to spend your AST tokens. This is done by calling the approve function, and is a feature in most smart contracts.

  2. Stake: After you’ve approved the spending of your token, you can call the stake function. When you stake your tokens, you’ll receive sAST tokens in exchange for locking up your AST tokens in the smart contract. sAST is kind of like an IOU for AST tokens that you receive from the smart contract. It’s an IOU, but your sAST balance is also used to calculate your voting power.

  3. Unstake: Calling the unstake function lets you unlock your tokens from the smart contract. Note that when you stake AST tokens, your tokens unlock linearly over time.

Components and functions used

This section covers various components and functions in the Staking feature. Every function won’t be described here, but feel free to view all the code on GitHub if you’re curious.

How to build a modal with React

The modal is the box in the center of the screen. Clicking the X in the upper right corner, or pressing the "escape" key will close the modal.
The modal is the box in the center of the screen. Clicking the X in the upper right corner, or pressing the "escape" key will close the modal.

The Modal component in the app utilizes the HTML dialog element. The dialog element is a modern, elegant way to create a modal. It comes with built-in JavaScript methods, such as showModal and close.

To create a modal, use the JSX tag <dialog />, and pass it a ref. For the ref, we can utilize the useRef hook. useRef is a React hook that persists a value between renders. Since the value persists, it means that our modal can remain open (or closed) when we want it to.

Here’s a code sample of the Modal component:

import { useKeyboardEvent } from "@react-hookz/web";
import { useEffect, useRef } from "react";

export const Modal = ({
  // omitted code
  isClosable = true,
  onCloseRequest,
}: {
  // omitted code
  isClosable?: boolean;
  onCloseRequest: () => void;
}) => {
  const modalRef = useRef<HTMLDialogElement>(null);

  useKeyboardEvent("Escape", () => {
    if (!isClosable) return;
    onCloseRequest && onCloseRequest();
    modalRef.current?.close();
  });

  useEffect(() => {
    if (modalRef.current && !modalRef.current.hasAttribute("open")) {
      modalRef.current.showModal();
    }
  }, [modalRef]);

  return (
    <dialog ref={modalRef} >
    // omitted code
    </dialog>
  );
};

In the code above, first we create a ref object called modalRef, which gets passed into the dialog. We also have a hook called useKeyboardEvent, which gets returned from the react-hookz library. useKeyboardEvent returns a callback function that closes the modal when the user presses the “escape” key.

Note that close is a method which gets called on modalRef. Since the dialog node has a ref of modalRef, the ref object now can access the JavaScript functions that are associated with the HTML dialog element, and pass it along to the dialog.

Clicking the blue “STAKING” button in the header opens the staking modal.
Clicking the blue “STAKING” button in the header opens the staking modal.

The useEffect hook checks whether the dialog is open. The Modal component opens when a user clicks on a button in another component, but I’ve omitted that code from this blog post. Opening the Modal component would give it an attribute of open.

Above is a screenshot of HTML if you log console.log(modalRef.current) in dev tools. Note how dialog has the attribute open.
Above is a screenshot of HTML if you log console.log(modalRef.current) in dev tools. Note how dialog has the attribute open.

Zustand store

Zustand is a lightweight alternative to Redux. We chose Zustand over Redux because the app is relatively small in size. Redux would’ve been overkill for our needs on this app.

We store values in the Zustand store so these values persist across component re-renders and various stages of the staking cycle. It’s possible to use React “prop-drilling” to pass values from parent to children components, but using Zustand produces cleaner code.

Another cool feature of Zustand is that it comes with persist middleware. This middleware makes it easy to “persist” data into local storage. This middleware isn’t used for the staking modal, but some other features in the app utilize it.

Wagmi hooks

In the Staking feature, there are main 4 custom hooks which call Wagmi hooks:

  1. useApproveAst.ts - calls smart contract “write” function to approve the spending of AST token

  2. useAstAllowance.ts - calls a smart contract “read” function to return the amount of AST tokens held in a connected wallet

  3. useStakeAst.ts - calls smart contract “write” function to stake AST

  4. useUnstakeSast.ts - calls smart contract “write” function to unstake AST

These hooks are mostly similar, so for brevity I’ll only cover useStakeAst in detail.

Staking hook - useStakeAst

This hook returns a function that users can use to stake tokens. It calls 3 Wagmi hooks:

  1. usePrepareContractWrite. This hook prepares an object called config. This object later gets passed into useContractWrite, which is explained below. usePrepareContractWrite accepts several arguments:

    1. Address - the smart contract address of the contract you want to use.

    2. ABI - an interface of the smart contract.

    3. Function - the function on the ABI you want to call. In our case, stake.

    4. Arguments - an optional array of arguments to be passed into the function want to call.

    5. Enabled - an optional boolean value. If false, the function will not run. Using this can optimize the performance of the app. For this purpose, variable called canStake was used. canStake is a boolean value that checks that the transaction type is “stake”, that needsApproval is false, and that the input the user entered is valid.

  2. useContractWrite - This hook is used to call smart contract functions. It returns several values that were utilized:

    1. write - this is the function that writes to the blockchain when it gets called. When a user clicks the “stake” button, this “write” function gets called.

    2. data - this object returns data about the transaction after write gets called. It contains useful info such as the transaction hash.

    3. reset- After a transaction completes, the status of the transaction will persist until it changes, or the user refreshes the page. The code will look like this: status === 'success'. Calling reset will reset the transaction status and prevent UX pitfalls.

  3. useWaitForTransaction - we use this hook primarily to get the status of a transaction, and its transaction hash.

    • status can either be: “error”, “idle”, “success”, or “loading. These values change during the course of a transaction’s lifetime.

    • data is a return object that contains a transaction hash. We use the transaction has to link users to an etherscan link with their transaction data.

Clicking on the "View on Etherscan" link opens an Etherscan transaction link
Clicking on the "View on Etherscan" link opens an Etherscan transaction link

Controlling variables

The transaction tracker conditionally renders a variety of things. Much of it is determined by the variables below.
The transaction tracker conditionally renders a variety of things. Much of it is determined by the variables below.

Several variables used in the main StakingModal component are used to control the flow of certain hooks. For example, the following boolean variables: needsApproval, canStake, and canUnstake. If needsApproval is true, the hook useApproveAst will be enabled, and the hook useStakeAst will be disabled. For canStake to be true, needsApproval must be false.

Here are a few code snippets with explanations below:

const needsApproval =
    txType === TxType.STAKE &&
    Number(astAllowance) < Number(stakingAmountFormatted) * 10 ** 4 &&
    validNumberInput;

const canStake =
  txType === TxType.STAKE && !needsApproval && validNumberInput;

const {
    writeAsync: approveAst,
    data: dataApproveAst,
    reset: resetApproveAst,
    isLoading: approvalAwaitingSignature,
  } = useApproveAst({
    stakingAmountFormatted: Number(stakingAmountFormatted) || 0,
    enabled: needsApproval,
  });

Check if a user can approve tokens or not:

needsApproval is a boolean value with a few checks. The user must toggle the “stake” option, the user’s allowance must be less than the amount they entered to stake, and the number inputted to the form must be valid.

Transaction Type

Let’s look at the following code: txType === TxType.STAKE. The txType variable is stored in the Zustand store. (Code is not shown above). The value of txType changes when you click on the STAKE/UNSTAKE toggle.

Clicking the "STAKE" option will toggle txType so it's equal to txType.STAKE
Clicking the "STAKE" option will toggle txType so it's equal to txType.STAKE

TxType is a TypeScript enum with the following shape:

export enum TxType {
  STAKE = "stake",
  UNSTAKE = "unstake",
}

Check if a user can stake or not:

canStake checks that the user has toggled the “stake” option, that needsApproval is false, and that a valid number has been inputted into the staking form.

Loading transaction variable:

If txIsLoading is true, the screen above might render.
If txIsLoading is true, the screen above might render.
const txIsLoading =
  approvalAwaitingSignature ||
  stakeAwaitingSignature ||
  unstakeAwaitingSignature ||
  txStatus === "loading";

This variable is a boolean that changes depending on the current transaction status. This value gets passed into the Modal component as a prop called isClosable. If isClosable is true, the close button in the modal will be disabled.

<Modal
  className="w-full max-w-none xs:max-w-[360px] text-white"
  heading={modalLoadingStateHeadlines}
  isClosable={!txIsLoading}
  onCloseRequest={() => setShowStakingModal(false)}
>

Above is a code snippet that shows the Modal component accepting txIsLoading as a prop for the isClosable variable

We want to disable the close button when a transaction is processing because closing the modal will make a user confused about the status of an active blockchain transaction. It’s bad UX when this happens. Blockchain users usually want a status update on their transaction and will often look at their screen until they see a “success” (or “failed”) confirmation of a completed transaction.

Transaction Tracker

The Transaction Tracker takes many forms. This is the tracker after a successful staking transaction.
The Transaction Tracker takes many forms. This is the tracker after a successful staking transaction.

This component was designed to be as generic as possible and to display the current state of transaction data. It’s used for the staking feature, as well as the claims feature (which will not be covered in this post).

The tracker takes in several props, including a transaction hash. This is a hash that gets passed into the Wagmi hook useWaitForTransaction, which returns the status of a transaction. The status is either: idle, loading, successful, or failed. The values of these status variables are used to determine which text and images to display in the transaction tracker. This provides users with feedback on their current stage in the staking and unstaking process.

Action Buttons

The staking modal has 1 main button to handle user actions. These actions include: approve, stake, unstaking. There’s also a toggle where users can switch between staking and unstaking.

actionButtonsObject is a function that takes in 4 arguments and returns an object. The return object takes the shape of ActionButton, which returns button labels and callback functions. 3 of these arguments are functions that were returned from Wagmi hooks.

type ActionButton = {
  afterSuccess: { label: string; callback: () => void };
  afterFailure: { label: string; callback: () => void };
}; 

Here are some examples of how the button would change:

  • After successfully calling the “approve” function, the button label will read “Continue”, and clicking on it would call the function resetApproveAst, which resets the status of the function. At this point, the user is permitted to stake tokens.

  • After a failed attempt at calling the "approve" function, the button label will read “Try again”, and clicking on it would also call resetApproveAst. The status of the approval function will reset, but the user will still be required to approve token spending before staking is allowed.

After successfully approving, the button says “CONTINUE”. Clicking that button would call the resetApproveAst function.
After successfully approving, the button says “CONTINUE”. Clicking that button would call the resetApproveAst function.
After clicking the “CONTINUE” button in the previous screenshot, the staking modal will appear as shown above. The approved amount (20 AST) will persist in the form, allowing for a seamless user experience when staking the tokens by clicking “STAKE”.
After clicking the “CONTINUE” button in the previous screenshot, the staking modal will appear as shown above. The approved amount (20 AST) will persist in the form, allowing for a seamless user experience when staking the tokens by clicking “STAKE”.

The staking modal component also has a function called actionButtonLogic. This checks the status of various staking actions and returns a value based on which staking action is true. The return value of actionButtonLogic gets passed as props into the TransactionTracker component. The value that gets passed into the Transaction Tracker is what the user will see on the button.

The following code snippet shows how the TransactionTracker component is called in the StakingModal component.

<TransactionTracker
  actionButtons={actionButtonLogic()}
  successContent={
    <span>
      You successfully {verb}{" "}
      <span className="text-white">{stakingAmountFormatted} AST</span>
    </span>
  }
  failureContent={"Your transaction has failed"}
  signatureExplainer={
    isApproval
      ? "To stake AST you will first need to approve the token spend."
      : undefined
    }
    txHash={currentTransactionHash}
  />

signatureExplainer is a prop in TransactionTracker. It pops up when a user has initiated a transaction, but has not yet signed the transaction in his or her wallet (for example MetaMask). The explainer is used to tell the user the next steps in the Staking flow.

This screen will appear when a user's MetaMask wallet is open, but the transaction hasn't yet been signed.
This screen will appear when a user's MetaMask wallet is open, but the transaction hasn't yet been signed.

Summary

Here’s a video demo of the staking flow in action:

This article covers many of the functions that make up the staking modal, but there are several details I skipped over. Dive into the code on GitHub to see the rest of it. Here’s a high-level recap of how the staking modal works:

  • First, a user must approve the smart contract to spend AST tokens. In the StakingModal component, the boolean variable needsApproval checks whether or not a user can approve.

  • If needsApproval is true, the custom hook useApproveAst is enabled. Now, when a user clicks the “Approve” button in the TransactionTracker component, useApproveAst returns a callback function that writes the "approve" transaction to the blockchain.

  • After successfully approving, needsApproval should be set to false. Now the boolean value canStake determines whether a user can stake tokens or not.

  • If canStake is true, the custom hook useStakeAst will return the Wagmi callback function stakeAst (the original function name returned from Wagmi is writeAsync, but I renamed it in the staking Modal component). This process is similar to the token spending approval process, and unstaking tokens is similar to the token staking process.

  • useApproveAst, useStakeAst, useUnstakeSast all return objects called data, which contain a transaction hash (or undefined). These hooks also return the status of transactions: "idle", "loading", "success", or "failed".

  • These status variables and transaction hashes are passed into the TransactionTracker component, and give users feedback on their transactions.

The AirSwap Member Dashboard has more features than Staking, but I want to keep this blog post specific and won’t go into the other features today.

Users can also vote on proposals that affect the AirSwap protocol.
Users can also vote on proposals that affect the AirSwap protocol.

Conclusion

Developing this Staking feature was one of my most challenging projects. I did countless refactors and put in many hours of work to make sure it came out as expected. I was fortunate to work alongside a more senior developer on this app. The guidance and mentorship were priceless.

Here’s an incomplete list of some of my learning lessons from building this feature:

  • It’s almost impossible to refactor code too much. Continuously refactoring code can seem tedious, but in the end, it makes you a more skilled developer.

  • Often the cause of bugs is having too much code, rather than too little. If there are bugs that are hard to fix, it can help to remove code until there aren’t any bugs. Once you reach the point of having no bugs, add in code until you can isolate exactly what was causing the original bugs.

  • Working with a mentor (or mentee) is priceless. Even if you’re building stuff that works, it’s nice to get other perspectives and hear of ways to improve your code.

  • Conducting code reviews for others can enhance your learning by providing insights into their coding structure.

I take pride in the work I've accomplished on this app, and I hope this blog post effectively conveys my enthusiasm during its development. Feel free to explore the app and test the staking feature. If you're interested, drop me a message for some Goerli AST to try it out. While there are areas for optimization, I am open to any feedback you may have

If you’ve read this far, I’d like to thank you for your attention.

GitHub: https://github.com/airswap/airswap-voter-rewards.

Demo: http://dao.airswap.eth.limo.

Subscribe to starrdev
Receive the latest updates directly to your inbox.
Mint this entry as an NFT to add it to your collection.
Verification
This entry has been permanently stored onchain and signed by its creator.