How to Index and Track Stablecoin Transfers on Solana

- USDC and USDT are SPL Token mints, and PYUSD and USDG are Token-2022 mints. Their addresses are in the table below.
- A transfer is one of three instructions,
transfer,transferCheckedortransferCheckedWithFee. Plaintransferdoesn't name its token, so you match it through the transaction's token balances. HyperIndex can watch both token programs and track all three. - Over a full day, only 23.3% of USDC, USDT, PYUSD and USDG transfers were top-level instructions. The rest ran inside other programs, most often two levels deep.
- 42.4% of USDC transfers and 42.9% of USDT transfers used plain
transfer, so an indexer that filters on the mint misses about two in five of them. - Nearly a third of USDC transfers were under $1. The 0.5% that were $100,000 or more carried 80.3% of the amount moved.
- All the code below is tested. Copy it, clone it from GitHub, or hand the job to your coding agent with our prompt.
Say you want every USDC transfer on Solana. It sounds like one filter. In practice a transfer can arrive as three different instructions, on two different token programs, and it's often made from inside a swap or a router rather than sent by a wallet directly.
Handle only some of those and your data will be incomplete. Nothing fails, because every instruction you did set up still works.
We wanted to see how big that gap is, so we built an indexer that tracks every kind of transfer and ran it over a full day of Solana.
Solana RPC Endpoints and HyperSync
Here's what you need to connect. Solana runs a public RPC endpoint for each network, and HyperSync has its own endpoint for mainnet and devnet.
| Network | Public RPC | HyperSync | HyperIndex chain id |
|---|---|---|---|
| Mainnet | https://api.mainnet.solana.com | https://solana.hypersync.xyz | solana |
| Devnet | https://api.devnet.solana.com | https://solana-devnet.hypersync.xyz | solana-devnet |
| Testnet | https://api.testnet.solana.com | Not listed | Not listed |
Public RPC endpoints from Solana's cluster docs. HyperSync endpoints and chain ids from the HyperIndex Solana configuration docs.
Solana's docs say the public endpoints are rate limited and not meant for production apps, and say to use dedicated or private RPC servers when you launch. HyperIndex doesn't need either to read instructions. It reads from HyperSync and picks the endpoint from the chain id in your config, so you rarely type the URL at all. To look up a single transaction or account, Solana's own explorer is at explorer.solana.com.
Stablecoin Mint Addresses on Solana
Each issuer publishes its Solana mint address. These are the four we track. If you're following stablecoins on EVM chains instead, our stablecoin dashboard post covers that side.
| Stablecoin | Mint address | Program |
|---|---|---|
| USDC | EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v | SPL Token |
| USDT | Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB | SPL Token |
| PYUSD | 2b1kV6DkPAnxd5ixfnxCpjxmKwqjjaYmCZfHsFu24GXo | Token-2022 |
| USDG | 2u1tszSeqZ3qBWF3uNGPFc8TzMk2tdiwknnRMWGWjGWH | Token-2022 |
Each coin links to its issuer's published address list. All four use 6 decimals. Program and decimals read from the mints' onchain token balances through HyperSync.
Solana has two token programs. USDC and USDT use the original SPL Token program, and PYUSD and USDG use the newer Token-2022 program. They're separate programs with separate addresses, so an indexer that only watches one of them never sees the coins on the other.
How a Stablecoin Transfer Looks on Solana
On Solana, sending tokens means sending an instruction to the token program. There are three instructions that move tokens, and each one is identified by a short code at the start of its data, called the discriminator.
| Instruction | Program | Discriminator | Accounts |
|---|---|---|---|
transfer | SPL Token and Token-2022 | 0x03 | source, destination, authority |
transferChecked | SPL Token and Token-2022 | 0x0c | source, mint, destination, authority |
transferCheckedWithFee | Token-2022 | 0x1a01 | source, mint, destination, authority |
Accounts are listed in the order the program expects them, and only the checked instructions include the mint. Taken from the SPL Token and Token-2022 program source.
The tricky one is plain transfer. It says which accounts the tokens moved between and how many, but not which token. To know whether it was USDC, you check the token balances the transaction recorded for those accounts. If you only filter by mint, you get every transferChecked and miss every plain transfer.
transferCheckedWithFee is the Token-2022 version that also states the transfer fee.
Transfers also happen in two places. Some are sent straight from a wallet, at the top of a transaction. Others happen inside another app, like a swap or a lending deposit, which calls the token program on the user's behalf. Those are called inner instructions, and you need both kinds.
Building the Solana Stablecoin Indexer
Check the prerequisites before running anything below, and use Node.js 22 or newer.
The Solana stablecoin indexer is three files. config.yaml says which chain and programs to read, schema.graphql says what to store, and one handler file does the work. HyperIndex pulls the instructions from HyperSync and gives you a GraphQL API over the result.
mkdir solana-stablecoin-indexer && cd solana-stablecoin-indexer
pnpm init
pnpm add envio@next --allow-build=esbuild
pnpm add -D typescript @types/node
Recent pnpm versions stop with ERR_PNPM_IGNORED_BUILDS when a dependency's build script is skipped, and esbuild has one. The --allow-build=esbuild flag approves it so the install finishes cleanly. Older pnpm versions don't know the flag, so update pnpm if you see an unknown option error.
The handler uses ESM imports, so make sure the module type is set. Some pnpm versions already set it in pnpm init, and running this again does no harm.
pnpm pkg set type=module
A tsconfig.json lets you catch type errors before the indexer runs. Keep skipLibCheck on. Without it, tsc also checks the type files inside node_modules and shows you errors that aren't in your code.
{
"compilerOptions": {
"target": "es2023",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["node"]
},
"include": ["src", "envio-env.d.ts"]
}
Once the three files below are in place and you've run pnpm envio codegen, check them.
pnpm exec tsc --noEmit
config.yaml
Each instruction is declared inline, with its discriminator, account order and argument layout, all taken from the program source linked above. The layout also works as a filter, because a call whose data doesn't fit it is skipped.
start_slot and end_slot are slot numbers, and here they pin the exact day we measured. HyperSync's Solana history starts at the earliest slot it has indexed rather than at genesis. A start_slot before that doesn't raise an error. The indexer starts from the earliest indexed slot instead. For a window of your own, get the current head from https://solana.hypersync.xyz/height and work back from there.
Show config.yaml
# yaml-language-server: $schema=./node_modules/envio/svm.schema.json
name: solana-stablecoin-indexer
description: USDC, USDT, PYUSD and USDG transfers on Solana
ecosystem: svm
chains:
- id: solana # mainnet. HyperSync is the default data source.
# One full UTC day. Slot 445741737 is the first block of the day and
# slot 446015087 is the first block of the next.
start_slot: 445741737
end_slot: 446015086
programs:
# USDC and USDT live on the original SPL Token program.
- name: SplToken
program_id: TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA
instructions:
- name: transfer
discriminator: "0x03"
accounts: [source, destination, authority]
args:
- { name: amount, type: u64 }
- name: transferChecked
discriminator: "0x0c"
accounts: [source, mint, destination, authority]
args:
- { name: amount, type: u64 }
- { name: decimals, type: u8 }
# PYUSD and USDG live on Token-2022, a separate program with its own id.
- name: Token2022
program_id: TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb
instructions:
- name: transfer
discriminator: "0x03"
accounts: [source, destination, authority]
args:
- { name: amount, type: u64 }
- name: transferChecked
discriminator: "0x0c"
accounts: [source, mint, destination, authority]
args:
- { name: amount, type: u64 }
- { name: decimals, type: u8 }
# Byte 26 is the transfer fee extension and byte 1 selects this call
# within it, so the discriminator is two bytes.
- name: transferCheckedWithFee
discriminator: "0x1a01"
accounts: [source, mint, destination, authority]
args:
- { name: amount, type: u64 }
- { name: decimals, type: u8 }
- { name: fee, type: u64 }
schema.graphql
We store totals, not a row per transfer. A full day is millions of transfers, and the questions in this post only need counts per coin, per size and per hour.
HandlerScan measures the indexer itself. For each handler it counts the calls that came in and how many of them were stablecoin transfers, which is how we worked out what a missing mint costs.
Show schema.graphql
# Amounts are raw base units. All four stablecoins use 6 decimals, so divide by
# 1,000,000 for a dollar figure at face value.
type Stablecoin {
id: ID! # symbol, e.g. USDC
mint: String!
tokenProgram: String!
transfers: BigInt!
volume: BigInt!
# transfer names no mint, so its token is resolved from account activity.
plainTransfers: BigInt!
# transferChecked and transferCheckedWithFee name the mint in their accounts.
checkedTransfers: BigInt!
# Call depth from the instruction path. 0 is a top-level instruction, 1 is
# called by a top-level instruction, and so on.
topLevel: BigInt!
depth1: BigInt!
depth2: BigInt!
depth3Plus: BigInt!
}
type SizeBucket {
id: ID! # <symbol>-<bucket>
stablecoin: Stablecoin!
bucket: String!
transfers: BigInt!
volume: BigInt!
}
type HourlyStat {
id: ID! # <symbol>-<hour index>
stablecoin: Stablecoin!
hour: Int! @index
transfers: Int!
topLevel: Int!
volume: BigInt!
}
# How many calls reached each handler, and how many were stablecoin transfers.
type HandlerScan {
id: ID! # <program>-<instruction>
seen: BigInt!
kept: BigInt!
}
The Handler
src/handlers/stablecoins.ts registers five handlers, one per program and instruction. HyperIndex picks up anything in src/handlers on its own.
For transferChecked and transferCheckedWithFee the mint is right there in the accounts, so each registration passes the stablecoin mints to where. Only matching calls reach the handler.
Plain transfer has nothing to filter on, so every plain transfer of every token arrives. The handler asks for token.mint through account activity, which attaches each account's token balance record, and keeps the call only if the source or destination holds one of the four coins.
Every handler also reads path, the instruction's position in the transaction. Its length tells you how deep the call was, which is how a wallet's own transfer gets counted apart from one made inside another program.
Entities that come back from getOrCreate are read only, so each update copies the existing row into a new object.
Show src/handlers/stablecoins.ts
import { indexer, type SvmOnSlotContext } from "envio";
// Mint addresses as published by each issuer.
const STABLECOINS: Record<string, { symbol: string; program: string }> = {
EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v: { symbol: "USDC", program: "SplToken" },
Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB: { symbol: "USDT", program: "SplToken" },
"2b1kV6DkPAnxd5ixfnxCpjxmKwqjjaYmCZfHsFu24GXo": { symbol: "PYUSD", program: "Token2022" },
"2u1tszSeqZ3qBWF3uNGPFc8TzMk2tdiwknnRMWGWjGWH": { symbol: "USDG", program: "Token2022" },
};
const mintsOn = (program: string) =>
Object.keys(STABLECOINS).filter((mint) => STABLECOINS[mint]!.program === program);
// All four use 6 decimals, so 1_000_000 base units is one dollar at face value.
const DOLLAR = 1_000_000n;
const bucketOf = (amount: bigint) =>
amount < DOLLAR ? "under $1"
: amount < 1_000n * DOLLAR ? "$1 to $1k"
: amount < 100_000n * DOLLAR ? "$1k to $100k"
: "$100k and over";
// onInstruction handlers receive the same context type as onSlot handlers.
type Context = SvmOnSlotContext;
async function scan(context: Context, id: string, kept: boolean) {
const row = await context.HandlerScan.getOrCreate({ id, seen: 0n, kept: 0n });
context.HandlerScan.set({
...row,
seen: row.seen + 1n,
kept: row.kept + (kept ? 1n : 0n),
});
}
async function record(
context: Context,
mint: string,
amount: bigint,
path: readonly number[],
time: number,
checked: boolean,
) {
const { symbol, program } = STABLECOINS[mint]!;
const depth = path.length - 1;
const hour = Math.floor(time / 3600);
const bucket = bucketOf(amount);
const [coin, size, hourly] = await Promise.all([
context.Stablecoin.getOrCreate({
id: symbol,
mint,
tokenProgram: program,
transfers: 0n,
volume: 0n,
plainTransfers: 0n,
checkedTransfers: 0n,
topLevel: 0n,
depth1: 0n,
depth2: 0n,
depth3Plus: 0n,
}),
context.SizeBucket.getOrCreate({
id: `${symbol}-${bucket}`,
stablecoin_id: symbol,
bucket,
transfers: 0n,
volume: 0n,
}),
context.HourlyStat.getOrCreate({
id: `${symbol}-${hour}`,
stablecoin_id: symbol,
hour,
transfers: 0,
topLevel: 0,
volume: 0n,
}),
]);
context.Stablecoin.set({
...coin,
transfers: coin.transfers + 1n,
volume: coin.volume + amount,
plainTransfers: coin.plainTransfers + (checked ? 0n : 1n),
checkedTransfers: coin.checkedTransfers + (checked ? 1n : 0n),
topLevel: coin.topLevel + (depth === 0 ? 1n : 0n),
depth1: coin.depth1 + (depth === 1 ? 1n : 0n),
depth2: coin.depth2 + (depth === 2 ? 1n : 0n),
depth3Plus: coin.depth3Plus + (depth >= 3 ? 1n : 0n),
});
context.SizeBucket.set({
...size,
transfers: size.transfers + 1n,
volume: size.volume + amount,
});
context.HourlyStat.set({
...hourly,
transfers: hourly.transfers + 1,
topLevel: hourly.topLevel + (depth === 0 ? 1 : 0),
volume: hourly.volume + amount,
});
}
for (const program of ["SplToken", "Token2022"] as const) {
// transferChecked names the mint, so `where` filters on the server and only
// stablecoin transfers reach the handler.
indexer.onInstruction(
{
program,
instruction: "transferChecked",
fields: { instruction: ["args", "accounts", "path"], block: ["time"] },
where: { accounts: mintsOn(program).map((mint) => ({ mint })) },
},
async ({ instruction, context }) => {
const mint = instruction.accounts.mint.address;
await scan(context, `${program}-transferChecked`, true);
await record(context, mint, instruction.args.amount, instruction.path, instruction.block.time, true);
},
);
// transfer names no mint, so every transfer of every token on the program
// arrives here. The mint comes from the token balances of either account.
indexer.onInstruction(
{
program,
instruction: "transfer",
fields: {
instruction: ["args", "accounts", "path"],
accountActivity: ["token.mint"],
block: ["time"],
},
},
async ({ instruction, context }) => {
const { source, destination } = instruction.accounts;
const mint = [source.activity?.token?.mint, destination.activity?.token?.mint].find(
(m) => m !== undefined && m in STABLECOINS,
);
await scan(context, `${program}-transfer`, mint !== undefined);
if (!mint) return;
await record(context, mint, instruction.args.amount, instruction.path, instruction.block.time, false);
},
);
}
indexer.onInstruction(
{
program: "Token2022",
instruction: "transferCheckedWithFee",
fields: { instruction: ["args", "accounts", "path"], block: ["time"] },
where: { accounts: mintsOn("Token2022").map((mint) => ({ mint })) },
},
async ({ instruction, context }) => {
await scan(context, "Token2022-transferCheckedWithFee", true);
await record(
context,
instruction.accounts.mint.address,
instruction.args.amount,
instruction.path,
instruction.block.time,
true,
);
},
);
Running It
Put your free Envio API token in a .env file in the project root, and keep that file out of git.
echo "ENVIO_API_TOKEN=your_token_here" > .env
pnpm envio codegen
pnpm envio dev
envio dev starts Postgres and Hasura in Docker, sets up the tables and starts syncing. The GraphQL playground is at http://localhost:8080, and the local admin secret is testing.
You can follow progress in the envio_chains table. The run is done when progress_block reaches 446015086, the end_slot in the config. Our run processed 32,400,698 instruction calls and kept 18,468,473 of them as stablecoin transfers.
If your token hits its rate limit, HyperSync replies with HTTP 429 and the indexer keeps retrying without logging an error, so progress_block stops moving until the limit resets. The HyperSync free plan uses fair-use rate limiting, and a full day is a lot of data, so on that plan start with a few thousand slots.
What the Data Shows
Everything here comes from the indexer's tables after it finished one full UTC day, slots 445,741,737 to 446,015,086. It's one day, so treat the shares as a snapshot rather than a trend.
Where the Transfers Happen
| Stablecoin | Transfers | Top-level | Inner calls | Plain transfer |
|---|---|---|---|---|
| USDC | 15,484,830 | 21.5% | 78.5% | 42.4% |
| USDT | 2,860,830 | 33.7% | 66.3% | 42.9% |
| USDG | 79,691 | 4.5% | 95.5% | 0% |
| PYUSD | 43,122 | 11.7% | 88.3% | 0% |
Transfers over the indexed day, from the indexer's Stablecoin table. Top-level is topLevel divided by transfers, and plain transfer is plainTransfers divided by transfers.

Share of each stablecoin's transfers that were top-level instructions over the indexed day. Computed from the topLevel and transfers columns of the indexer's Stablecoin table.
Fewer than one in four transfers were sent at the top of a transaction. USDT had the highest share of direct transfers, about a third. USDG had the fewest, with 95.5% made from inside other programs.
What stood out to us was how deep they go. The most common spot was two levels down, where one program calls another and that one calls the token program. That's where 54.2% of USDC transfers sat, 41.3% of USDT transfers, and more than three quarters of PYUSD and USDG transfers.
If you're reconciling balances, for a payments ledger or treasury tracking, this is the table to keep in mind. Read only top-level instructions and most transfers never show up.
What a Missing Mint Costs
Plain transfer made up about two in five USDC and USDT transfers. It has no mint to filter on, so the indexer has to look at every one.
The SPL Token transfer handler got 18,648,565 calls in the day and kept 7,787,127. The other 58.2% were ruled out after checking their balances. In the 200-slot check further down, almost all of the ruled-out calls were transfers of other tokens.
Token-2022 went the other way. Its transfer handler got 3,070,787 calls and kept none. Both the PYUSD and USDG mints use Token-2022's transfer fee extension, and the Token-2022 source says a plain transfer fails when either account carries that extension, so both coins move through the checked instructions, 258 of them through transferCheckedWithFee that day. The same source marks plain transfer as deprecated, but the program still accepts it for other tokens, and it ran more than 3 million times that day.
All of this depends on the token balance records being there. To check, this script skips the indexer and asks HyperSync directly, using its account activity table, for every SPL Token transfer in a 200-slot window, along with the balance records of each transaction. Both scripts in this post read your API token from the environment rather than from .env, so export it first.
export ENVIO_API_TOKEN=your_token_here
Show the check script
import os, json, urllib.request, collections
TOKEN = os.environ["ENVIO_API_TOKEN"]
URL = "https://solana.hypersync.xyz/query"
SPL_TOKEN = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"
STABLECOINS = {
"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", # USDC
"Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB", # USDT
}
def query(body):
req = urllib.request.Request(
URL,
data=json.dumps(body).encode(),
headers={"Content-Type": "application/json",
"Authorization": f"Bearer {TOKEN}"},
)
with urllib.request.urlopen(req, timeout=120) as r:
return json.loads(r.read())
START, END = 445_800_000, 445_800_200
counts, slot = collections.Counter(), START
while slot < END:
d = query({
"from_slot": slot,
"to_slot": END,
# Plain transfer, discriminator 0x03, on the SPL Token program
"instruction_calls": [{"executing_account": [SPL_TOKEN], "d1": ["03"]}],
"field_selection": {
"instruction_call": ["slot", "transaction_index", "a0", "a1"],
"account_activity": ["slot", "transaction_index", "account", "mint"],
},
})
# Token balance records for every account in the matched transactions
mints = {}
for batch in d.get("account_activity", []):
for row in batch:
if row.get("mint"):
mints[(row["slot"], row["transaction_index"], row["account"])] = row["mint"]
for batch in d.get("instruction_calls", []):
for call in batch:
tx = (call["slot"], call["transaction_index"])
source = mints.get(tx + (call["a0"],))
destination = mints.get(tx + (call["a1"],))
if source is None and destination is None:
counts["no mint on either account"] += 1
elif source in STABLECOINS or destination in STABLECOINS:
counts["USDC or USDT"] += 1
else:
counts["another token"] += 1
if d["next_slot"] <= slot:
break
slot = d["next_slot"]
print(f"{sum(counts.values()):,} transfer calls")
for label, n in counts.most_common():
print(f" {label}: {n:,}")
9,054 transfer calls
another token: 5,050
USDC or USDT: 4,003
no mint on either account: 1
Only 1 of the 9,054 calls had no balance record on either account, so matching through balances holds up.
Who Makes the Inner Transfers
Inner transfers raise the obvious next question, which programs make them. We checked a 1,000-slot sample, slots 445,800,000 to 445,800,999, using only USDC transferChecked calls, because those name the mint without a balance lookup. The script reads every instruction in those slots, so expect it to run for several minutes.
Show the script
import os, json, urllib.request, collections
TOKEN = os.environ["ENVIO_API_TOKEN"]
URL = "https://solana.hypersync.xyz/query"
SPL_TOKEN = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"
USDC = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"
def query(body):
req = urllib.request.Request(
URL,
data=json.dumps(body).encode(),
headers={"Content-Type": "application/json",
"Authorization": f"Bearer {TOKEN}"},
)
with urllib.request.urlopen(req, timeout=180) as r:
return json.loads(r.read())
def scan(selection, fields):
rows, slot = [], START
while slot < END:
d = query({"from_slot": slot, "to_slot": END,
"instruction_calls": [selection],
"field_selection": {"instruction_call": fields}})
for batch in d.get("instruction_calls", []):
rows.extend(batch)
if d["next_slot"] <= slot:
break
slot = d["next_slot"]
return rows
START, END = 445_800_000, 445_801_000
# Every USDC transferChecked (the mint is account 1) and where it sits
transfers = scan({"executing_account": [SPL_TOKEN], "d1": ["0c"], "a1": [USDC]},
["slot", "transaction_index", "instruction_address"])
inner = [t for t in transfers if len(t["instruction_address"]) > 1]
# Every instruction in the window, to look up which program sits at a given path
wanted = {(t["slot"], t["transaction_index"]) for t in inner}
program_at = {}
for r in scan({}, ["slot", "transaction_index", "instruction_address", "executing_account"]):
key = (r["slot"], r["transaction_index"])
if key in wanted:
program_at[key + (tuple(r["instruction_address"]),)] = r["executing_account"]
top_level, direct_caller = collections.Counter(), collections.Counter()
for t in inner:
key = (t["slot"], t["transaction_index"])
path = tuple(t["instruction_address"])
top_level[program_at[key + (path[:1],)]] += 1
direct_caller[program_at[key + (path[:-1],)]] += 1
def top5(counter):
return 100 * sum(n for _, n in counter.most_common(5)) / len(inner)
print(f"{len(transfers):,} USDC transferChecked calls, {len(inner):,} inner")
print(f"top-level programs: {len(top_level)}, top 5 share {top5(top_level):.1f}%")
print(f"direct callers: {len(direct_caller)}, top 5 share {top5(direct_caller):.1f}%")
27,181 USDC transferChecked calls, 19,592 inner
top-level programs: 153, top 5 share 62.3%
direct callers: 87, top 5 share 62.3%
The 19,592 inner transfers started from 153 different top-level programs and were made directly by 87 different programs. In both cases the five busiest programs accounted for 62.3%, which leaves more than a third spread across a long tail. An indexer built around a list of known apps would miss that tail, which is another reason to index the token program itself.
Transfer Size

USDC transfers by size over the indexed day, share of transfer count against share of the summed amount. Computed from the indexer's SizeBucket table. Amounts are summed per transfer, so dollars that pass through several accounts in one transaction count at each step.
USDC is a lot of small transfers and a few very large ones. Nearly a third were under $1 and together moved less than 0.01% of the total. The 69,994 transfers of $100,000 or more were about half a percent of the count and 80.3% of the amount.
Querying the Result
With the indexer finished, http://localhost:8080 gives you a Hasura console over the tables. These three queries produced the findings above, apart from the balance check, which has its own script. Hasura returns BigInt columns as strings, so convert them before doing any maths.
Transfers, plain transfer counts and call depth for each coin.
query StablecoinTransfers {
Stablecoin(order_by: { transfers: desc }) {
id
transfers
plainTransfers
topLevel
depth2
}
}
Show the result
{
"Stablecoin": [
{
"id": "USDC",
"transfers": "15484830",
"plainTransfers": "6559485",
"topLevel": "3325495",
"depth2": "8389391"
},
{
"id": "USDT",
"transfers": "2860830",
"plainTransfers": "1227642",
"topLevel": "965444",
"depth2": "1181157"
},
{
"id": "USDG",
"transfers": "79691",
"plainTransfers": "0",
"topLevel": "3575",
"depth2": "61475"
},
{
"id": "PYUSD",
"transfers": "43122",
"plainTransfers": "0",
"topLevel": "5066",
"depth2": "33519"
}
]
}
Calls in and calls kept, for each handler.
query HandlerScan {
HandlerScan(order_by: { seen: desc }) {
id
seen
kept
}
}
Show the result
{
"HandlerScan": [
{
"id": "SplToken-transfer",
"seen": "18648565",
"kept": "7787127"
},
{
"id": "SplToken-transferChecked",
"seen": "10558533",
"kept": "10558533"
},
{
"id": "Token2022-transfer",
"seen": "3070787",
"kept": "0"
},
{
"id": "Token2022-transferChecked",
"seen": "122555",
"kept": "122555"
},
{
"id": "Token2022-transferCheckedWithFee",
"seen": "258",
"kept": "258"
}
]
}
USDC transfers by size. volume is in raw units, so divide by 1,000,000 for dollars.
query UsdcBySize {
SizeBucket(
where: { stablecoin_id: { _eq: "USDC" } }
order_by: { transfers: desc }
) {
bucket
transfers
volume
}
}
Show the result
{
"SizeBucket": [
{
"bucket": "$1 to $1k",
"transfers": "9886868",
"volume": "1264251714382622"
},
{
"bucket": "under $1",
"transfers": "4996117",
"volume": "923584950597"
},
{
"bucket": "$1k to $100k",
"transfers": "531851",
"volume": "2079765734639681"
},
{
"bucket": "$100k and over",
"transfers": "69994",
"volume": "13660058742700356"
}
]
}
Tracking Transfers Between Wallets
The totals are enough for the findings. To see who sent USDC to whom, you need a row per transfer, and you need to know that transfers move tokens between token accounts, not wallets.
Every wallet has its own token account for each token, so the source and destination in the instruction are those accounts. The wallet is the account's owner, and the transaction's token balances record it. Ask for token.owner in account activity and you'll find it on instruction.accounts.source.activity and instruction.accounts.destination.activity.
Add this type to schema.graphql.
# One row per USDC transfer, with the wallets that own each token account.
type UsdcTransfer {
id: ID! # <slot>-<transaction index>-<instruction path>
signature: String!
slot: Int!
time: Int!
depth: Int!
amount: BigInt!
sourceAccount: String!
destinationAccount: String!
# Owner wallets, read from the transaction's token balances. Empty when the
# transaction records no balance for that account.
sender: String!
receiver: String!
checked: Boolean!
}
Then add src/handlers/usdcTransfers.ts. It listens to the same two SPL Token instructions as the main handler file, and both files get every matching call.
Show src/handlers/usdcTransfers.ts
import { indexer } from "envio";
const USDC = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v";
const fields = {
instruction: ["args", "accounts", "path"],
transaction: ["signature", "transactionIndex"],
accountActivity: ["token.mint", "token.owner"],
block: ["time"],
} as const;
// A transfer moves tokens between token accounts. The wallet behind each one
// is the owner recorded in the transaction's token balances.
indexer.onInstruction(
{ program: "SplToken", instruction: "transferChecked", fields, where: { accounts: { mint: USDC } } },
async ({ instruction, context }) => {
const { source, destination } = instruction.accounts;
context.UsdcTransfer.set({
id: `${instruction.block.slot}-${instruction.transaction.transactionIndex}-${instruction.path.join(".")}`,
signature: instruction.transaction.signature,
slot: instruction.block.slot,
time: instruction.block.time,
depth: instruction.path.length - 1,
amount: instruction.args.amount,
sourceAccount: source.address,
destinationAccount: destination.address,
sender: source.activity?.token?.owner ?? "",
receiver: destination.activity?.token?.owner ?? "",
checked: true,
});
},
);
indexer.onInstruction(
{ program: "SplToken", instruction: "transfer", fields },
async ({ instruction, context }) => {
const { source, destination } = instruction.accounts;
if (source.activity?.token?.mint !== USDC && destination.activity?.token?.mint !== USDC) return;
context.UsdcTransfer.set({
id: `${instruction.block.slot}-${instruction.transaction.transactionIndex}-${instruction.path.join(".")}`,
signature: instruction.transaction.signature,
slot: instruction.block.slot,
time: instruction.block.time,
depth: instruction.path.length - 1,
amount: instruction.args.amount,
sourceAccount: source.address,
destinationAccount: destination.address,
sender: source.activity?.token?.owner ?? "",
receiver: destination.activity?.token?.owner ?? "",
checked: false,
});
},
);
We ran it over 10,000 slots, 445,800,000 to 445,809,999. It wrote 451,468 rows from 222,128 transactions, the same USDC count the Stablecoin table recorded for those slots. About 1% of rows had no sender wallet (4,429) and about 1% had no receiver wallet (4,290), because the transaction didn't record a balance for that account. We looked up the two largest rows below on a public explorer, and the signature, amount and both wallets matched.
One of those two rows has the same wallet as sender and receiver. That's a wallet moving USDC between two of its own token accounts, which is worth filtering out if you're counting payments between different people.
query LargestUsdcTransfers {
UsdcTransfer(order_by: { amount: desc }, limit: 3) {
signature
amount
sender
receiver
depth
checked
}
}
{
"UsdcTransfer": [
{
"signature": "5iAoKjxryR1bcLnYYpuByCixq7YvXy6LQjsjxtYZXxM82CNBF98xpibnyPdu7Hvz1XSz2Qd6ZDQoUTGhXbfV2aQ3",
"amount": "21116822110000",
"sender": "H8sMJSCQxfKiFTCfDR3DUMLPwcRbM61LGFJ8N4dK3WjS",
"receiver": "41zCUJsKk6cMB94DDtm99qWmyMZfp4GkAhhuz4xTwePu",
"depth": 0,
"checked": true
},
{
"signature": "5cVij85wDZx31yU8xCTHdfCk2P79vSE1teWsRFYeSa5PnwpbJmYVM3tjXmBvF9W237NJLdr2D76zrVX3Xc5KWVhc",
"amount": "21116822110000",
"sender": "41zCUJsKk6cMB94DDtm99qWmyMZfp4GkAhhuz4xTwePu",
"receiver": "41zCUJsKk6cMB94DDtm99qWmyMZfp4GkAhhuz4xTwePu",
"depth": 0,
"checked": true
},
{
"signature": "2tSsXhMHu9GNbzcHopzqxJXiXFNdN3bDSrvykFhREXZebtQ773in6v49TW4y5MnLQXfnjpZVhFms5CQyy7RJ3tur",
"amount": "16323710744333",
"sender": "BjYtrpedqFgveTy8u55fYadfhcepwMtneJQL9SkK6ZmU",
"receiver": "H8sMJSCQxfKiFTCfDR3DUMLPwcRbM61LGFJ8N4dK3WjS",
"depth": 0,
"checked": true
}
]
}
Failed transactions don't show up here. The HyperSync query docs say servers running the failed-transaction trim keep no instruction rows for failed transactions, and the public endpoint behaved that way when we checked. In slots 445,800,000 to 445,800,999 it listed 99,408 failed transactions and returned no transfer or transferChecked instructions from them, so every row here is a transfer that went through.
A day of USDC is more than 15 million rows. Keep the window short while you try it, or only write rows for the wallets you care about.
Replicate This With Your Own Agent
If you'd rather have your coding agent build this, the prompt below was written from the finished indexer and includes the problems we ran into along the way.
Give the agent the current docs first, so it works from today's syntax and not from memory.
claude mcp add --transport http envio-docs https://docs.envio.dev/mcp
Cursor and VS Code use the same endpoint in their MCP settings. An agent with shell access can use envio tools search-docs instead and skip MCP. Both are covered on the MCP server page.
Then paste this.
Show the full prompt
Build me an Envio HyperIndex indexer for stablecoin transfers on Solana mainnet.
Before writing any code, read the current docs rather than working from memory.
If you have the envio-docs MCP server, use docs_search and docs_fetch. Otherwise,
run `envio tools search-docs <query>` and `envio tools fetch-docs <url>`. Look up
the Solana configuration file, instruction handlers, and decoding pages first.
THE TOKENS AND THE PROGRAMS
Index these four mints.
USDC EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v SPL Token
USDT Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB SPL Token
PYUSD 2b1kV6DkPAnxd5ixfnxCpjxmKwqjjaYmCZfHsFu24GXo Token-2022
USDG 2u1tszSeqZ3qBWF3uNGPFc8TzMk2tdiwknnRMWGWjGWH Token-2022
SPL Token is TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA. Token-2022 is a separate
program, TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb. Configure both, or PYUSD and
USDG never arrive.
Declare these instructions inline on each program, with their discriminator,
accounts in order, and args.
transfer 0x03 source, destination, authority
args: amount u64
transferChecked 0x0c source, mint, destination, authority
args: amount u64, decimals u8
transferCheckedWithFee 0x1a01 source, mint, destination, authority
args: amount u64, decimals u8, fee u64
(Token-2022 only)
WHAT TO STORE
Per stablecoin, store transfer count, volume, how many came through plain
transfer versus the checked instructions, and call depth from the instruction
path (top level, depth 1, depth 2, depth 3 or more). Per stablecoin, also store
transfers and volume by size bucket, and transfers per hour. Store aggregates,
not one row per transfer. All four use 6 decimals.
PROJECT SETUP
mkdir solana-stablecoin-indexer && cd solana-stablecoin-indexer
pnpm init
pnpm add envio@next --allow-build=esbuild
pnpm add -D typescript @types/node
pnpm pkg set type=module
Put the handler in src/handlers/, which HyperIndex loads automatically. Put the
Envio API token from https://envio.dev/app/api-tokens in .env as
ENVIO_API_TOKEN, and add .env to .gitignore before your first commit. Run
`pnpm envio codegen`, then `pnpm envio dev`, which needs Docker running.
TRAPS THAT WILL COST YOU TIME
- Newer pnpm versions stop `pnpm add envio@next` with ERR_PNPM_IGNORED_BUILDS
because esbuild's build script is blocked. Pass --allow-build=esbuild.
- Add a tsconfig.json with "skipLibCheck": true and "include": ["src",
"envio-env.d.ts"], and run `pnpm exec tsc --noEmit` after codegen. Without
skipLibCheck, tsc reports errors from the type definition files inside
node_modules instead of from your handler.
- Plain transfer names no mint. Select accountActivity token.mint in the
handler's fields and read the mint from the source or the destination
account's activity, whichever carries one.
- transferChecked and transferCheckedWithFee name the mint, so filter them on
the server with where: { accounts: [{ mint: ... }] } instead of filtering in
the handler.
- transferCheckedWithFee is not byte 26 on its own. Byte 26 selects Token-2022's
transfer fee extension and byte 1 selects the call, so the discriminator is
0x1a01.
- A transfer moves tokens between token accounts, not wallets. If you need the
sender and receiver wallets, select accountActivity token.owner and read
instruction.accounts.source.activity.token.owner and the same on destination.
It is missing for a small share of rows, so allow for an empty value.
- Handlers receive top-level and inner calls by default. Select path in
fields.instruction to tell them apart. A transfer made from inside another
program is an inner call, so narrowing to top-level calls drops it.
- start_slot and end_slot are slot numbers. HyperSync's Solana history starts at
the earliest indexed slot, not genesis, and a start_slot before that starts
from the earliest indexed slot without an error. Read the head from
https://solana.hypersync.xyz/height and pick your window from there.
- envio dev reuses any Postgres already listening on port 5433. If another
project's indexer uses it, set ENVIO_PG_SCHEMA to a schema of your own so the
two sets of tables stay apart.
- If your API token has a rate limit and uses up its quota, HyperSync answers
HTTP 429 and the indexer retries without logging an error, so progress stops. If
progress_block in the envio_chains table stops moving, check the
x-ratelimit-remaining header with a curl -D - against the query endpoint
before debugging anything else.
- Running headless, set ENVIO_TUI=false. To skip Hasura, set ENVIO_HASURA=false.
WHEN IT RUNS, REPORT THESE SEPARATELY
1. The slot window you indexed and whether the indexer reached end_slot.
2. For each stablecoin, the transfer count and the share that came through
plain transfer.
3. For each stablecoin, the share of transfers that were top-level calls.
State the numbers you actually got, from the indexer's tables.
Deploying the Indexer
The finished indexer is on GitHub at enviodev/solana-stablecoin-indexer if you'd rather clone it than copy the files above.
To keep tracking transfers as they happen, remove end_slot. We tried start_slot: latest with no end_slot, and the indexer caught up, switched to realtime indexing and stayed within a few slots of the chain head for the couple of minutes we ran it.
When you want it online for good, push the repo to GitHub and deploy it on Envio Cloud, which runs the database, the GraphQL API and the sync for you.
Frequently Asked Questions
What Is the Solana RPC URL?
Solana's public mainnet RPC endpoint is https://api.mainnet.solana.com, and the devnet and testnet endpoints are https://api.devnet.solana.com and https://api.testnet.solana.com, all listed in Solana's cluster docs. Solana says these public endpoints are rate limited and not meant for production apps, and says to use dedicated or private RPC servers when you launch. For indexing instructions, HyperIndex doesn't need an RPC endpoint. It reads them from HyperSync at https://solana.hypersync.xyz for mainnet and https://solana-devnet.hypersync.xyz for devnet.
What Is the USDC Mint Address on Solana?
The USDC mint on Solana mainnet is EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v, listed on Circle's contract address page. It's an SPL Token mint with 6 decimals, so a raw amount of 1000000 is one USDC. Circle lists a separate devnet mint for testing on the same page.
What Is the Difference Between Transfer and TransferChecked on Solana?
Both move tokens from one token account to another. transferChecked also passes the mint account and the token's decimals, and the token program rejects the call if either is wrong. The SPL Token source says that check may be useful when building transactions offline or on a hardware wallet, and Token-2022 marks plain transfer as deprecated. Plain transfer only passes the source, destination and authority, so the instruction doesn't say which token moved. For an indexer, that means transferChecked can be filtered by mint directly, while transfer has to be matched to a mint through the token balances on the transaction.
Why Does My Indexer Miss PYUSD and USDG Transfers?
PYUSD and USDG are Token-2022 mints, and the Token-2022 program is separate from the original SPL Token program. An indexer that only listens to the SPL Token program never sees their instructions. Add Token-2022 as a second program with the same transfer and transferChecked instructions, plus transferCheckedWithFee with the two-byte discriminator 0x1a01.
How Do I Index Inner Instructions on Solana?
With HyperIndex, an onInstruction handler gets both top-level and inner calls of the program by default. Select path in fields.instruction to see where each call sits in the transaction. [0] is the first top-level instruction and [0, 1] is the second call made from inside it. Set where: { isInner: false } or where: { isInner: true } to get only one kind.
How Far Back Can I Index Solana With HyperSync?
HyperSync's Solana history starts at the earliest slot Envio has indexed rather than at genesis, and the docs say Envio keeps extending it backwards. Get the current head from https://solana.hypersync.xyz/height. To check whether a particular start slot is served, send the one-slot bounded query described in the configuration docs. For older history, the docs ask you to get in touch on Discord.
How Do I Track USDC Transfers on Solana?
Index both USDC instructions on the SPL Token program, transferChecked, which names the USDC mint, and plain transfer, which doesn't, and keep inner instructions as well as top-level ones. Match plain transfer to USDC through the token balances of the source or destination account. To see wallets rather than token accounts, read each account's owner from those same balances. This post's wallet tracking section has a handler that does all of this with HyperIndex.
Which Stablecoins Are on Solana?
Stablecoins on Solana include USDC and USDT on the SPL Token program, and PYUSD and USDG on Token-2022. Each issuer publishes its Solana mint address, and this post lists all four with links to the issuers' pages. This post tracks those four, and you can follow any other SPL Token or Token-2022 mint by adding it to the STABLECOINS list in the handler.
Build With Envio
Envio is a real-time multichain blockchain indexer that turns onchain data into a queryable GraphQL API. HyperIndex support for Solana is stable, and it runs on HyperSync out of the box, with handlers written in TypeScript. Start indexing Solana, deploy on Envio Cloud or self-host, and if you're building on Solana, come talk to us about your data needs.
Website | X | Discord | Telegram | GitHub | YouTube | Reddit
Jordyn Laurier

