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.
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.
If you hold AirSwap Token, (AST), you can use this app to stake your tokens. Here’s the flow of how staking works:
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.
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.
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.
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.
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
.
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
.
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.
In the Staking feature, there are main 4 custom hooks which call Wagmi hooks:
useApproveAst.ts - calls smart contract “write” function to approve the spending of AST
token
useAstAllowance.ts - calls a smart contract “read” function to return the amount of AST
tokens held in a connected wallet
useStakeAst.ts - calls smart contract “write” function to stake AST
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.
useStakeAst
This hook returns a function that users can use to stake tokens. It calls 3 Wagmi hooks:
usePrepareContractWrite
. This hook prepares an object called config
. This object later gets passed into useContractWrite
, which is explained below. usePrepareContractWrite
accepts several arguments:
Address - the smart contract address of the contract you want to use.
ABI - an interface of the smart contract.
Function - the function on the ABI you want to call. In our case, stake
.
Arguments - an optional array of arguments to be passed into the function want to call.
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.
useContractWrite
- This hook is used to call smart contract functions. It returns several values that were utilized:
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.
data
- this object returns data about the transaction after write
gets called. It contains useful info such as the transaction hash.
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.
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.
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.
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:
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.
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.
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.
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.
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.
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.