> ## Documentation Index
> Fetch the complete documentation index at: https://docs.goldsky.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Cross-chain USDC settlement ledger

> 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](https://www.circle.com/blog/cctp-v2-the-future-of-cross-chain), 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](/turbo-pipelines/introduction) 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):

```mermaid theme={null}
flowchart LR
    A[User burns USDC<br/>on source chain] -->|DepositForBurn<br/>TokenMessenger| B[Iris attestation]
    B --> C[Mint USDC<br/>on destination chain]
    C -->|MintAndWithdraw<br/>MessageTransmitter| D[(Cross-chain ledger)]
    A -->|nonce + amount + destinationDomain| D
    D --> E{Matched by nonce?}
    E -->|yes| F[Settled]
    E -->|no, past SLA| G[In-flight / delayed]
```

* **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).

<Note>
  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](https://developers.circle.com/cctp).
</Note>

## Prerequisites

* The [Turbo CLI extension](/turbo-pipelines/cli#installation) installed and logged in.
* A Postgres database and a [Goldsky secret](/turbo-pipelines/pipeline-config#secrets).
* 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](/chains/supported-networks) 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](/turbo-pipelines/sources/evm#guide-multi-chain-monitoring) pattern with a decode step.

```yaml cctp-burns.yaml expandable theme={null}
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
```

<Warning>
  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.
</Warning>

## 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).

```yaml cctp-mints.yaml expandable theme={null}
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
```

<Note>
  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.
</Note>

## 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)`:

```sql theme={null}
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](/compose/introduction) task checks pending transfers against Circle's Iris attestation service and escalates anything unresolved.

```typescript src/tasks/cctp-watchdog.ts theme={null}
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](/compose/debugging) 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](https://www.circle.com/gateway).

## Resources

* [Multi-chain monitoring with Turbo](/turbo-pipelines/sources/evm#guide-multi-chain-monitoring)
* [Circle CCTP documentation](https://developers.circle.com/cctp)
* [Real-time reconciliation](/solutions/real-time-reconciliation)

Can't find what you're looking for? Reach out to us at [support@goldsky.com](mailto:support@goldsky.com) for help.
