Skip to main content

How to Create a Token Allowance Checker App

Updated on
Sep 13, 2024

24 min read

Overview

While making transactions in DeFi, whether it's swapping, staking, etc., you approve your ERC-20 tokens for a smart contract, allowing the smart contract to spend tokens for you. Most of the time, smart contracts approve way more tokens than required for that particular transaction to save you future gas fees, but this can be a security risk as you approve a large number of tokens. So, let's create a token allowance/approval checker app where you or anyone can check token allowances for a given wallet address for various tokens along with your allowance history.

Subscribe to our YouTube channel for more videos!

Token Allowance Checker App

Step 1: Creating a QuickNode Stream

We will need to create index allowance events by filtering logs for various blocks; now, since we need to get historical allowances as well, we will need to get the data from the Genesis block.

Create a QuickNode account if you don't have one already and create a Stream. Once you click on the link, the dataset field with Logs dataset and the filter code will be prefilled in your Stream configuration.

Now, for Stream start select Block # and enter block 0 or 1 as we will need to index the chain from genesis, and keep the Stream end as unchecked as we'll need the latest block data as its created.

This is what the filter code will look like:

function extractAddress(paddedAddress) {
if (typeof paddedAddress !== 'string' || paddedAddress.length < 40) {
console.error('Invalid address format:', paddedAddress);
return null;
}
return '0x' + paddedAddress.slice(-40);
}

function hexToDecimal(hexValue) {
if (typeof hexValue !== 'string') {
console.error('Invalid hex value:', hexValue);
return '0';
}
// Remove '0x' prefix if present
hexValue = hexValue.replace(/^0x/, '');
// If the resulting string is empty, return '0'
if (hexValue === '') {
return '0';
}
// Parse the hex string to a BigInt (to handle large numbers)
try {
return BigInt('0x' + hexValue).toString();
} catch (error) {
console.error('Error converting hex to decimal:', error);
return '0';
}
}

function findAndFilterLogs(data) {
const targetTopic = "0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925";
let results = [];
let signatureFound = false;

function search(item) {
if (Array.isArray(item)) {
item.forEach(search);
} else if (typeof item === 'object' && item !== null) {
if (item.topics && item.topics[0] === targetTopic) {
signatureFound = true;
try {
const wallet = extractAddress(item.topics[1]);
const spender = extractAddress(item.topics[2]);
if (wallet && spender) {
results.push({
token: item.address,
wallet: wallet,
spender: spender,
amount: hexToDecimal(item.data),
blockNumber: hexToDecimal(item.blockNumber),
transactionHash: item.transactionHash
});
}
} catch (error) {
console.error('Error processing log entry:', error);
}
} else {
Object.values(item).forEach(search);
}
}
}

search(data);
return signatureFound ? results : null;
}

function main(data) {
const filteredData = findAndFilterLogs(data);
console.log(JSON.stringify(filteredData, null, 2));
return filteredData;
}

In this code snippet, we are filtering the logs for a particular token allowance/approval event, Approval (index_topic_1 address owner, index_topic_2 address spender, uint256 value) and we are doing that by finding events in logs for signature 0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925 , which is basically the encoded/hashed format of the Approval event. Along with filtering for the event, we are also making addresses and block numbers more readable and returning a wealth of useful information like token, wallet, spender, amount, blockNumber, and transactionHash.

We filter the Stream data before sending it to a destination because we only want specific data and do not need to deal with unwanted data. With Streams, you only pay for whatever data is streamed, so win++.

Now, click on Next and select PostgreSQL as the destination type and fill your Postgres instance's configuration details. In the Table field write a table name, this table will be created by Streams and the data will be streamed to that table.

Step 2: Create a pgSQL function

We will first create a new table with columns wallet, approval_history, latest_approvals, and last_updated.

Then we will use the following function to further refine the data from the table created by Streams

CREATE OR REPLACE FUNCTION process_wallet_approval_states_latest()
RETURNS void AS $$
DECLARE
-- make sure to add your table's name instead of token_allowances_new
cur CURSOR FOR SELECT * FROM token_allowances_new ORDER BY block_number ASC;
row_data RECORD;
allowance_events JSONB;
allowance_event JSONB;
v_wallet_address VARCHAR(42);
approval_data JSONB;
total_rows INT;
processed_rows INT := 0;
skipped_rows INT := 0;
BEGIN
SELECT COUNT(*) INTO total_rows FROM token_allowances_new;
RAISE NOTICE 'Starting to process % rows', total_rows;

FOR row_data IN cur LOOP
BEGIN
IF jsonb_typeof(row_data.data) = 'array' THEN
allowance_events := row_data.data;
ELSE
-- If it's not an array, wrap it in an array
allowance_events := jsonb_build_array(row_data.data);
END IF;

FOR allowance_event IN SELECT jsonb_array_elements(allowance_events)
LOOP
BEGIN
v_wallet_address := allowance_event->>'wallet';
IF v_wallet_address IS NULL THEN
RAISE EXCEPTION 'Wallet address is null';
END IF;

approval_data := jsonb_build_object(
'token', allowance_event->>'token',
'spender', allowance_event->>'spender',
'amount', allowance_event->>'amount',
'blockNumber', (allowance_event->>'blockNumber')::bigint,
'transactionHash', allowance_event->>'transactionHash'
);

-- Insert or update the wallet_approval_states table
-- make sure to add your table's name instead of wallet_approval_states_latest
INSERT INTO wallet_approval_states_latest (wallet_address, approval_history, latest_approvals)
VALUES (
v_wallet_address,
ARRAY[approval_data],
jsonb_build_object(
concat(approval_data->>'token', ':', approval_data->>'spender'),
approval_data
)
)
ON CONFLICT (wallet_address)
DO UPDATE SET
approval_history = wallet_approval_states_latest.approval_history || ARRAY[approval_data],
latest_approvals =
CASE
WHEN (approval_data->>'blockNumber')::bigint > COALESCE((wallet_approval_states_latest.latest_approvals->>(concat(approval_data->>'token', ':', approval_data->>'spender')))::jsonb->>'blockNumber', '0')::bigint
THEN wallet_approval_states_latest.latest_approvals || jsonb_build_object(concat(approval_data->>'token', ':', approval_data->>'spender'), approval_data)
ELSE wallet_approval_states_latest.latest_approvals
END,
last_updated = CURRENT_TIMESTAMP
WHERE wallet_approval_states_latest.wallet_address = v_wallet_address;

EXCEPTION
WHEN OTHERS THEN
RAISE WARNING 'Error processing event in row % (block %): %', processed_rows, row_data.block_number, SQLERRM;
END;
END LOOP;

processed_rows := processed_rows + 1;
IF processed_rows % 100 = 0 THEN
RAISE NOTICE 'Processed % out of % rows', processed_rows, total_rows;
END IF;
EXCEPTION
WHEN OTHERS THEN
skipped_rows := skipped_rows + 1;
RAISE WARNING 'Error processing row % (block %): %', processed_rows, row_data.block_number, SQLERRM;
END;
END LOOP;

RAISE NOTICE 'Finished processing % rows. Skipped % rows.', processed_rows, skipped_rows;
END;
$$ LANGUAGE plpgsql;

Note: In the above function, the table created by Streams is token_allowances_new and another table with refined data is wallet_approval_states_latest.

Step 3: React app and Express server

Find the React app and Express server code in this directory of sample apps GitHub mono repository.

Here we have the React app in the src directory, an environment file, and a server.js for the Express server.

The React app has a component WalletApprovalSearch.js, which takes a wallet address from user, gets the approval data from Express server, gets token data from their smart contracts using QuickNode RPC and then displays it.

import React, { useState } from 'react';
import { ethers } from 'ethers';
import axios from 'axios';
import {
TextField, Button, Table, TableBody, TableCell,
TableContainer, TableHead, TableRow, Paper, Modal,
Box, Typography, Container, CircularProgress
} from '@mui/material';

const WalletApprovalSearch = () => {
const [walletAddress, setWalletAddress] = useState('');
const [approvalData, setApprovalData] = useState(null);
const [historyData, setHistoryData] = useState(null);
const [isHistoryModalOpen, setIsHistoryModalOpen] = useState(false);
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);

const handleSearch = async () => {
setIsLoading(true);
setError('');
setApprovalData(null);
setHistoryData(null);

try {
const response = await axios.get(`http://localhost:3001/api/wallet-approval-states/${walletAddress}`);
const { latest_approvals, approval_history } = response.data;

if (latest_approvals) {
const processedApprovals = await processApprovals(latest_approvals);
setApprovalData(processedApprovals);
}

if (approval_history) {
const historyObject = typeof approval_history === 'string'
? JSON.parse(approval_history)
: approval_history;
const processedHistory = await processApprovals(historyObject);
setHistoryData(processedHistory);
}

if (!latest_approvals && !approval_history) {
setError('No approval data found for this wallet address.');
}
} catch (error) {
console.error('Error fetching data:', error);
setError('Error fetching data. Please try again. ' + (error.response?.data?.error || error.message));
} finally {
setIsLoading(false);
}
};

const processApprovals = async (approvals) => {
const provider = new ethers.JsonRpcProvider(process.env.REACT_APP_QUICKNODE_ENDPOINT);

return Promise.all(Object.entries(approvals).map(async ([key, approval]) => {
const tokenContract = new ethers.Contract(approval.token, [
'function name() view returns (string)',
'function symbol() view returns (string)',
'function decimals() view returns (uint8)',
], provider);

try {
const [name, symbol, decimals] = await Promise.all([
tokenContract.name().catch(() => 'Unknown'),
tokenContract.symbol().catch(() => 'UNK'),
tokenContract.decimals().catch(() => 18),
]);

const normalizedAmount = ethers.formatUnits(approval.amount, decimals);

return {
...approval,
tokenName: name,
tokenSymbol: symbol,
normalizedAmount,
};
} catch (error) {
console.warn(`Error processing token ${approval.token}:`, error);
return {
...approval,
tokenName: 'Unknown',
tokenSymbol: 'UNK',
normalizedAmount: ethers.formatUnits(approval.amount, 18), // assume 18 decimals
};
}
}));
};

return (
<Container maxWidth="lg">
<Box sx={{ my: 4 }}>
<Typography variant="h4" component="h1" gutterBottom>
Wallet Approval Search
</Typography>
<Box sx={{ display: 'flex', gap: 2, mb: 2 }}>
<TextField
fullWidth
label="Wallet Address"
variant="outlined"
value={walletAddress}
onChange={(e) => setWalletAddress(e.target.value)}
/>
<Button variant="contained" onClick={handleSearch} disabled={isLoading}>
{isLoading ? <CircularProgress size={24} /> : 'Search'}
</Button>
</Box>

{error && (
<Typography color="error" sx={{ mb: 2 }}>
{error}
</Typography>
)}

{isLoading && (
<Box sx={{ display: 'flex', justifyContent: 'center', my: 2 }}>
<CircularProgress />
</Box>
)}

{!isLoading && approvalData && approvalData.length > 0 && (
<>
<Typography variant="h6" gutterBottom>
Latest Approvals
</Typography>
{approvalData.some(approval => approval.tokenName === 'Unknown') && (
<Typography color="warning" sx={{ mb: 2 }}>
Warning: Some token information could not be retrieved. These tokens are marked as 'Unknown'.
</Typography>
)}
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>Token Name</TableCell>
<TableCell>Token Symbol</TableCell>
<TableCell>Amount</TableCell>
<TableCell>Spender</TableCell>
<TableCell>Block Number</TableCell>
</TableRow>
</TableHead>
<TableBody>
{approvalData.map((approval, index) => (
<TableRow key={index}>
<TableCell>{approval.tokenName}</TableCell>
<TableCell>{approval.tokenSymbol}</TableCell>
<TableCell>{approval.normalizedAmount}</TableCell>
<TableCell>{approval.spender}</TableCell>
<TableCell>{approval.blockNumber}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>

{historyData && historyData.length > 0 && (
<Button onClick={() => setIsHistoryModalOpen(true)} sx={{ mt: 2 }}>
View History
</Button>
)}
</>
)}

{!isLoading && approvalData !== null && approvalData.length === 0 && (
<Typography>No records found</Typography>
)}

<Modal
open={isHistoryModalOpen}
onClose={() => setIsHistoryModalOpen(false)}
>
<Box sx={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: '80%',
maxWidth: 800,
bgcolor: 'background.paper',
boxShadow: 24,
p: 4,
maxHeight: '90vh',
overflow: 'auto',
}}>
<Typography variant="h6" gutterBottom>Approval History</Typography>
{historyData && historyData.some(approval => approval.tokenName === 'Unknown') && (
<Typography color="warning" sx={{ mb: 2 }}>
Warning: Some historical token information could not be retrieved. These tokens are marked as 'Unknown'.
</Typography>
)}
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>Token Name</TableCell>
<TableCell>Token Symbol</TableCell>
<TableCell>Amount</TableCell>
<TableCell>Spender</TableCell>
<TableCell>Block Number</TableCell>
</TableRow>
</TableHead>
<TableBody>
{historyData && historyData.map((approval, index) => (
<TableRow key={index}>
<TableCell>{approval.tokenName}</TableCell>
<TableCell>{approval.tokenSymbol}</TableCell>
<TableCell>{approval.normalizedAmount}</TableCell>
<TableCell>{approval.spender}</TableCell>
<TableCell>{approval.blockNumber}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<Button onClick={() => setIsHistoryModalOpen(false)} sx={{ mt: 2 }}>
Close
</Button>
</Box>
</Modal>
</Box>
</Container>
);
};

export default WalletApprovalSearch;

The environment variable file has the QuickNode URL and PostgreSQL instance's configuration.

REACT_APP_QUICKNODE_ENDPOINT=

# Database configuration
DB_USER=
DB_HOST=
DB_NAME=
DB_PASSWORD=
DB_PORT=

The server.js is an Express server that connects to the PostgreSQL instance and serves on port 3001.

require('dotenv').config();
const express = require('express');
const { Pool } = require('pg');
const cors = require('cors');

const pool = new Pool({
user: process.env.DB_USER,
host: process.env.DB_HOST,
database: process.env.DB_NAME,
password: process.env.DB_PASSWORD,
port: process.env.DB_PORT,
ssl: {
rejectUnauthorized: false
}
});

// Test the database connection
pool.connect((err, client, release) => {
if (err) {
return console.error('Error acquiring client', err.stack);
}
client.query('SELECT NOW()', (err, result) => {
release();
if (err) {
return console.error('Error executing query', err.stack);
}
console.log('Connected to database successfully');
});
});

const app = express();
app.use(cors());

app.get('/api/wallet-approval-states/:walletAddress', async (req, res) => {
try {
const { walletAddress } = req.params;
const result = await pool.query(
'SELECT latest_approvals, approval_history FROM wallet_approval_states WHERE wallet_address = $1', //replace wallet_approval_states with your table name
[walletAddress]
);

if (result.rows.length > 0) {
res.json(result.rows[0]);
} else {
res.status(404).json({ error: 'Wallet address not found' });
}
} catch (error) {
console.error('Database error:', error);
res.status(500).json({ error: 'Internal server error', details: error.message });
}
});

const PORT = process.env.PORT || 3001;
app.listen(PORT, () => console.log(`Server running on port ${PORT}`));

We ❤️ Feedback!

Let us know if you have any feedback or requests for new topics. We'd love to hear from you.

Share this guide