Skip to main content
Stablecoin liquidity does not live on one chain. With Circle’s CCTP V2, USDC moves natively across 13+ chains by burning on the source and minting on the destination - no wrapped tokens, no bridge pools. That is great for liquidity and a problem for whoever has to account for it: a single dollar can be burned on Ethereum but not yet minted on Base, and your cash position is now split across two ledgers and a few minutes of uncertainty. This guide builds one cross-chain settlement ledger. A multi-chain Turbo pipeline indexes every burn and every mint, matches them by nonce, and gives your treasury a single table showing exactly where every dollar is - settled, or in-flight.

How CCTP transfers work

Every CCTP transfer produces two onchain events on two different chains, correlated by a nonce (and a message hash):
  • Source: DepositForBurn on the TokenMessenger contract - carries the nonce, burn amount, destinationDomain, and mintRecipient.
  • Destination: MintAndWithdraw on the MessageTransmitter/TokenMinter - carries the matching nonce and mint amount.
  • In-flight means the burn exists but the mint has not landed yet. Fast Transfers resolve in seconds; Standard Transfers wait for source-chain hard finality (~13-19 minutes on Ethereum).
CCTP identifies each chain by a numeric domain ID, not a chain ID: Ethereum 0, Avalanche 1, Optimism 2, Arbitrum 3, Noble 4, Solana 5, Base 6, Polygon PoS 7. Confirm the current list and contract addresses in Circle’s CCTP docs.

Prerequisites

  • The Turbo CLI extension installed and logged in.
  • A Postgres database and a Goldsky secret.
  • The TokenMessenger and MessageTransmitter contract addresses for each chain you settle on (from Circle’s docs). CCTP V2 is live on Ethereum, Base, Arbitrum, Optimism, Polygon, and Avalanche - all supported by Goldsky across every product.

Step 1: Index burns across every source chain

Stream DepositForBurn from the TokenMessenger on each chain and combine them with UNION ALL. This is the multi-chain monitoring pattern with a decode step.
cctp-burns.yaml
name: cctp-burns
resource_size: m

sources:
  ethereum_logs:
    type: dataset
    dataset_name: ethereum.raw_logs
    version: 1.2.0
    start_at: latest
    filter: address = lower('0xETH_TOKENMESSENGER')
  base_logs:
    type: dataset
    dataset_name: base.raw_logs
    version: 1.0.0
    start_at: latest
    filter: address = lower('0xBASE_TOKENMESSENGER')
  arbitrum_logs:
    type: dataset
    dataset_name: arbitrum_one.raw_logs
    version: 1.0.0
    start_at: latest
    filter: address = lower('0xARB_TOKENMESSENGER')

transforms:
  burns:
    type: sql
    primary_key: id
    sql: |
      SELECT id, 0 AS source_domain, decoded, block_timestamp, transaction_hash, _gs_op
      FROM (
        SELECT id,
          _gs_log_decode(_gs_fetch_abi('https://raw.githubusercontent.com/your-org/abis/main/token-messenger.json', 'raw'), topics, data) AS decoded,
          block_timestamp, transaction_hash, _gs_op
        FROM ethereum_logs
      ) WHERE decoded IS NOT NULL AND decoded.event_signature = 'DepositForBurn'

      UNION ALL

      SELECT id, 6 AS source_domain, decoded, block_timestamp, transaction_hash, _gs_op
      FROM (
        SELECT id,
          _gs_log_decode(_gs_fetch_abi('https://raw.githubusercontent.com/your-org/abis/main/token-messenger.json', 'raw'), topics, data) AS decoded,
          block_timestamp, transaction_hash, _gs_op
        FROM base_logs
      ) WHERE decoded IS NOT NULL AND decoded.event_signature = 'DepositForBurn'

      UNION ALL

      SELECT id, 3 AS source_domain, decoded, block_timestamp, transaction_hash, _gs_op
      FROM (
        SELECT id,
          _gs_log_decode(_gs_fetch_abi('https://raw.githubusercontent.com/your-org/abis/main/token-messenger.json', 'raw'), topics, data) AS decoded,
          block_timestamp, transaction_hash, _gs_op
        FROM arbitrum_logs
      ) WHERE decoded IS NOT NULL AND decoded.event_signature = 'DepositForBurn'

  burns_clean:
    type: sql
    primary_key: id
    sql: |
      SELECT
        id,
        source_domain,
        decoded.event_params[1]  AS nonce,
        CAST(decoded.event_params[3] AS DECIMAL(38, 0)) AS amount,
        CAST(decoded.event_params[4] AS INT) AS destination_domain,
        decoded.event_params[5]  AS mint_recipient,
        to_timestamp(block_timestamp) AS burned_at,
        transaction_hash AS burn_tx,
        _gs_op
      FROM burns

sinks:
  cctp_burns:
    type: postgres
    from: burns_clean
    schema: cctp
    table: burns
    secret_name: MY_POSTGRES
    primary_key: id
The event_params[n] positions above follow the CCTP ABI field order - confirm them against the ABI you fetch, since V1 and V2 differ. Fetching the ABI with _gs_fetch_abi (rather than hardcoding) keeps the pipeline correct across upgrades.

Step 2: Index mints on every destination chain

Mirror Step 1 for the destination side. Exactly as each source chain tagged its burns with a constant source_domain, each destination chain tags its mints with its own destination_domain - the column Step 3 joins on, so it must be persisted. The nonce comes from the MessageReceived event on each chain’s MessageTransmitter (the MintAndWithdraw event does not carry it).
cctp-mints.yaml
name: cctp-mints
resource_size: m

sources:
  ethereum_mints:
    type: dataset
    dataset_name: ethereum.raw_logs
    version: 1.2.0
    start_at: latest
    filter: address = lower('0xETH_MESSAGETRANSMITTER')
  base_mints:
    type: dataset
    dataset_name: base.raw_logs
    version: 1.0.0
    start_at: latest
    filter: address = lower('0xBASE_MESSAGETRANSMITTER')
  arbitrum_mints:
    type: dataset
    dataset_name: arbitrum_one.raw_logs
    version: 1.0.0
    start_at: latest
    filter: address = lower('0xARB_MESSAGETRANSMITTER')

transforms:
  mints:
    type: sql
    primary_key: id
    sql: |
      -- Tag each chain's mints with its own domain (the destination_domain)
      SELECT id, 0 AS destination_domain, decoded, block_timestamp, transaction_hash, _gs_op
      FROM (
        SELECT id,
          _gs_log_decode(_gs_fetch_abi('https://raw.githubusercontent.com/your-org/abis/main/message-transmitter.json', 'raw'), topics, data) AS decoded,
          block_timestamp, transaction_hash, _gs_op
        FROM ethereum_mints
      ) WHERE decoded IS NOT NULL AND decoded.event_signature = 'MessageReceived'

      UNION ALL

      SELECT id, 6 AS destination_domain, decoded, block_timestamp, transaction_hash, _gs_op
      FROM (
        SELECT id,
          _gs_log_decode(_gs_fetch_abi('https://raw.githubusercontent.com/your-org/abis/main/message-transmitter.json', 'raw'), topics, data) AS decoded,
          block_timestamp, transaction_hash, _gs_op
        FROM base_mints
      ) WHERE decoded IS NOT NULL AND decoded.event_signature = 'MessageReceived'

      UNION ALL

      SELECT id, 3 AS destination_domain, decoded, block_timestamp, transaction_hash, _gs_op
      FROM (
        SELECT id,
          _gs_log_decode(_gs_fetch_abi('https://raw.githubusercontent.com/your-org/abis/main/message-transmitter.json', 'raw'), topics, data) AS decoded,
          block_timestamp, transaction_hash, _gs_op
        FROM arbitrum_mints
      ) WHERE decoded IS NOT NULL AND decoded.event_signature = 'MessageReceived'

  mints_clean:
    type: sql
    primary_key: id
    sql: |
      SELECT
        id,
        destination_domain,                                     -- chain this mint landed on
        CAST(decoded.event_params[2] AS INT) AS source_domain,  -- origin domain (from MessageReceived)
        decoded.event_params[3]  AS nonce,                      -- per-source-domain nonce
        to_timestamp(block_timestamp) AS minted_at,
        transaction_hash AS mint_tx,
        _gs_op
      FROM mints

sinks:
  cctp_mints:
    type: postgres
    from: mints_clean
    schema: cctp
    table: mints
    secret_name: MY_POSTGRES
    primary_key: id
Confirm the MessageReceived event name and event_params positions against the ABI you fetch - they differ between CCTP V1 and V2. CCTP nonces are scoped per source domain, so the join key is (source_domain, nonce) - both come from MessageReceived, while destination_domain records the chain the mint landed on. Amount and recipient already live on the burn row, so the mint row only needs those keys plus timing. A chain is both a source and a destination, so you can fold burns and mints into one pipeline with more sinks, or keep them split for clarity.

Step 3: Reconcile into one ledger

With both tables filling in real time, the cross-chain ledger is a single join on the message identity, (source_domain, nonce):
SELECT
  b.nonce,
  b.source_domain,
  b.destination_domain,
  b.amount,
  b.burned_at,
  m.minted_at,
  m.mint_tx,
  CASE
    WHEN m.nonce IS NOT NULL                              THEN 'settled'
    WHEN b.burned_at < NOW() - INTERVAL '20 minutes'      THEN 'delayed'
    ELSE 'in_flight'
  END AS status,
  EXTRACT(EPOCH FROM (m.minted_at - b.burned_at)) AS settlement_seconds
FROM cctp.burns b
LEFT JOIN cctp.mints m
  ON b.nonce = m.nonce
 AND b.source_domain = m.source_domain;
You now have, in one query:
  • Where every dollar is - settled on the destination, or in-flight between chains.
  • Your true cross-chain cash position - sum settled balances per domain, plus in-flight amounts by destination.
  • Corridor analytics - volume and settlement latency by (source_domain → destination_domain).
  • Stuck-transfer detection - anything delayed past your SLA.

Step 4: Alert on stuck transfers with Compose

Turn delayed from a dashboard row into an action. A scheduled Compose task checks pending transfers against Circle’s Iris attestation service and escalates anything unresolved.
src/tasks/cctp-watchdog.ts
import type { TaskContext } from "compose";

export async function main(context: TaskContext) {
  const { fetch } = context;

  // Burns with no matching mint past SLA, from your ledger service (backed by
  // the Step 3 reconciliation view). nonce, sourceDomain, and burnTx all come
  // straight from the cctp.burns table - no extra fields needed.
  const pending = await fetch<{ nonce: string; sourceDomain: number; burnTx: string }[]>(
    "https://ledger.example.com/cctp/in-flight?olderThanMinutes=20",
  );

  for (const t of pending) {
    // Iris v2 looks up a transfer by source domain + burn transaction hash, and
    // reports whether the attestation is ready ("complete") or still pending.
    const res = await fetch<{ messages: { status: string }[] }>(
      `https://iris-api.circle.com/v2/messages/${t.sourceDomain}?transactionHash=${t.burnTx}`,
      { max_attempts: 3, initial_interval_ms: 1000, backoff_factor: 2 },
    );

    const status = res.messages?.[0]?.status ?? "not_found";
    if (status !== "complete") {
      await fetch("https://ops.example.com/alerts/cctp-stuck", {
        method: "POST",
        body: JSON.stringify({ nonce: t.nonce, burnTx: t.burnTx, status }),
      });
    }
  }

  return { checked: pending.length };
}
Every check is traced and the task retries durably - so the watchdog itself never silently dies.

Business outcomes

  • One cross-chain cash position instead of a spreadsheet per chain.
  • In-flight visibility - you know the moment a transfer is late, with the exact nonce to investigate.
  • Corridor intelligence - real settlement times and volumes per route, to tune where you hold liquidity.
  • Works with Circle Gateway too - the same burn/mint ledger underpins reporting on unified USDC balances.

Resources

Can’t find what you’re looking for? Reach out to us at support@goldsky.com for help.