Guide

Multi-Wallet ERC-20 Balance Scanning from TypeScript: No ABIs, No RPC, No Viem

Scan ERC-20 token balances for many wallets, across Ethereum, Base, and BNB Chain, using TypeScript fetch. No Viem, no ABI files, no RPC provider needed.

evmquery team · · 7 min read
Multi-wallet ERC-20 balance scanning with TypeScript REST API — no Viem, no RPC node

Reading ERC-20 token balances sounds like a five-minute job. One contract, one wallet, one balanceOf call. But the jobs that actually ship to production almost never stay that small. A treasury monitor watching 20 wallets, a yield tracker checking six DeFi positions across three chains, a liquidation bot scanning 500 borrowers on every block. Each of those multiplies the baseline read by a factor that quickly swamps any public RPC endpoint.

The standard fix is batching through Multicall3, but it still requires a web3 library, a provider account, and ABI management. There is a shorter path: one HTTP POST, a typed expression, and structured JSON back. No library installs, no provider keys, no ABI files.

TL;DR

The evmquery REST API accepts a CEL expression, a contract address map, and optional context variables, then returns a typed result from the live chain. Scanning 50 wallets costs one HTTP call. Switch chains by changing one field. Works from any language with fetch; this guide uses plain TypeScript. Get a free key to follow along.

How the request body is structured

Every query is a POST to https://api.evmquery.com/api/v1/query with an x-api-key header and this JSON shape:

{
  "chain": "evm_ethereum",
  "schema": {
    "contracts": { "usdc": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" },
    "context":   { "wallet": "sol_address" }
  },
  "context": { "wallet": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045" },
  "expression": "formatUnits(usdc.balanceOf(wallet), usdc.decimals())"
}

Three concepts worth internalizing before you write any expressions:

  • schema.contracts maps a local name (usdc) to a deployed address. ABI resolution is automatic: evmquery fetches the verified source, reconstructs the interface, and exposes every public read method. You never manage an ABI file.
  • schema.context declares variable types. sol_address tells the engine this variable holds a single Ethereum address. list<sol_address> tells it you are passing a list. Declaring the wrong type causes a clear type error at evaluation time rather than a silent wrong result.
  • expression is CEL with a Solidity overlay. formatUnits, parseUnits, solInt, and isZeroAddress are built-in helpers. Arithmetic, list literals, ternary expressions, and the map, filter, all, and exists list macros work as expected.

Supported chains: evm_ethereum, evm_base, evm_bnb_mainnet.

Reading a single ERC-20 balance

Start with the simplest case, using only the fetch global available in Node 18+:

const API_KEY  = process.env.EVMQUERY_API_KEY!;
const ENDPOINT = "https://api.evmquery.com/api/v1/query";

async function usdcBalance(wallet: string): Promise<number> {
  const res = await fetch(ENDPOINT, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "x-api-key": API_KEY,
    },
    body: JSON.stringify({
      chain: "evm_ethereum",
      schema: {
        contracts: { usdc: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" },
        context: { wallet: "sol_address" },
      },
      context: { wallet },
      expression: "formatUnits(usdc.balanceOf(wallet), usdc.decimals())",
    }),
  });
  if (!res.ok) throw new Error(await res.text());
  const { result } = await res.json();
  return parseFloat(result);
}

// 5567.40 USDC
console.log(await usdcBalance("0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"));

No npm install. The result field holds the return value; the response also includes the block number, on-chain call count, and credits consumed, which is useful for cost accounting and debugging.

Zero extra dependencies

This runs on Node 18+ with no additional packages. Deno, Bun, and browser contexts use the same fetch call without modification.

Scanning many wallets with map

The real leverage comes from the map macro. Declare the context variable as list<sol_address>, pass an array, and the expression fans out across every address in a single HTTP request:

async function usdcBalances(wallets: string[]): Promise<number[]> {
  const res = await fetch(ENDPOINT, {
    method: "POST",
    headers: { "Content-Type": "application/json", "x-api-key": API_KEY },
    body: JSON.stringify({
      chain: "evm_ethereum",
      schema: {
        contracts: { usdc: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" },
        context: { wallets: "list<sol_address>" },
      },
      context: { wallets },
      expression: "wallets.map(w, formatUnits(usdc.balanceOf(w), usdc.decimals()))",
    }),
  });
  if (!res.ok) throw new Error(await res.text());
  return (await res.json()).result as number[];
}

const addresses = [
  "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", // 5,567.40 USDC
  "0x47ac0Fb4F2D84898e4D9E7b4DaB3C24507a6D503", //     0.01 USDC
  "0xBE0eB53F46cd790Cd13851d5EFf43D12404d33E8", //     3.00 USDC
];

console.log(await usdcBalances(addresses));
// [5567.402493, 0.009929, 3]

One HTTP call. Three on-chain reads. Internally the engine batches the balanceOf calls the same way Multicall3 does — the batching logic is not your problem. Scale this to 100 or 500 wallets; the request shape does not change.

One detail that bites people: the type declaration must match the runtime value. Passing an array but declaring "sol_address" (not "list<sol_address>") in schema.context causes a type error at evaluation time. The fix is always the same.

Filtering: wallets above a threshold

Replace map with filter to get back only the addresses for which a predicate holds:

const body = {
  chain: "evm_ethereum",
  schema: {
    contracts: { usdc: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" },
    context: { wallets: "list<sol_address>" },
  },
  context: { wallets: addresses },
  // Return only wallets holding more than 100 USDC
  expression: 'wallets.filter(w, usdc.balanceOf(w) > parseUnits("100", 6))',
};

parseUnits("100", 6) converts 100 USDC to its raw uint256 representation (100,000,000). Applied to the three-wallet list above, the result is a single-element array containing only the wallet with 5,567 USDC.

This pattern maps directly to DeFi monitoring workflows: pass a list of 500 Aave borrowers, filter to those with a health factor below 1.05, and act on a far smaller set. One request does the work that used to require a loop and a threshold check in application code.

Reading two tokens in one expression

Declare two contracts in schema.contracts and return a list literal from the expression. Both calls execute in the same batched round:

const body = {
  chain: "evm_ethereum",
  schema: {
    contracts: {
      usdc: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
      weth: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2",
    },
    context: { wallet: "sol_address" },
  },
  context: { wallet: "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045" },
  expression: "[formatUnits(usdc.balanceOf(wallet), 6), formatUnits(weth.balanceOf(wallet), 18)]",
};
// result: [5567.402493, 0.0000001]

Two contract calls, one HTTP round trip, one response object. You can extend this to as many tokens as you need by adding entries to schema.contracts and expanding the list expression.

Cross-chain: Ethereum, Base, and BNB Chain

Each query targets exactly one chain. To compare the same wallet across chains, fire requests in parallel and correlate in your application:

async function fetchBalance(
  chain: string,
  contract: string,
  wallet: string,
  decimals: number,
): Promise<number> {
  const res = await fetch(ENDPOINT, {
    method: "POST",
    headers: { "Content-Type": "application/json", "x-api-key": API_KEY },
    body: JSON.stringify({
      chain,
      schema: {
        contracts: { token: contract },
        context: { wallet: "sol_address" },
      },
      context: { wallet },
      expression: `formatUnits(token.balanceOf(wallet), ${decimals})`,
    }),
  });
  if (!res.ok) throw new Error(await res.text());
  return parseFloat((await res.json()).result);
}

const ETH_USDC  = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
const BASE_USDC = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
const BNB_USDT  = "0x55d398326f99059fF775485246999027B3197955"; // 18 decimals on BNB

const wallet = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045";

const [ethBal, baseBal, bnbBal] = await Promise.all([
  fetchBalance("evm_ethereum",    ETH_USDC,  wallet, 6),
  fetchBalance("evm_base",        BASE_USDC, wallet, 6),
  fetchBalance("evm_bnb_mainnet", BNB_USDT,  wallet, 18),
]);

console.log(`Ethereum: ${ethBal.toFixed(2)} USDC`);  // 5,567.40
console.log(`Base:     ${baseBal.toFixed(2)} USDC`); //    44.48
console.log(`BNB:      ${bnbBal.toFixed(2)} USDT`);  // 1,190.97

Note that USDC’s contract address differs by chain. On BNB Chain the dominant stablecoin is USDT at 0x55d3...b955 with 18 decimals, unlike the 6 decimals used on Ethereum and Base.

A complete TypeScript balance monitor

Here is a self-contained script that reads stablecoin balances for a watchlist across two chains and writes the result to disk. Copy it into a .ts file, set EVMQUERY_API_KEY, and run with tsx or ts-node:

import { writeFileSync } from "node:fs";

const API_KEY  = process.env.EVMQUERY_API_KEY!;
const ENDPOINT = "https://api.evmquery.com/api/v1/query";

const WATCHLIST = [
  "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045",
  "0x47ac0Fb4F2D84898e4D9E7b4DaB3C24507a6D503",
  "0xBE0eB53F46cd790Cd13851d5EFf43D12404d33E8",
];

async function scanBalances(
  chain: string,
  contract: string,
  wallets: string[],
): Promise<number[]> {
  const res = await fetch(ENDPOINT, {
    method: "POST",
    headers: { "Content-Type": "application/json", "x-api-key": API_KEY },
    body: JSON.stringify({
      chain,
      schema: {
        contracts: { token: contract },
        context: { wallets: "list<sol_address>" },
      },
      context: { wallets },
      expression: "wallets.map(w, formatUnits(token.balanceOf(w), token.decimals()))",
    }),
  });
  if (!res.ok) throw new Error(await res.text());
  return (await res.json()).result as number[];
}

const [ethBalances, baseBalances] = await Promise.all([
  scanBalances("evm_ethereum", "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", WATCHLIST),
  scanBalances("evm_base",     "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", WATCHLIST),
]);

const report = WATCHLIST.map((address, i) => ({
  address,
  usdc_ethereum: ethBalances[i],
  usdc_base:     baseBalances[i],
}));

console.table(report);
writeFileSync("balances.json", JSON.stringify(report, null, 2));

Two parallel requests, three wallets each, six on-chain reads total. No ABI files, no provider configuration, no web3 library in node_modules. The output is a typed array you can log, push to a database, or forward to a webhook.

If you need the same pattern in Python, the Python REST API guide uses an identical request shape with requests.post.

Credits and rate limits

The free tier includes 2,000 credits per month. A map across any number of wallets costs totalCalls + totalRounds credits. A single batched balanceOf scan costs 2 credits (1 call, 1 round), regardless of how many wallets are in the list. The filter examples each consumed 1 credit because the engine folded them into one optimized round.

For applications that poll frequently, the credit model rewards batching. Scanning 50 wallets in one request is no more expensive than scanning one.

Next steps

2,000 free credits, no card needed

Sign up, grab an API key, and run the multi-wallet example against a live contract in under five minutes.