The deceptively complex job of tracking stablecoins
As far as understanding onchain activity goes, stablecoins are about as simple as they get. from address, to address, token, and amount. They follow decade-old contract standards. That's the version that exists in our docs, but our customers who use us in production at Phantom, Privy, and more know that the task is considerably messier. This post walks through that complexity ladder, rung by rung.
1. One stablecoin on one chain
Let's start with the simplest case. USDC on Ethereum is a standard ERC20. Every transfer emits a Transfer(address indexed from, address indexed to, uint256 value) event from the contract at 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48.
To index this with Goldsky, you define a Mirror pipeline that listens to that event:
name: stablecoin-solana-usdc
resource_size: s
sources:
solana_usdc_transfers:
type: dataset
dataset_name: solana.token_transfers
version: 1.0.0
# Native USDC mint on Solana
filter: token_mint_address = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'
transforms:
usdc_transfers:
type: sql
primary_key: id
sql: |
SELECT
id,
signature AS tx_hash,
block_slot,
block_timestamp,
from_token_account,
to_token_account,
(CAST(amount AS DOUBLE) / 1e6) AS amount_usdc
FROM solana_usdc_transfers
sinks:
out:
type: blackhole
from: usdc_transfersThe output is clean: from, to, value, block_number, transaction_hash. Pipe it into Postgres or your data warehouse. This works. This is the easy part.
The problem is that this is almost never the complete picture.
2. Multiple chains, multiple coins
USDC doesn't live on one chain. Circle has deployed native USDC on Ethereum, Base, Arbitrum, Optimism, Polygon, Avalanche, Solana, and more. USDT has a similar footprint. Each deployment is a separate contract with a separate address.
For a data engineer at a payments company or custodian, "track USDC transfers" means tracking all of them: a different contract address per chain, a different RPC endpoint per chain, a different indexing job per chain, unless your infrastructure handles multi-chain natively.
Here's what the contract landscape looks like for USDC and USDT across three chains:
Chain | USDC | USDT |
|---|---|---|
Ethereum | 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 | 0xdAC17F958D2ee523a2206206994597C13D831ec7 |
Base | 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 | -- |
Optimism | 0x0b2C639c533813f4Aa9D7837CAF62653d097Ff85 | 0x94b008aA00579c1307B0EF2c499aD98a8ce58e58 |
With Goldsky Mirror, you define each source independently and route them into the same destination table, tagged by chain:
name: stablecoin-multichain
resource_size: s
sources:
ethereum_transfers:
type: dataset
dataset_name: ethereum.erc20_transfers
version: 1.2.0
start_at: latest
filter: >-
address IN (
'0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48',
'0xdac17f958d2ee523a2206206994597c13d831ec7'
)
base_transfers:
type: dataset
dataset_name: base.erc20_transfers
version: 1.2.0
start_at: latest
filter: address = '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913'
optimism_transfers:
type: dataset
dataset_name: optimism.erc20_transfers
version: 1.2.0
start_at: latest
filter: >-
address IN (
'0x0b2c639c533813f4aa9d7837caf62653d097ff85',
'0x94b008aa00579c1307b0ef2c499ad98a8ce58e58'
)
transforms:
stablecoin_transfers:
type: sql
primary_key: id
sql: |
SELECT 'ethereum' AS chain, id, address AS token,
sender AS from_address, recipient AS to_address,
amount, block_number, block_timestamp, transaction_hash
FROM ethereum_transfers
UNION ALL
SELECT 'base' AS chain, id, address AS token,
sender AS from_address, recipient AS to_address,
amount, block_number, block_timestamp, transaction_hash
FROM base_transfers
UNION ALL
SELECT 'optimism' AS chain, id, address AS token,
sender AS from_address, recipient AS to_address,
amount, block_number, block_timestamp, transaction_hash
FROM optimism_transfers
sinks:
out:
type: blackhole
from: stablecoin_transfersOne stablecoin_transfers table. One chain column. Cross-chain queries become trivial. But we're unfortunately still at the easy part.
3. Bridged tokens
Things start to get complicated from here. When USDC moves from Ethereum to Base via Circle's Cross-Chain Transfer Protocol (CCTP), the mechanics are not a standard ERC20 transfer. CCTP burns USDC on the source chain and mints it on the destination chain. Two events:
Burnon Ethereum (emitted by theTokenMessengercontract)Minton Base (emitted by theTokenMintercontract)
These are separate transactions on separate chains with no shared transaction hash. The only link between them is a nonce embedded in the burn event and a MessageSent event from the MessageTransmitter contract.
If you're only watching Transfer events, you'll see a transfer to the zero address on Ethereum (the burn) and a transfer from the zero address on Base (the mint). Two unrelated events. No indication they represent the same movement of capital.
For institutions reconciling cross-chain flows: treasury operations, compliance monitoring, liquidity tracking - this gap matters enormously. The fix is indexing the CCTP contracts alongside the token contracts:
# Add to the multi-chain pipeline above.
sources:
# ... ethereum_transfers, base_transfers, optimism_transfers as before
ethereum_cctp_logs:
type: dataset
dataset_name: ethereum.raw_logs
version: 1.2.0
start_at: latest
filter: address = '0xbd3fa81b58ba92a82136038b25adec7066af3155' # TokenMessenger
base_cctp_logs:
type: dataset
dataset_name: base.raw_logs
version: 1.0.0
start_at: latest
filter: address = '0xe45b133ddc64be80252b0e9c75a8e74ef280eed6' # TokenMinter
transforms:
# Decode the burn leg on Ethereum. _gs_log_decode takes an ABI fragment
# plus the raw `topics` + `data` columns and returns a struct whose
# event_params are 1-indexed, matching the ABI input order.
cctp_burns:
type: sql
primary_key: id
sql: |
SELECT
id, block_number, block_timestamp, transaction_hash,
decoded.event_params[1] AS nonce,
decoded.event_params[2] AS burn_token,
decoded.event_params[3] AS amount,
decoded.event_params[4] AS depositor,
decoded.event_params[5] AS mint_recipient,
decoded.event_params[6] AS destination_domain
FROM (
SELECT
_gs_log_decode(
'[{"type":"event","name":"DepositForBurn","inputs":[
{"indexed":true,"name":"nonce","type":"uint64"},
{"indexed":true,"name":"burnToken","type":"address"},
{"indexed":false,"name":"amount","type":"uint256"},
{"indexed":true,"name":"depositor","type":"address"},
{"indexed":false,"name":"mintRecipient","type":"bytes32"},
{"indexed":false,"name":"destinationDomain","type":"uint32"},
{"indexed":false,"name":"destinationTokenMessenger","type":"bytes32"},
{"indexed":false,"name":"destinationCaller","type":"bytes32"}]}]',
topics, `data`
) AS decoded,
id, block_number, block_timestamp, transaction_hash
FROM ethereum_cctp_logs
)
WHERE decoded.event_signature = 'DepositForBurn'
# Decode the mint leg on Base. Same pattern, different ABI.
cctp_mints:
type: sql
primary_key: id
sql: |
SELECT
id, block_number, block_timestamp, transaction_hash,
decoded.event_params[1] AS mint_recipient,
decoded.event_params[2] AS amount,
decoded.event_params[3] AS mint_token
FROM (
SELECT
_gs_log_decode(
'[{"type":"event","name":"MintAndWithdraw","inputs":[
{"indexed":true,"name":"mintRecipient","type":"address"},
{"indexed":false,"name":"amount","type":"uint256"},
{"indexed":true,"name":"mintToken","type":"address"}]}]',
topics, `data`
) AS decoded,
id, block_number, block_timestamp, transaction_hash
FROM base_cctp_logs
)
WHERE decoded.event_signature = 'MintAndWithdraw'Postgres aggregates and joins with TTLs can be used to collate these with each other in-stream, meaning you don't need to do ANY downstream reconciliation or reconstruction. The data model shifts from "a list of on-chain events that I need to do a bunch of work on" to "a list of economic transfers" which is what analysts and compliance teams actually need.
4.Filtering
A naive implementation indexes every USDC transfer on every chain. That's millions of rows per day. For most institutional use cases, you don't need all of them: you need transfers touching a specific set of addresses: customer wallets, treasury addresses, smart contract vaults.
Two approaches, with different tradeoffs.
Post-hoc filtering means indexing everything and filtering in SQL:
SELECT *
FROM stablecoin_transfers
WHERE "from" IN (SELECT address FROM monitored_addresses)
OR "to" IN (SELECT address FROM monitored_addresses)Simple, but expensive at scale. You're storing and querying data you don't need. Goldsky has a unique advanced function called Dynamic Tables to help manage this - allowing you to index only events involving a changing list of addresses you care about. If that list changes, a simple API call is all you need; no ETL reconfiguration necessary.
transforms:
# A Postgres-backed lookup table. Insert/update/delete rows in
# streamling.monitored_addresses and the pipeline picks up the
# change immediately - no redeploy.
monitored_addresses:
type: dynamic_table
backend_type: Postgres
backend_entity_name: monitored_addresses
secret_name: MY_POSTGRES_SECRET
tracked_usdc_transfers:
type: sql
primary_key: id
sql: |
SELECT id, address AS token, sender AS from_address,
recipient AS to_address, amount,
block_number, block_timestamp, transaction_hash
FROM ethereum_transfers
WHERE dynamic_table_check('monitored_addresses', sender)
OR dynamic_table_check('monitored_addresses', recipient)
sinks:
out:
type: blackhole
from: tracked_usdc_transfersFor a custodian like Fireblocks managing thousands of customer addresses, the right architecture is a hybrid: index all transfers into a raw table, maintain a separate monitored_addresses table, and run a continuous join. Goldsky's pipeline transforms support this pattern directly and natively, saving you the work downstream: filtered views stays current without a separate batch job.
5. The VM Jump
Everything above assumes EVM. Solana, Canton, Stellar, Zama break all of those assumptions. USDC on Solana is an SPL token, not an ERC20. On EVM, a token contract holds a mapping of address → balance. Alice sends USDC to Bob, the contract emits a Transfer event, you index the event.
On Solana, there is no single contract holding balances. Each wallet has a token account, a separate on-chain account holding a specific token for a specific owner. When Alice sends USDC to Bob, the SPL token program processes an instruction that debits Alice's token account and credits Bob's. USDC on Solana has a mint address of EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v. But in a transfer instruction, the from and to fields are token account addresses, not wallet addresses. Resolving the actual wallet owner requires a separate account lookup.
This is why most data pipelines treat Solana as a completely separate system. The indexing logic, the data model, and the query patterns are all different. Most teams end up maintaining a distinct codebase for each VM they operate on: EVM, SVM, etc., and reconciling them manually.
But Goldsky Turbo is VM-agnostic. The same pipeline abstraction that handles EVM event logs handles Solana instructions. One system, not two.
For Solana USDC transfers, a Turbo pipeline looks like this:
name: stablecoin-eth-usdc
resource_size: s
sources:
ethereum_usdc_transfers:
type: dataset
dataset_name: ethereum.erc20_transfers
version: 1.2.0
start_at: latest
# Pre-filter at the source for efficiency
filter: address = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'
transforms:
usdc_transfers:
type: sql
primary_key: id
sql: |
SELECT id, sender AS from_address, recipient AS to_address,
amount, block_number, transaction_hash
FROM ethereum_usdc_transfers
sinks:
out:
# Swap for postgres, clickhouse, kafka, s3, etc. when ready.
type: blackhole
from: usdc_transfersTurbo handles instruction parsing, token account resolution, and owner lookup. What lands in your database is a normalized row with from_owner, to_owner, amount, slot, and signature: the Solana equivalents of the EVM fields you're already working with.
Your unified stablecoin_transfers table now has rows from Ethereum, Base, and Solana in the same schema:
SELECT
chain,
from_address,
to_address,
amount / 1e6 AS amount_usdc,
block_time,
tx_hash
FROM stablecoin_transfers
WHERE block_time > NOW() - INTERVAL '24 hours'
ORDER BY block_time DESCOne query. No chain-specific logic. No separate Solana pipeline to maintain.
6. Making changes
Most blockchain data providers give you an API. You query their infrastructure, you get rate-limited, you pay per query, and you have no control over the data model. But over time, your needs evolve, as you factor in:
new standards (eg. TIP20, ERC8056 with scaling tokens)
wrapped tokens and unofficial token representations
real owners vs. onchain accounts (eg. pool to which a different wallet has an underlying claim)
Goldsky Turbo makes this easy be letting you own the data, and letting you adapt what you stream in on an ongoing basis. The data is yours - in whatever database you choose - Postgres, Snowflake, BigQuery, ClickHouse, and more. You can join stablecoin transfer records against internal customer tables without exporting anything. You can build real-time dashboards without hitting an external API on every page load. Historical compliance queries run against your own infrastructure, at your own cost structure.
Putting It All Together
The "simple" problem of tracking stablecoin transfers has at least five distinct layers of complexity:
Core indexing
Multiple tokens, multiple chains
Bridges
Filtering
VM differences
Making changes over time
Each layer is solvable in isolation, but the challenge for data engineers at institutions is solving all six simultaneously in a system that's maintainable, real-time, and doesn't require a separate codebase for every chain. Goldsky's combination Turbo, Postgres Aggregates, and Dynamic Tables - all streaming data info your own DB - is the infrastructure layer that makes this tractable. Define your sources, your transforms, and your destination once. The pipeline handles the rest, across chains, across VMs, continuously.
The stablecoin transfer problem isn't simple. But it doesn't have to be as hard as most teams find it when building from scratch.
Ready to start indexing? The Goldsky documentation covers Turbo setup, dynamic tables, and more. And if you'd like some help getting started, don't hesitate to reach out.