Read ERC-20 Balances and Allowances with Ethers.js
Why I wanted a direct wallet read
After building a CSV history, the next gap was current permissions. Portfolio history tells me what happened. It does not tell me which contracts can still pull tokens from the wallet tomorrow.
This post builds a small Ethers.js script that reads native ETH balance, ERC-20 balances, and current allowances for a configured set of protocol contracts. The goal is not to replace Revoke.cash or a full portfolio tracker. I wanted a local, repeatable check I could run before sending more funds into a learning wallet.
What the script actually reads
The Ethers docs split blockchain access into a Provider for read-only calls and a Signer for writes that need account authorization. Ethers getting started docs{target="_blank" rel=“noopener noreferrer”} This script only creates a JsonRpcProvider. No private key, no seed phrase, no wallet connection, no transactions.
For ERC-20s, the useful read methods are boring:
| Method | Why it matters |
|---|---|
balanceOf(owner) | Current token balance for the wallet |
allowance(owner, spender) | Amount a spender can still transfer with transferFrom |
decimals() | How to display the raw integer balance |
symbol() | Human-readable output |
balanceOf, allowance, approve, transferFrom, and the Approval event are defined in the ERC-20 standard. ERC-20 token standard{target="_blank" rel=“noopener noreferrer”} The important mental model: an allowance is not a token balance. It is a permission. If you approve a router for 10,000 USDC while holding 40 USDC today, the future 9,960 USDC you later deposit can still be in scope unless you revoke or overwrite the allowance.
There are two inputs I keep explicit:
- Tokens: the assets I actually hold or expect to hold on that chain.
- Spenders: protocol contracts I have interacted with, pulled from transaction receipts or explorer labels.
That manual list is intentional. For a sub-$1k wallet, I do not need an indexer before I understand the basic state.
Ethers.js balance and allowance script
I tested this with Node 24.13.0 and ethers@6.16.0. Install Ethers in a scratch folder or your scripts repo:
npm install ethers@^6
Save this as audit-allowances.mjs:
import { writeFile } from "node:fs/promises";
import {
Contract,
JsonRpcProvider,
formatEther,
formatUnits,
getAddress,
} from "ethers";
const RPC_URL = process.env.RPC_URL ?? "https://arb1.arbitrum.io/rpc";
const WALLET = getAddress(requiredEnv("WALLET"));
const MAX_UINT256 = (1n << 256n) - 1n;
const TOKENS = [
{
symbol: "USDC",
address: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831",
},
{
symbol: "WETH",
address: "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1",
},
];
const SPENDERS = [
{
name: "Aave V3 Pool",
address: "0x794a61358D6845594F94dc1DB02A252b5b4814aD",
},
{
name: "Uniswap V3 Position Manager",
address: "0xC36442b4a4522E871399CD717aBDD847Ab11FE88",
},
];
const ERC20_ABI = [
"function symbol() view returns (string)",
"function decimals() view returns (uint8)",
"function balanceOf(address owner) view returns (uint256)",
"function allowance(address owner, address spender) view returns (uint256)",
"event Approval(address indexed owner, address indexed spender, uint256 value)",
];
const provider = new JsonRpcProvider(RPC_URL);
function requiredEnv(name) {
const value = process.env[name];
if (!value) {
throw new Error(`Missing ${name}. Example: ${name}=0xYourWallet`);
}
return value;
}
function allowanceRisk(balance, allowance) {
if (allowance === 0n) return "none";
if (allowance === MAX_UINT256) return "unlimited";
if (balance === 0n) return "stale";
if (allowance > balance * 10n) return "large";
return "bounded";
}
function csv(value) {
const text = String(value);
return /[",\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
}
async function snapshotToken(token) {
const contract = new Contract(token.address, ERC20_ABI, provider);
const [symbol, decimals, balance] = await Promise.all([
contract.symbol().catch(() => token.symbol),
contract.decimals(),
contract.balanceOf(WALLET),
]);
const spenderRows = [];
for (const spender of SPENDERS) {
const spenderAddress = getAddress(spender.address);
const allowance = await contract.allowance(WALLET, spenderAddress);
spenderRows.push({
token: symbol,
tokenAddress: getAddress(token.address),
balance: formatUnits(balance, decimals),
spender: spender.name,
spenderAddress,
allowance: formatUnits(allowance, decimals),
risk: allowanceRisk(balance, allowance),
});
}
return { symbol, decimals, balance, spenderRows };
}
async function main() {
const nativeBalance = await provider.getBalance(WALLET);
console.log(`wallet: ${WALLET}`);
console.log(`native ETH: ${formatEther(nativeBalance)}`);
const lines = [
"token,token_address,balance,spender,spender_address,allowance,risk",
];
for (const token of TOKENS) {
const snapshot = await snapshotToken(token);
console.log(`${snapshot.symbol}: ${formatUnits(snapshot.balance, snapshot.decimals)}`);
for (const row of snapshot.spenderRows) {
console.log(` ${row.spender}: ${row.allowance} (${row.risk})`);
lines.push([
row.token,
row.tokenAddress,
row.balance,
row.spender,
row.spenderAddress,
row.allowance,
row.risk,
].map(csv).join(","));
}
}
await writeFile("allowance-audit.csv", `${lines.join("\n")}\n`);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
Run it with the wallet address you want to audit:
WALLET=0xYourWalletAddress node audit-allowances.mjs
Example output looks like this:
wallet: 0xYourWalletAddress
native ETH: 0.0284
USDC: 147.31
Aave V3 Pool: 200.0 (bounded)
Uniswap V3 Position Manager: 0.0 (none)
WETH: 0.035
Aave V3 Pool: 0.0 (none)
Uniswap V3 Position Manager: 0.1 (bounded)
The script also writes allowance-audit.csv, which is the part I care about long term. I can compare that file before and after a protocol interaction and see exactly which permissions changed.
Because these are read-only eth_call-style reads through a provider, the script itself pays no gas. The cost starts only if I decide to revoke an allowance with a real transaction.
Finding spenders without building an indexer
The manual SPENDERS list is the safest starting point, but it is easy to miss an old router. ERC-20 Approval logs can help seed the list.
async function recentApprovalSpenders(tokenAddress, owner, lookbackBlocks = 50_000) {
const token = new Contract(tokenAddress, ERC20_ABI, provider);
const latest = await provider.getBlockNumber();
const fromBlock = Math.max(0, latest - lookbackBlocks);
const events = await token.queryFilter(
token.filters.Approval(owner),
fromBlock,
latest,
);
return [...new Set(events.map((event) => getAddress(event.args.spender)))];
}
I use this as a hint, not as the source of truth. Ethers explicitly warns that querying large historical block ranges can be slow, fail, or get truncated by the backend. Ethers event query docs{target="_blank" rel=“noopener noreferrer”} For a serious cleanup, I still cross-check explorer approval pages and transaction receipts.
What the first audit showed
On my small Arbitrum wallet, the interesting result was not the balances. It was the difference between balances and permissions.
| Token | Balance | Largest allowance | Notes |
|---|---|---|---|
| USDC | ~$150 | 200 USDC | Aave approval from the lending test |
| WETH | ~$120 | 0.1 WETH | Position manager approval from the LP test |
| USDT | $0 | 0 USDT | No stale approval |
That is fine. Both active allowances are bounded and match recent experiments. If I saw unlimited or an allowance many times larger than the current balance, I would revoke it before adding new funds.
The main thing I learned: allowance audits are more useful before funding than after. A stale approval on an empty wallet feels harmless until you bridge in fresh stablecoins.
Risk analysis
Technical risk: incomplete spender coverage
The script only checks spenders you list or discover from recent logs. An old approval outside the lookback window can be missed. Mitigation: seed SPENDERS from explorer approval pages, recent receipts, and the protocols you know you used.
Technical risk: non-standard token behavior
ERC-20 decimals() is optional, and older tokens sometimes behave strangely around return values. ERC-20 metadata note{target="_blank" rel=“noopener noreferrer”} If decimals() fails, add a hardcoded decimal value for that token instead of assuming 18.
Economic risk: future deposits are exposed
An allowance can be larger than the current balance. That means the risk is not capped by today’s wallet balance if you plan to add more of the same token later. This matters most for stablecoins because people tend to reuse the same wallet and same routers.
Operational risk: wrong chain, wrong contract
USDC on Arbitrum is not the same contract address as USDC on Ethereum mainnet or Base. A script pointed at the wrong RPC can produce a clean-looking report for the wrong chain. Keep one config per chain and print the RPC/network in your run logs if you automate this.
Privacy risk: public RPC queries
Read-only calls still reveal interest in a wallet and token set to the RPC provider. For a small learning wallet this is acceptable. For sensitive addresses, run your own node or route reads through infrastructure you control.
Practical takeaways for wallet ops
Run a balance and allowance audit before adding funds, not just after you finish a protocol interaction.
Keep spender lists boring and explicit. Start with the contracts you actually used: Aave Pool, Uniswap Position Manager, routers, bridges, and vaults.
Treat unlimited, stale, and large as review flags, not automatic emergencies. Revoke when the spender is no longer needed, but do not panic-revoke active positions that need an allowance to unwind cleanly.
For a sub-$1k wallet, this script is enough. It gives me a repeatable snapshot, a CSV artifact, and a short list of permissions to clean up before the next experiment.