Track CCTP V2 burn-and-mint USDC transfers across every chain in one ledger with Turbo, and catch in-flight transfers with Compose.
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.
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.
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.
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-burnsresource_size: msources: 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 burnssinks: 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.
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-mintsresource_size: msources: 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 mintssinks: 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.
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_secondsFROM cctp.burns bLEFT 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.
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.