Developing a token approval management system (revoke)
approve(spender, type(uint256).max) — a line in a transaction that most users sign without looking, because the dApp won't work without it. Result: hundreds of contracts with unlimited access to wallet tokens. When one of them is hacked, the attacker drains everything — not just the transaction they approved for. Revoke.cash and Etherscan Token Approvals solve this problem for end users, but if you need a custom system for a specific protocol, white-label product, or corporate wallet — that's a separate development.
Technical basics: how approvals work
ERC-20 allowance
The ERC-20 standard defines allowance(owner, spender) — how many tokens spender can spend on behalf of owner. Set via approve(spender, amount). The value type(uint256).max (2^256-1) means "infinite" — most protocols require exactly this for convenience.
Problem: allowance has no expiration date. No automatic revocation mechanism. If a protocol is compromised a year after your approve — the allowance is still active.
ERC-721 and ERC-1155 approvals
For NFTs, two types of approvals:
-
approve(operator, tokenId)— permission for a specific token -
setApprovalForAll(operator, true)— full access to the entire collection
setApprovalForAll is used by OpenSea, blur.io, and other marketplaces. This is the most dangerous type — one hacked marketplace with active setApprovalForAll = entire collection lost.
EIP-2612: Permit
permit(owner, spender, value, deadline, v, r, s) — signature instead of transaction. Doesn't create a permanent allowance, works once with a specific deadline. Properly designed dApps use permit instead of approve.
But permit has a nuance: if DAI, USDC, or another token supports permit — allowance through permit can also be viewed through allowance(). They're indistinguishable from regular approves.
Reading approval data
Via Approval event
Direct allowance(owner, spender) query requires knowing the spender address. To get ALL active approvals of a wallet — you need to read events:
import { createPublicClient, http, parseAbi } from 'viem';
const ERC20_ABI = parseAbi([
'event Approval(address indexed owner, address indexed spender, uint256 value)',
'function allowance(address owner, address spender) view returns (uint256)',
'function symbol() view returns (string)',
'function decimals() view returns (uint8)',
]);
async function getTokenApprovals(ownerAddress: `0x${string}`) {
const client = createPublicClient({ chain: mainnet, transport: http(RPC_URL) });
// Get all Approval events where owner = our address
const approvalLogs = await client.getLogs({
event: ERC20_ABI[0], // Approval event
args: { owner: ownerAddress },
fromBlock: 0n,
toBlock: 'latest'
});
// Deduplication: keep only latest Approval for each token+spender pair
const latestApprovals = new Map<string, typeof approvalLogs[0]>();
for (const log of approvalLogs) {
const key = `${log.address}-${log.args.spender}`;
latestApprovals.set(key, log); // later ones overwrite earlier
}
// Check current allowance for each pair
const results = await Promise.all(
Array.from(latestApprovals.values()).map(async (log) => {
const [allowance, symbol, decimals] = await Promise.all([
client.readContract({
address: log.address,
abi: ERC20_ABI,
functionName: 'allowance',
args: [ownerAddress, log.args.spender!]
}),
client.readContract({ address: log.address, abi: ERC20_ABI, functionName: 'symbol' }),
client.readContract({ address: log.address, abi: ERC20_ABI, functionName: 'decimals' }),
]);
return {
tokenAddress: log.address,
spenderAddress: log.args.spender!,
allowance,
symbol,
decimals,
isUnlimited: allowance === BigInt('0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'),
};
})
);
// Filter out zero allowances (already revoked)
return results.filter(r => r.allowance > 0n);
}
Problem with historical data
getLogs with fromBlock: 0n — slow and expensive query for public RPC. Solutions:
- The Graph: index Approval events via subgraph, GraphQL queries are instantaneous
-
Etherscan/Alchemy API: ready-made endpoints for token approvals (
alchemy_getTokenAllowances) - Incremental indexing: track the last indexed block, on each update fetch only new events
For a production system, a Graph subgraph is optimal. One query returns all active approvals with metadata.
Revoke operations
ERC-20 revoke
Revoke = approve(spender, 0). One transaction per token+spender pair.
async function revokeERC20Approval(
tokenAddress: `0x${string}`,
spenderAddress: `0x${string}`
) {
const { writeContract } = useWriteContract();
writeContract({
address: tokenAddress,
abi: erc20Abi,
functionName: 'approve',
args: [spenderAddress, 0n]
});
}
Batch revoke via Multicall
Revoking 10 approvals = 10 transactions, 10 user signatures. Unacceptable. But! ERC-20 approve can't be called on behalf of the user without their signature — no multicall way to do batch approve/revoke in one click without a custom contract or Permit2.
Permit2 batch revoke (if user uses Permit2): Permit2 supports lockdown(TokenSpenderPair[] calldata approvals) — revokes multiple Permit2 allowances in one call. But doesn't revoke direct ERC-20 approvals.
Practical solution: queue of revoke transactions with automatic sending of the next after the previous is confirmed. UI shows progress Revoking 3 of 8.... User signs each, but doesn't wait manually — the next pop-up appears automatically.
async function batchRevoke(approvals: Approval[]) {
for (const approval of approvals) {
await writeContractAsync({
address: approval.tokenAddress,
abi: erc20Abi,
functionName: 'approve',
args: [approval.spenderAddress, 0n]
});
// Wait for confirmation before next
await waitForTransaction({ hash: txHash });
}
}
ERC-721 / ERC-1155 revoke
setApprovalForAll(operator, false) — revoke full access to collection. More critical, so highlight in red in the UI. approve(operator, tokenId) followed by revoke is less critical — access to a specific token.
UI design of the system
Approvals table
Main component — table with sorting and filtering:
| Token | Spender | Allowance | Risk | Action |
|---|---|---|---|---|
| USDC | Uniswap V3 | Unlimited | Medium | Revoke |
| WETH | Old Protocol (deprecated) | Unlimited | High | Revoke |
| DAI | Aave V3 | 1,000 DAI | Low | Revoke |
Risk scoring — important part of UX. Spender addresses are identified via:
- Etherscan Labels API
- DefiLlama protocol database
- Own whitelist of known protocols
Verified protocol = medium risk (approve exists, but protocol is reliable). Unknown contract = high risk. Deprecated/dead contract = critical risk.
Filters: by network, by type (ERC-20 / NFT), by risk level, unlimited approvals only.
Multi-chain
Users have approvals on Ethereum, Base, Arbitrum, Polygon, and other networks. System should aggregate data from all supported chains. Parallel requests via Promise.all to each chain's RPC, combine results in a single list with a network icon.
Development stack
React + Next.js, wagmi 2.x + viem for on-chain operations, TanStack Query for caching and background updates, TanStack Table for the approvals table, The Graph for event indexing (or Alchemy Transfers API). TypeScript.
For multi-chain: wagmi config with supported chains, separate viem publicClient for each network.
Timeline estimates
ERC-20 revoke system for one network with reading via RPC Events, risk scoring by whitelist, and batch revoke queue — 2 days. With multi-chain support (5+ networks), ERC-721/ERC-1155 approvals, Graph subgraph indexer, and custom risk scoring — 3-5 days.







