By Hemanth·

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_transfers

The 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_transfers

One 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:

  • Burn on Ethereum (emitted by the TokenMessenger contract)

  • Mint on Base (emitted by the TokenMinter contract)

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_transfers

For 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_transfers

Turbo 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 DESC

One 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:

  1. Core indexing

  2. Multiple tokens, multiple chains

  3. Bridges

  4. Filtering

  5. VM differences

  6. 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.

1,840 words
Made with