Building an ERC-20 indexer app
Hello everyone! I'm back with a new blog post which is going to be about another weekly project of the Alchemy University Ethereum Developer Bootcamp. This week's project is creating an ERC-20 token indexer application and as always we have a skeleton code to start with. We, the participants of the bootcamp, are expected to implement new features to this app and make it a little bit better. As in my previous blog posts, I will write the obstacles and errors I encountered, the mistakes I made and how I managed to solve those issues. I believe the reader of this post, you, will learn from my mistakes. Let's begin!
Base Code of the Project
You can find the base code of the project in this GitHub repo. It is written with Vite and uses Chakra UI for front-end components. When you run the base code, you will see that there is an input box where you can type a wallet address and a button that fetches all the ERC-20 tokens when clicked. That's it, and it's our job to improve this app.
What is the first thing to implement? Down below.
Wallet Integration
In the current stage, this app doesn't have a feature to connect a wallet and check its token balances. So, as the first step, I wanted to add a connect wallet button and the code I wrote for this was something like that:
<Stack align="end" m={5}>
<Button variant="outline" onClick={connectWallet} size="sm" colorScheme="teal">
Connect Wallet
</Button>
</Stack>
The <stack>
and colorScheme
are coming from Chakra UI. This button is located at the top right corner of the page and simply calls the connectWallet
function which was not created yet. So let's create it. There are multiple ways to do it. For example, you can check the MetaMask docs to get some knowledge about it. I decided to use the same thing I learned while working on the previous week's project, which is something like this:
const provider = new ethers.providers.Web3Provider(window.ethereum);
async function connectWallet() {
if(!window.ethereum){
alert("MetaMask is not installed!")
}
const accounts = await provider.send('eth_requestAccounts', []);
setAccount(accounts[0]);
}
As you might know, window.ethereum
is actually MetaMask and with this code, we first check if the MetaMask is installed and then we get the current account using eth_requestAccounts
. After everything, my button was like this:
It was working but there was a problem. It was always looking like this, no matter if the wallet is connected or not. I was expecting this because I didn't write some code that checks if a wallet is connected or not before rendering the page, and I added that later. Do you remember this: {something ? () : ()}
<Stack align="end" m={5}>
{!account ? (
<Button variant="outline" onClick={connectWallet} size="sm" colorScheme="teal">
Connect Wallet
</Button>) : (
<Tag size="sm" colorScheme="teal">
Connected
</Tag>
)}
</Stack>
After this implementation, the app checks if an account is set, and if there isn't any account it renders the button which disappears when some wallet is connected, and a tag appears. Like this:
All right, that's great. I implemented the wallet connection, and at this point, I had a connect wallet button that works and connects MetaMask to my app. Now what?
Getting Balance of the Connected Wallet
In the base code of the app, we have a function, getTokenBalance()
, which is called when the "check token balances" button is clicked. This function uses the alchemy-sdk to get the token balances and token metadata. This function doesn't take any parameters but if you check the getTokenBalances endpoint you'll see it requires a parameter (address etc). Where is this parameter comes from? Yes, you guessed it: This function uses the state variable. Let me clarify:
//this variable is set with "type address" input field in the frontend
const [userAddress, setUserAddress] = useState('');
//function uses that state variable in the alchemy endpoint.
async function getTokenBalance() {
const data = await alchemy.core.getTokenBalances(userAddress);
//this is the part that renders the frontend
<Input
onChange={(e) => setUserAddress(e.target.value)}
placeholder='Please Type a Wallet Address'
/>
Okay, I think it's clear now. User types and address -> Address is set -> This variable is used inside the function to get the balances. Alright, what about getting the balances of the connected address?
That's the part where my bugs were started :D
First of all, I added a new button to check the connected wallet's balances and the last shape of this basic app was this:
Yes, I know, it's ugly. But that was not the issue at the moment, the issue was the app not working properly and I had to solve this. So, I had two buttons for different purposes and now it was time to make them work.
As I mentioned above, the getTokenBalance()
function doesn't take any arguments and uses state variables. So the first thing I thought was "Let me assign the connected account's address to the userAddress
state variable when the green button is clicked", and I wrote this:
async function getWalletBalance(){
setUserAddress(account);
await getTokenBalance();
}
This function was called when the green button is clicked and supposedly it should have changed the state and called the getTokenBalance()
function. This worked, but with a major problem :O
- I had to click two times to get the balances. It was not setting the address and getting the balances at the same time. Damn!
When I clicked the "get wallet balances" button the userAddress
was changed but the tokens were not there. When I clicked again I could see the tokens. I learned that it's not rendering the page after the state changes. I did some research and learned that I might pass a second argument as a callback function that renders the page when the state changes. It's something like this setState(account, async () => {await getTokenBalance()})
. I tried it, aaaaaand the result waaaaas:
Of course an error! Again. useState
doesn't support the second argument. useEffect
is required but that one doesn't work inside the function, it has to be in the body. That didn't work. I was frustrated but I needed to try something else.
I know, there were some logical errors in my implementation. I was trying to use only one state variable for both of the buttons and it was obvious those buttons will use different values. Anytime the user clicked the button, it had to change the variable first, then call the function with this new variable. I had to find a way that doesn't require changing the state, that uses the variable already there. I also didn't want to repeat myself and write the getTokenBalance()
function all the way again for the second button because I should be able to use it.
I have decided to change the getTokenBalance()
function to the getTokenBalance(address)
function. Only a small change, but it requires a parameter now. The rest of the function was the same. You can see previous and later versions down below.
//previous version that uses state variable
async function getTokenBalance() {
const data = await alchemy.core.getTokenBalances(userAddress); //userAddress is state variable
//new version that uses parameter
async function getTokenBalance(address) {
const data = await alchemy.core.getTokenBalances(address);
In addition to that, I changed the getWalletBalance()
function and also created a new one which I named getQueryBalance()
. These two functions don't change the state and they pass some value to call the getTokenBalance(address)
function which does the main work. Like this:
//When the green button is clicked
async function getWalletBalance(){
if(!account){
alert("Please Connect Wallet")
}
await getTokenBalance(account);
}
//When the blue button is clicked
async function getQueryBalance(){
const addr = document.getElementById('inputAddress').value;
const isAddress = ethers.utils.isAddress(addr);
const isENS = await alchemy.core.resolveName(addr);
if (!isAddress && isENS == null){
alert("Please type a valid address!");
} else {
await getTokenBalance(addr);
}
}
As you can see above, both of the functions call the getTokenBalance(address)
function by passing some address values without changing anything in the state. You can see some implementation of query inputs which I'll explain below.
Checking Input Validity & ENS Support
In the base code of this application, there was no error checking for user inputs. The userAddress
is set again and again no matter what the user types with this code:
//this variable is set with "type address" input field in the frontend
const [userAddress, setUserAddress] = useState('');
//this is the part that renders the frontend
<Input
onChange={(e) => setUserAddress(e.target.value)}
placeholder='Please Type a Wallet Address'
/>
As you can see above, every change in the input field is setting the value again. For example, if the user wants to type "vitalik.eth" in the field, the userAddress
is going to be set as "v", "vi", "vit", "vita", "vital" and so on while the user types the address. First of all, I wanted to change this and removed the onChange
event and added an "id" in this input, and also removed the const [userAddress, setUserAddress] = useState('');
The new one was like this:
<Input
id="inputAddress"
placeholder='Please Type a Wallet Address'
/>
With this, I could get the value of the input only when the button is clicked (with document.getElementById('inputAddress').value
), not every time it changes. After that, I wanted to check if the value is a proper address.
Did you know that there is a method to check this which is isAddress in the ethers library? If you check the link you will see this method returns a boolean value but it doesn't support ENS. So, how do we check if a string is an ENS that refers to an address? We have to find a way and there is no way better than reading the documentation. You can check the ENS documentation for that and you can see something like this: var address = await provider.resolveName('alice.eth');
This returns an address if the parameter actually refers to an address, and returns null otherwise. I used alchemy.core
as the provider here. I shared the code above but let me put it down here again:
//when the blue button is clicked
async function getQueryBalance(){
const addr = document.getElementById('inputAddress').value;
const isAddress = ethers.utils.isAddress(addr);
const isENS = await alchemy.core.resolveName(addr);
if (!isAddress && isENS == null){
alert("Please type a valid address!");
} else {
await getTokenBalance(addr);
}
}
I get the input value
Check if it is an address (returns true or false) (This will return false for ENS ones too)
Check if it is an ENS (returns the address or null)
If it is not an address and not an ENS, alert!
Call the
getTokenBalance(addr)
with input value.
Alright, those were the main features I implemented while improving this base app. I also added some "loading..." renders, and alerts, and also played with Chakra UI etc but I'm not going to write everything in here. I struggled with a lot of bugs and got errors multiple times but I learned so much during that process too. I hope you enjoy reading this post and thank you for sticking here up to this point. See you in the next week's project.
Take Home Messages
You can not set a new state value and use it at the same time!!!
You have to try using
useEffect
for this but you can not useuseEffect
inside another function.If you need to call the same function with different buttons/values etc, this function should get these values as parameters. Don't try to set the value inside the function.
Separate the functions if you feel stuck. Divide & Conquer.