How to Index ERC20 Token Transfers
Introduction
This tutorial indexes ERC20 token transfers with Envio HyperIndex, using USDC on Base as the example. You end up with every Transfer event in a database, a running total per account, and a GraphQL API over both.
There are two ways to get there. Write the three files yourself, which is below and takes a copy and paste, or generate them from the contract address with contract import, which is the rest of the page.
The short version
Three files and a handful of commands. Everything in this section was run against Base before it was written, and the handler keeps a running total per account as well as the raw transfers.
mkdir usdc-base-transfers && cd usdc-base-transfers
pnpm init
pnpm pkg set type=module
pnpm add envio
The handlers are ES modules, so type has to be set before the indexer will load them.
Indexing reads from HyperSync, which needs an API token. Put it in a .env file in the project root, or nothing will sync:
ENVIO_API_TOKEN=your_token_here
config.yaml
# yaml-language-server: $schema=./node_modules/envio/evm.schema.json
name: usdc-base-transfers
contracts:
- name: USDC
handler: src/EventHandlers.ts
events:
- event: "Transfer(address indexed from, address indexed to, uint256 value)"
chains:
- id: 8453 # Base
start_block: 28000000
contracts:
- name: USDC
address: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
schema.graphql
type Transfer {
id: ID!
from: String!
to: String!
value: BigInt!
blockNumber: Int!
blockTimestamp: Int!
}
type Account {
id: ID!
sent: BigInt!
received: BigInt!
transferCount: Int!
}
src/EventHandlers.ts
import { indexer } from "envio";
indexer.onEvent(
{ contract: "USDC", event: "Transfer" },
async ({ event, context }) => {
context.Transfer.set({
id: `${event.chainId}_${event.block.number}_${event.logIndex}`,
from: event.params.from,
to: event.params.to,
value: event.params.value,
blockNumber: event.block.number,
blockTimestamp: event.block.timestamp,
});
const sender = await context.Account.get(event.params.from);
context.Account.set({
id: event.params.from,
sent: (sender?.sent ?? 0n) + event.params.value,
received: sender?.received ?? 0n,
transferCount: (sender?.transferCount ?? 0) + 1,
});
const receiver = await context.Account.get(event.params.to);
context.Account.set({
id: event.params.to,
sent: receiver?.sent ?? 0n,
received: (receiver?.received ?? 0n) + event.params.value,
transferCount: (receiver?.transferCount ?? 0) + 1,
});
},
);
Then start it:
pnpm envio dev
If you have run another indexer on this machine before, run pnpm envio stop first so it starts from a clean database.
Indexing starts from the block in config.yaml, and Hasura opens at http://localhost:8080 with the password testing.
Querying what you indexed
The largest transfers:
query LargestTransfers {
Transfer(limit: 3, order_by: { value: desc }) {
from
to
value
blockNumber
}
}
{
"from": "0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb",
"to": "0xadaa772e1EEc300C1bb62D8342b69E8c627172a6",
"value": "28060166801788",
"blockNumber": 28000952
}
USDC has 6 decimals, so that value is about 28.06 million USDC.
The accounts that received the most, which comes from the Account entity the handler keeps updated:
query TopReceivers {
Account(limit: 3, order_by: { received: desc }) {
id
received
transferCount
}
}
{
"id": "0xb2cc224c1c9feE385f8ad6a55b4d94E92359DC59",
"received": "2521096268603014",
"transferCount": 24430
}
An aggregate like that is why you index rather than call an RPC. It is one row, kept current as new transfers arrive.
Prefer to generate it from the contract?
The rest of this page does the same job with envio init contract-import, which reads the ABI from a block explorer and writes the three files for you.
Prerequisites
Before starting, ensure you have the following installed:
- Node.js (v22 or newer, which
enviorequires) - pnpm (recommended but not required)
- Docker Desktop (required to run the Envio indexer locally)
Note: Docker is specifically required to run your blockchain indexer locally. You can skip Docker installation if you plan only to use Envio Cloud.
Step 1: Initialize Your Indexer
envio init on its own prints what to do next, aimed at a coding agent as much as a person. With no token set yet it opens with that (trimmed):
Welcome to Envio Indexer! Let's set up an indexer that will become a reliable blockchain backend you trust, love, and own.
Leave the rest to your favorite agent:
1. ENVIO_API_TOKEN is not set. Ask the user to create one at https://envio.dev/app/api-tokens and provide it to the session before continuing.
2. Prompt the user for the project intent if it is missing from context (what should the indexer track and surface?).
3. Determine the chain, contract, and addresses needed to produce that result. Use web search or block-explorer tool calls when the user hasn't supplied them.
4. To continue, call:
pnpx envio init contract-import explorer \
-n ${indexer-name} \
-c ${address} \
-b ${chainId} \
--single-contract \
--all-events \
-d ${directory}
Create the token first at envio.dev/app/api-tokens and export it, because contract import refuses to run without one:
export ENVIO_API_TOKEN=your_token_here
Step 2: Import the USDC Token Contract
Fill those in for USDC on Base and run it:
pnpx envio init contract-import explorer \
--name usdc-base-transfer-indexer \
--contract-address 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 \
--blockchain base \
--single-contract \
--all-events \
--directory usdc-base-transfer-indexer
The ABI comes from the block explorer, so you only need the address. View the contract on BaseScan.
Drop --all-events to pick events yourself, and --single-contract to add more contracts, addresses or chains in the same pass. It finishes with:
Your indexer is ready! Pick how you'd like to run it:
1. cd usdc-base-transfer-indexer && pnpm test # run the tests (recommended for AI)
2. cd usdc-base-transfer-indexer && pnpm dev # run locally
3. cd usdc-base-transfer-indexer && pnpm start # run in production
Step 3: Start Your Indexer
- Move into the project, and stop any indexer already running there:
cd usdc-base-transfer-indexer
pnpm envio stop
Note: You can skip this step if this is your first time running an indexer.
- Start your new indexer:
pnpm dev
This command:
- Starts the required Docker containers
- Sets up your database
- Launches the indexing process
- Opens the Hasura GraphQL interface
Step 4: Understanding the Generated Code
Contract import writes a whole project, including tests, a .env and a git repo. These are the three files you will actually edit, shown here for USDC and trimmed to the Transfer event.
config.yaml holds the chain, the start block, the contract address and the events to index:
# yaml-language-server: $schema=./node_modules/envio/evm.schema.json
name: usdc-base-transfer-indexer
disable_default_cross_chain: true
chains:
- id: 8453
start_block: 0
contracts:
- name: FiatTokenProxy
address:
- "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
events:
- event: Transfer(address indexed from, address indexed to, uint256 value)
schema.graphql holds an entity per event, named after the contract:
type FiatTokenProxy_Transfer {
id: ID!
from: String!
to: String!
value: BigInt!
}
src/handlers/FiatTokenProxy.ts holds a handler per event, writing one row per log:
import { indexer } from "envio";
import type { FiatTokenProxy_Transfer } from "envio";
indexer.onEvent({ contract: "FiatTokenProxy", event: "Transfer" }, async ({ event, context }) => {
const entity: FiatTokenProxy_Transfer = {
id: `${event.chainId}_${event.block.number}_${event.logIndex}`,
from: event.params.from,
to: event.params.to,
value: event.params.value,
};
context.FiatTokenProxy_Transfer.set(entity);
});
The entity name comes from the contract name in config.yaml, so renaming the contract renames the entity and the GraphQL field with it.
Step 5: Exploring Your Indexed Data
Now you can interact with your indexed USDC transfer data:
Accessing Hasura
- Open Hasura at http://localhost:8080
- When prompted, enter the admin password:
testing
Monitoring Indexing Progress
Progress lives in two places. The Data tab tracks chain_metadata, a view with one row per chain, where latest_processed_block is how far indexing has reached and block_height is the head:
chain_id | start_block | block_height | latest_processed_block | num_events_processed
----------+-------------+--------------+------------------------+----------------------
8453 | 28000000 | 51661447 | 28272195 | 6160136
The numbers above come from the hand written indexer earlier on this page. The envio_chains table holds the same thing with different column names, but Hasura does not track it, so read that one from Postgres directly.
Note: Thanks to Envio's HyperSync, you index from the chain's own data layer rather than paging an RPC node.
Querying Indexed Events
- Click the API tab
- Construct a GraphQL query to explore your data
Here's an example query to fetch the 10 largest USDC transfers:
query LargestTransfers {
FiatTokenProxy_Transfer(limit: 10, order_by: { value: desc }) {
from
to
value
}
}
- Click the Play button to execute your query
Hand this to your coding agent
Set up an Envio HyperIndex indexer for ERC20 Transfer events.
- pnpm init, pnpm pkg set type=module, pnpm add envio, files config.yaml,
schema.graphql, src/EventHandlers.ts
- Put ENVIO_API_TOKEN in a .env file in the project root, indexing fails without it
- config.yaml: top-level contracts block with the Transfer event signature, then a
chains block with the chain id, start_block and the token address
- schema.graphql: a Transfer entity, plus an Account entity holding sent, received
and transferCount
- src/EventHandlers.ts: import { indexer } from "envio" and register with
indexer.onEvent({ contract, event }, handler). Read the account with
context.Account.get before writing it back with context.Account.set
- Run it with pnpx envio dev, which needs Docker
Docs: https://docs.envio.dev/docs/HyperIndex/tutorial-erc20-token-transfers
Common follow-ups
How do I index more than one token?
Add another address under the same contract in config.yaml, or a second contract with its own handler. The configuration file guide covers both.
How do I index the same token on several chains?
Add another entry to chains with that chain's id, start block and token address. One indexer covers them all, see multichain indexing.
How do I index every ERC20 rather than a list of them?
Index the Transfer event across all contracts with wildcard indexing, or register tokens as they are deployed with dynamic contracts.
How do I get balances rather than transfers?
Keep the running total in the handler, as the Account entity above does. Reading a balance then becomes one row rather than a call per holder.
Conclusion
You now have an indexer for USDC transfers on Base, either written by hand or generated from the contract, with a GraphQL API over the results.
What You've Learned
- How to initialize an indexer using Envio's contract import feature
- How to index ERC20 token transfers on the Base chain
- How to query and analyze token transfer data using GraphQL
Next Steps
- Try customizing the event handlers to add additional logic
- Create aggregated statistics about token transfers
- Add more tokens or events to your indexer
- Deploy your indexer to Envio Cloud
For more tutorials and advanced features, see the documentation.