Skip to content
New guideAgent Cookbook 12 copy-ready prompts for AI coding agents.See recipes
All blog posts
Track USDC Transfers Into and Out of Arc: Match CCTP V2 Burns and Mints with eth_getLogs

Track USDC Transfers Into and Out of Arc: Match CCTP V2 Burns and Mints with eth_getLogs

By Ben Chatwin25min read

A treasury wallet on Arc receives 10,000 USDC. The explorer shows a Transfer from the zero address. That shows a mint, but not where the USDC came from. It came from a burn on Base 23 minutes earlier, in a different transaction on a different chain. Circle's Cross-Chain Transfer Protocol (CCTP) moves USDC by burning it on one chain and minting it on another, and the two transactions share no hash.

This guide builds a TypeScript tracker that finds both legs with eth_getLogs. It reads CCTP V2 burns and mints on Arc and Base and decodes the events. Then it matches each burn to its mint and reports burns that never minted. Everything runs on Arc mainnet (chain ID 5042, CCTP domain 26) and Base mainnet. Every hash below came from a run against Dwellir's endpoints on 11 November 2026.

In CCTP V2 the source chain never sees the transfer's nonce. Circle assigns it at attestation, so the burn event contains zeros. The tracker matches the legs on the fields both sides contain, and checks the result against Circle's attestation API.

What You Will Learn

  • Read CCTP V2 DepositForBurn and MessageReceived events on Arc and Base with eth_getLogs
  • Page through eth_getLogs inside a 10,000-block limit and split windows that return too much
  • Decode the BurnMessageV2 body of a mint into amount, recipient, sender and fee
  • Match a burn on one chain to its mint on the other without the nonce
  • Tell a transfer waiting for attestation from one that is attested but never minted

Prerequisites

  • Node.js 20 or later (node --version). We ran the code on Node.js 24 with viem 2.57 and tsx 4.
  • A Dwellir API key for the Arc and Base endpoints. Create a free account if you do not have one.
  • Familiarity with TypeScript, async/await and EVM event logs.
  • For background on Arc itself, read What is Arc?. For how USDC balances and logs work on Arc, read the financial apps on Arc guide.

Set up the project:

Bash
mkdir arc-cctp-tracker && cd arc-cctp-tracker
npm init -y && npm pkg set type=module
npm install viem tsx typescript @types/node
mkdir src

Put the endpoints in your shell. The key stays out of the code:

Bash
export ARC_RPC_URL=https://api-arc-mainnet.n.dwellir.com/YOUR_API_KEY
export BASE_RPC_URL=https://api-base-mainnet-archive.n.dwellir.com/YOUR_API_KEY

How a CCTP Transfer Looks on Chain

A CCTP V2 transfer has three steps:

  1. The sender calls depositForBurn on TokenMessengerV2 on the source chain. The contract burns the USDC and emits a message.
  2. Circle's attestation service (Iris) waits until the source block reaches the finality the sender asked for. Then it signs the message and assigns it a nonce.
  3. Someone submits the signed message to receiveMessage on MessageTransmitterV2 on the destination chain. The contract mints the USDC to the recipient.
A CCTP V2 transfer of 10,000 USDC from Base to Arc: burn on Base with a zero nonce, Circle attests and assigns the nonce, mint on Arc, then the tracker matches the two legs. Standard transfers took 6 to 36 seconds from Arc to Base and 17 to 23 minutes from Base to Arc.

Each step leaves logs. These are the ones the tracker reads:

ChainContractEventWhat it gives you
SourceTokenMessengerV2DepositForBurnAmount, sender, recipient, destination domain, max fee, hook data
SourceMessageTransmitterV2MessageSentThe raw message, with the nonce as 32 zero bytes
DestinationMessageTransmitterV2MessageReceivedSource domain, the nonce, and the burn message body
DestinationTokenMessengerV2MintAndWithdrawRecipient, net amount minted, fee collected

CCTP V2 uses the same contract addresses on Arc and Base mainnet, and on most of the other EVM chains it supports:

ContractAddress
TokenMessengerV20x28b5a0e9C621a5BadaA536219b3a228C8168cf5d
MessageTransmitterV20x81D40F21F12A8F0E3252Bccb954D722d4c464B64
TokenMinterV20xfd78EE919681417d192449715b2594ab58f5D002

CCTP identifies chains by its own domain numbers, not chain IDs: Ethereum is 0, Base is 6 and Arc is 26. Arc testnet runs CCTP under the same domain number at different addresses, listed in Arc's contract addresses. This guide uses mainnet only.

On Arc, a burn also appears in the EIP-7708 system logs as a native USDC Transfer to TokenMinterV2 and then to the zero address. A mint appears as a Transfer from the zero address at 18 decimals. An indexer that follows the system emitter, as in the financial apps guide, sees each burn and mint but not the chain on the other side. The CCTP events record that chain.

Set Up the Chain Clients

Start with the two chains, their CCTP domains and the contract addresses. Create src/chains.ts:

TypeScript
// chains.ts
import { createPublicClient, http, type PublicClient } from 'viem'

// CCTP domain IDs are Circle's own numbering, not chain IDs.
export const DOMAIN = { ethereum: 0, base: 6, arc: 26 } as const

// CCTP V2 contracts share these addresses on Arc and Base mainnet.
export const TOKEN_MESSENGER = '0x28b5a0e9C621a5BadaA536219b3a228C8168cf5d'
export const MESSAGE_TRANSMITTER = '0x81D40F21F12A8F0E3252Bccb954D722d4c464B64'

export type Side = { name: string; domain: number; client: PublicClient }

// Read-only clients. No chain object is needed to read logs and blocks.
export const ARC: Side = {
  name: 'Arc',
  domain: DOMAIN.arc,
  client: createPublicClient({ transport: http(process.env.ARC_RPC_URL) }),
}

export const BASE: Side = {
  name: 'Base',
  domain: DOMAIN.base,
  client: createPublicClient({ transport: http(process.env.BASE_RPC_URL) }),
}

Neither client gets a chain object. Reading logs and blocks does not need one, and leaving it out keeps Base's OP Stack transaction types out of the shared Side type.

Page Through eth_getLogs

Dwellir's Arc and Base endpoints both cap eth_getLogs at 10,000 blocks per request. Ask for 10,001 and the node answers with error -32005:

Text
eth_getLogs range of 10001 blocks exceeds the 10000-block limit for this plan; split the request into ranges of at most 10000 blocks

At Arc's 0.5 second blocks, 10,000 blocks is about 83 minutes. On Base, which has produced a block every 200 ms since 5 November 2026, it is about 33 minutes. CCTP traffic is light, so a 10,000-block window of these two contracts returns a few dozen logs. A busy window can still hit a result count cap or viem's 10 MB response limit. The helper below walks the range in 10,000-block windows and halves any window that fails for one of those reasons. viem retries a -32005 error a few times before the error reaches the helper. On a plan with a lower block limit, set MAX_RANGE to that limit.

Create src/logs.ts:

TypeScript
// logs.ts
import {
  InvalidParamsRpcError,
  LimitExceededRpcError,
  ResponseBodyTooLargeError,
  type AbiEvent,
  type Address,
  type GetLogsReturnType,
  type PublicClient,
} from 'viem'

const MAX_RANGE = 10_000n // Dwellir's eth_getLogs limit on Arc and Base

// A node refused the range or the response was too big: split and retry.
function shouldSplit(err: unknown) {
  if (err instanceof ResponseBodyTooLargeError) return true
  if (err instanceof LimitExceededRpcError) return true // -32005 range limit
  return err instanceof InvalidParamsRpcError && err.details.includes('max results')
}

async function fetchRange<E extends AbiEvent>(
  client: PublicClient,
  address: Address,
  event: E,
  fromBlock: bigint,
  toBlock: bigint,
): Promise<GetLogsReturnType<E, [E], true>> {
  try {
    return await client.getLogs({ address, event, fromBlock, toBlock, strict: true })
  } catch (err) {
    if (!shouldSplit(err) || fromBlock === toBlock) throw err
    const mid = (fromBlock + toBlock) / 2n
    return [
      ...(await fetchRange(client, address, event, fromBlock, mid)),
      ...(await fetchRange(client, address, event, mid + 1n, toBlock)),
    ]
  }
}

// Walk [fromBlock, toBlock] in windows the node accepts.
export async function getLogsPaged<E extends AbiEvent>(
  client: PublicClient,
  address: Address,
  event: E,
  fromBlock: bigint,
  toBlock: bigint,
) {
  const out: GetLogsReturnType<E, [E], true> = []
  for (let start = fromBlock; start <= toBlock; start += MAX_RANGE) {
    const end = start + MAX_RANGE - 1n < toBlock ? start + MAX_RANGE - 1n : toBlock
    out.push(...(await fetchRange(client, address, event, start, end)))
  }
  return out
}

// First block with timestamp >= ts, by binary search.
export async function blockAtTime(client: PublicClient, ts: bigint) {
  let lo = 0n
  let hi = await client.getBlockNumber()
  while (lo < hi) {
    const mid = (lo + hi) / 2n
    const { timestamp } = await client.getBlock({ blockNumber: mid })
    if (timestamp < ts) lo = mid + 1n
    else hi = mid
  }
  return lo
}

blockAtTime converts a timestamp into a block number on either chain with a binary search over eth_getBlockByNumber. The two chains produce blocks at different rates, so the tracker works in time and converts to blocks per chain.

Decode the Burn

The burn side needs three event definitions and one function that converts either leg into the same matching key. Create src/events.ts:

TypeScript
// events.ts
import { encodePacked, keccak256, parseAbiItem, sliceHex, type Hex } from 'viem'

export const depositForBurn = parseAbiItem(
  'event DepositForBurn(address indexed burnToken, uint256 amount, address indexed depositor, bytes32 mintRecipient, uint32 destinationDomain, bytes32 destinationTokenMessenger, bytes32 destinationCaller, uint256 maxFee, uint32 indexed minFinalityThreshold, bytes hookData)',
)
export const messageReceived = parseAbiItem(
  'event MessageReceived(address indexed caller, uint32 sourceDomain, bytes32 indexed nonce, bytes32 sender, uint32 indexed finalityThresholdExecuted, bytes messageBody)',
)
// bytes32 -> address (CCTP pads EVM addresses to 32 bytes)
export const toAddress = (b: Hex) => sliceHex(b, 12).toLowerCase() as Hex

// The fields both legs carry. The nonce is not one of them: Circle assigns it
// at attestation, so the source chain only ever sees zeros.
export function transferKey(t: {
  sourceDomain: number
  destinationDomain: number
  burnToken: Hex
  depositor: Hex
  mintRecipient: Hex
  amount: bigint
  maxFee: bigint
  hookData: Hex
}) {
  return keccak256(
    encodePacked(
      ['uint32', 'uint32', 'address', 'address', 'address', 'uint256', 'uint256', 'bytes32'],
      [
        t.sourceDomain,
        t.destinationDomain,
        t.burnToken,
        t.depositor,
        t.mintRecipient,
        t.amount,
        t.maxFee,
        keccak256(t.hookData),
      ],
    ),
  )
}

The key is a hash of the fields that a burn and its mint both contain. Those fields are the source and destination domain, the burned token, the address that called depositForBurn, the mint recipient, the gross amount, the max fee and the hook data. It leaves out three fields on purpose. The nonce is zero on the source chain. Circle fills in feeExecuted and expirationBlock at attestation, so the source chain never sees them either.

DepositForBurn contains every key field. The depositor is the address that called depositForBurn, which is the same address CCTP writes into the message as messageSender. Create src/burns.ts:

TypeScript
// burns.ts
import type { Hex } from 'viem'
import { TOKEN_MESSENGER, type Side } from './chains'
import { depositForBurn, toAddress, transferKey } from './events'
import { getLogsPaged } from './logs'

export type Burn = {
  chain: string
  txHash: Hex
  blockNumber: bigint
  sourceDomain: number
  destinationDomain: number
  burnToken: Hex
  depositor: Hex
  mintRecipient: Hex
  amount: bigint // USDC base units, 6 decimals
  maxFee: bigint
  minFinalityThreshold: number
  key: Hex
}

export async function getBurns(side: Side, fromBlock: bigint, toBlock: bigint) {
  const logs = await getLogsPaged(side.client, TOKEN_MESSENGER, depositForBurn, fromBlock, toBlock)
  return logs.map((l): Burn => {
    const a = l.args
    const fields = {
      sourceDomain: side.domain,
      destinationDomain: a.destinationDomain,
      burnToken: a.burnToken.toLowerCase() as Hex,
      depositor: a.depositor.toLowerCase() as Hex,
      mintRecipient: toAddress(a.mintRecipient),
      amount: a.amount,
      maxFee: a.maxFee,
      hookData: a.hookData,
    }
    return {
      chain: side.name,
      txHash: l.transactionHash,
      blockNumber: l.blockNumber,
      ...fields,
      minFinalityThreshold: a.minFinalityThreshold,
      key: transferKey(fields),
    }
  })
}

Amounts are in USDC's 6-decimal ERC-20 units on both chains, including Arc, where the burn goes through the ERC-20 interface at 0x3600000000000000000000000000000000000000.

Decode the Mint

On the destination chain, MessageReceived contains the source domain, the nonce and the message body. The body is the BurnMessageV2 the source chain created, with the fee filled in. The layout is fixed. It starts with a 4-byte version. Then come 32-byte words for burn token, mint recipient, amount, message sender, max fee, fee executed and expiration block. The hook data comes last. Create src/mints.ts:

TypeScript
// mints.ts
import { decodeAbiParameters, sliceHex, type Hex } from 'viem'
import { MESSAGE_TRANSMITTER, TOKEN_MESSENGER, type Side } from './chains'
import { messageReceived, toAddress, transferKey } from './events'
import { getLogsPaged } from './logs'

export type Mint = {
  chain: string
  txHash: Hex
  blockNumber: bigint
  sourceDomain: number
  destinationDomain: number
  nonce: Hex
  mintRecipient: Hex
  amount: bigint // gross amount from the burn message
  feeExecuted: bigint
  finalityThresholdExecuted: number
  key: Hex
}

// BurnMessageV2 layout inside messageBody:
// version(4) burnToken(32) mintRecipient(32) amount(32) messageSender(32)
// maxFee(32) feeExecuted(32) expirationBlock(32) hookData(rest)
function decodeBurnMessage(body: Hex) {
  const [burnToken, mintRecipient, amount, messageSender, maxFee, feeExecuted] =
    decodeAbiParameters(
      [
        { type: 'bytes32' }, { type: 'bytes32' }, { type: 'uint256' },
        { type: 'bytes32' }, { type: 'uint256' }, { type: 'uint256' },
      ],
      sliceHex(body, 4, 196),
    )
  const hookData = (body.length > 2 + 228 * 2 ? sliceHex(body, 228) : '0x') as Hex
  return {
    burnToken: toAddress(burnToken),
    mintRecipient: toAddress(mintRecipient),
    amount,
    messageSender: toAddress(messageSender),
    maxFee,
    feeExecuted,
    hookData,
  }
}

export async function getMints(side: Side, fromBlock: bigint, toBlock: bigint) {
  const logs = await getLogsPaged(side.client, MESSAGE_TRANSMITTER, messageReceived, fromBlock, toBlock)
  // Only token transfers: the sender on the source side is TokenMessengerV2.
  const burnsOnly = logs.filter((l) => toAddress(l.args.sender) === TOKEN_MESSENGER.toLowerCase())
  return burnsOnly.map((l): Mint => {
    const m = decodeBurnMessage(l.args.messageBody)
    return {
      chain: side.name,
      txHash: l.transactionHash,
      blockNumber: l.blockNumber,
      sourceDomain: l.args.sourceDomain,
      destinationDomain: side.domain,
      nonce: l.args.nonce,
      mintRecipient: m.mintRecipient,
      amount: m.amount,
      feeExecuted: m.feeExecuted,
      finalityThresholdExecuted: l.args.finalityThresholdExecuted,
      key: transferKey({
        sourceDomain: l.args.sourceDomain,
        destinationDomain: side.domain,
        burnToken: m.burnToken,
        depositor: m.messageSender,
        mintRecipient: m.mintRecipient,
        amount: m.amount,
        maxFee: m.maxFee,
        hookData: m.hookData,
      }),
    }
  })
}

The filter on sender drops messages that did not come from TokenMessengerV2. CCTP V2 also sends plain messages between contracts, and those have no burn body to decode.

feeExecuted is what the transfer paid. A Fast Transfer pays Circle a fee for attesting before hard finality, and a transfer that uses Circle's Forwarding Service pays for the destination gas. The minted amount is amount - feeExecuted.

Match Both Legs

With a key on every burn and every mint, matching is a lookup. Create src/match.ts:

TypeScript
// match.ts
import type { Hex } from 'viem'
import type { Burn } from './burns'
import type { Mint } from './mints'

export type Pair = { burn: Burn; mint: Mint }

// Pair each burn with the first unused mint that carries the same key.
// Mints arrive in order, so identical transfers pair up first-in, first-out.
export function matchLegs(burns: Burn[], mints: Mint[]) {
  const byKey = new Map<Hex, Mint[]>()
  for (const m of mints) byKey.set(m.key, [...(byKey.get(m.key) ?? []), m])

  const pairs: Pair[] = []
  const pending: Burn[] = []
  for (const burn of burns) {
    const candidates = byKey.get(burn.key)
    const mint = candidates?.shift()
    if (mint) pairs.push({ burn, mint })
    else pending.push(burn)
  }
  return { pairs, pending }
}

Two transfers can share a key: the same sender sending the same amount to the same recipient twice. The queue per key pairs them in order, which is enough for totals and balances. When you need to know which specific mint belongs to which burn, ask Circle for the nonce.

Confirm with Circle and Find Stuck Transfers

Circle's Iris API returns the attested messages for any source transaction, including the nonce and, once it exists, the destination transaction hash. It needs no API key. A transaction can contain more than one burn, so the lookup picks the message whose destination, recipient and amount match the burn. Create src/iris.ts:

TypeScript
// iris.ts
import type { Hex } from 'viem'
import type { Burn } from './burns'

const IRIS = 'https://iris-api.circle.com/v2/messages'

type IrisMessage = {
  eventNonce: Hex
  status: 'complete' | 'pending_confirmations'
  destinationMintTxHash?: Hex
  // Can be null while Circle is still waiting for finality.
  decodedMessage: {
    destinationDomain: string
    decodedMessageBody: { mintRecipient: string; amount: string } | null
  } | null
}

// Circle's attestation service: source tx in, the attested message out.
// One transaction can hold several burns, so pick the one that matches.
export async function irisLookup(burn: Burn) {
  const res = await fetch(`${IRIS}/${burn.sourceDomain}?transactionHash=${burn.txHash}`)
  if (res.status === 404) return undefined // not indexed yet
  if (!res.ok) throw new Error(`Iris ${res.status}`)
  const { messages } = (await res.json()) as { messages: IrisMessage[] }
  const match = messages.find((m) => {
    const body = m.decodedMessage?.decodedMessageBody
    return (
      Number(m.decodedMessage?.destinationDomain) === burn.destinationDomain &&
      body?.mintRecipient.toLowerCase() === burn.mintRecipient &&
      BigInt(body.amount) === burn.amount
    )
  })
  // No match yet: fall back to a message Circle has not decoded, if any.
  return match ?? messages.find((m) => !m.decodedMessage?.decodedMessageBody)
}

A burn with no mint is in one of two states. Either Iris has not attested it yet, or Iris attested it and nobody has called receiveMessage on the destination. Standard transfers that do not use the Forwarding Service need the recipient or an app to submit the mint, and some never do. usedNonces on the destination MessageTransmitterV2 tells the two apart. Create src/pending.ts:

TypeScript
// pending.ts
import { parseAbi, type Hex } from 'viem'
import { MESSAGE_TRANSMITTER, type Side } from './chains'
import { irisLookup } from './iris'
import type { Burn } from './burns'

const transmitterAbi = parseAbi(['function usedNonces(bytes32 nonce) view returns (uint256)'])

// Where a burn with no matching mint is stuck.
export async function pendingStatus(burn: Burn, dst: Side) {
  const msg = await irisLookup(burn)
  if (!msg || msg.status !== 'complete') return 'waiting for attestation'
  const used = await dst.client.readContract({
    address: MESSAGE_TRANSMITTER,
    abi: transmitterAbi,
    functionName: 'usedNonces',
    args: [msg.eventNonce as Hex],
  })
  // Attested but unused: nobody has called receiveMessage on the destination yet.
  return used === 0n ? `attested, not minted (nonce ${msg.eventNonce})` : 'minted outside the scan window'
}

Run the Tracker

The entry point picks a one-hour window that ended 40 minutes ago, so slow mints have had time to land. In both directions, it scans burns in that window on the source chain and mints in the window plus 40 minutes on the destination. Then it checks one match per direction against Iris. Create src/track.ts:

TypeScript
// track.ts
import { formatUnits } from 'viem'
import { ARC, BASE, type Side } from './chains'
import { getBurns } from './burns'
import { getMints } from './mints'
import { irisLookup } from './iris'
import { pendingStatus } from './pending'
import { blockAtTime } from './logs'
import { matchLegs } from './match'

const WINDOW_MIN = Number(process.env.WINDOW_MIN ?? 60) // burns to look at
const SLACK_MIN = 40 // how long after a burn its mint may land

async function blockRange(side: Side, fromTs: bigint, toTs: bigint) {
  const head = await side.client.getBlock()
  const from = await blockAtTime(side.client, fromTs)
  const to = toTs >= head.timestamp ? head.number : await blockAtTime(side.client, toTs)
  return { from, to }
}

async function trackDirection(src: Side, dst: Side, fromTs: bigint, toTs: bigint) {
  const s = await blockRange(src, fromTs, toTs)
  const d = await blockRange(dst, fromTs, toTs + BigInt(SLACK_MIN * 60))

  const burns = (await getBurns(src, s.from, s.to)).filter(
    (b) => b.destinationDomain === dst.domain,
  )
  const mints = (await getMints(dst, d.from, d.to)).filter(
    (m) => m.sourceDomain === src.domain,
  )
  const { pairs, pending } = matchLegs(burns, mints)

  console.log(
    `\n${src.name} -> ${dst.name}: ${burns.length} burns in ${src.name} blocks ` +
      `${s.from}-${s.to}, ${mints.length} mints in ${dst.name} blocks ${d.from}-${d.to}`,
  )
  for (const { burn, mint } of pairs) {
    console.log(
      `  matched ${formatUnits(burn.amount, 6)} USDC to ${burn.mintRecipient}\n` +
        `    burn ${src.name} ${burn.txHash} (block ${burn.blockNumber})\n` +
        `    mint ${dst.name} ${mint.txHash} (block ${mint.blockNumber}) ` +
        `fee ${formatUnits(mint.feeExecuted, 6)} finality ${mint.finalityThresholdExecuted}`,
    )
  }
  for (const burn of pending) {
    const status = await pendingStatus(burn, dst)
    console.log(`  no mint yet ${formatUnits(burn.amount, 6)} USDC ${burn.txHash}: ${status}`)
  }
  return { pairs, pending }
}

async function main() {
  const now = BigInt(Math.floor(Date.now() / 1000))
  // Leave room for the slack window to have happened already.
  const toTs = now - BigInt(SLACK_MIN * 60)
  const fromTs = toTs - BigInt(WINDOW_MIN * 60)

  const out = await trackDirection(ARC, BASE, fromTs, toTs)
  const inbound = await trackDirection(BASE, ARC, fromTs, toTs)

  // Check one match per direction against Circle's attestation service.
  for (const { burn, mint } of [out.pairs[0], inbound.pairs[0]].filter(Boolean)) {
    const msg = await irisLookup(burn)
    const ok = msg?.eventNonce === mint.nonce && msg?.destinationMintTxHash === mint.txHash
    console.log(`\nIris check ${burn.txHash}: nonce ${mint.nonce} ${ok ? 'confirmed' : 'MISMATCH'}`)
  }
}

main().catch((err) => {
  console.error(err)
  process.exit(1)
})

Run it:

Bash
npx tsx src/track.ts

Our run at 11:05 UTC on 11 November 2026 covered 09:25 to 10:25 UTC:

Text
Arc -> Base: 6 burns in Arc blocks 30667808-30674911, 10 mints in Base blocks 55993882-56023882
  matched 285.9428 USDC to 0x422c57f5928f1a4d0ad06d20081428d1ed0f5e3c
    burn Arc 0x7ee477fd54a99931df4b5f991f87cfd8efa4aaa4b25a1dd388c46a51efba0123 (block 30668362)
    mint Base 0xe3068e3b0ea1698bef62c595841287b35bc6e78e7992682fd9672e35c3a1ec0b (block 55995326) fee 0 finality 2000
  matched 92.9814 USDC to 0x27ae69c4a920ac54285ba8a93eb790cdf789c46c
    burn Arc 0xfe2afeec50c9bea38901d6756b2cbaa38dc02cad967dc342485eb2f648e40ba6 (block 30670044)
    mint Base 0x7ab0d453f5d29a84415ddd9886065b7a8113992dd441c1ed8079bc1f544db285 (block 55999602) fee 0 finality 2000
  matched 360.4279 USDC to 0xbde6361e195d9c4bb83f2c6479ce7055fab8011e
    burn Arc 0x013e9523b2fa0917bb329a8928644e16bdf849add7f6dbf522239ce430a8bb64 (block 30672419)
    mint Base 0xd029687aa8ebc514284c1abb31c2024db4c4d1c49181531a5da1dfd2360aaa14 (block 56005602) fee 0 finality 2000
  matched 376 USDC to 0xf8587ec80ede1b55ce44e565b96215327894d138
    burn Arc 0x1e953cd2c95368175d26bbba12a70547d8872c8f03f93a29d69ed43466dee22a (block 30672683)
    mint Base 0xf3963c40f37c57d159e5a490a5f00fe243d309b6547c7d36544d48892ef4ada7 (block 56006418) fee 0 finality 2000
  matched 15 USDC to 0xbbbe561c853b3b47a2bf31d669d98a20502ca7df
    burn Arc 0xa2f6da342645c4f8edbe780f6766cae62a99383519231619823bc2cd8ffbdd20 (block 30674162)
    mint Base 0xd1d7addcb6d33f6c11c917eae915d49033c3788e40e7add5f4b457ada8504b76 (block 56010012) fee 0.054082 finality 2000
  no mint yet 2.56923 USDC 0x458510de2048729b0fa7f80b2b6d508a46bd46661b3c3569c77da2ca3100926b: attested, not minted (nonce 0x286e0fc0c3359ac5bbd5a731e3361913d5808f0b80fce17b64c36e02c35ed778)

Base -> Arc: 6 burns in Base blocks 55993882-56011882, 12 mints in Arc blocks 30667808-30679645
  matched 285.756392 USDC to 0x422c57f5928f1a4d0ad06d20081428d1ed0f5e3c
    burn Base 0x9c3d2515031e6184c3dc5535b6724bd194b8f46a9cd4a3277b8644a11abe9d0a (block 55994458)
    mint Arc 0xcd8cef3b4be98a8f60408c7b23d98f38a75214723d493cf546f78e469e68d3dd (block 30668149) fee 0.009287 finality 1000
  matched 93.912241 USDC to 0x27ae69c4a920ac54285ba8a93eb790cdf789c46c
    burn Base 0x0d475e2d156a1922ff16a1f732d1f273ea420dddb01e4bbc1f3fb232280f080e (block 55998473)
    mint Arc 0x8624cd68f540ff0af3aa3a13032ac4d9abbbcf56de7516a97cab02a19a890c8d (block 30669676) fee 0.003052 finality 1000
  matched 360.699181 USDC to 0xbde6361e195d9c4bb83f2c6479ce7055fab8011e
    burn Base 0x6137575931ec7a1171d951e09fbbd9604ee002336e21277a381b32dac5e33dfd (block 56004316)
    mint Arc 0x1621cf3272e1864f2ba0e92f22517312d5047547252202edb176ad0a7a233169 (block 30672031) fee 0.011722 finality 1000
  matched 10000 USDC to 0x8d55eff552fe561bf88c15bfbdadfe059b865798
    burn Base 0x3e19d1ce81c56bddbff76e0c418f6e4aaeff2da9eacdde69fb0b03b843f24926 (block 56004962)
    mint Arc 0x877c43b286e0a9e3352d23eee7efb4547effcff5c3d86cfba38cac1cafe5f198 (block 30674871) fee 0 finality 2000
  matched 4000 USDC to 0x480a6a26dc39647ba9709fee818c1df1353e4199
    burn Base 0x04f920193d5e03c1b995d5af8a50cf11aff3a5586a2cd3c92147c70d8e2c936b (block 56005786)
    mint Arc 0x54a1db5ab628cf0dc42c04a03a20103fc31e27093f5b3e6f14f91eef5b976c7e (block 30672515) fee 0.13 finality 1000
  matched 10176.743863 USDC to 0x1da38a31f369343bdd33ccc48e89d165e8ab3450
    burn Base 0xc9cdb394b92340961047d69a557cfc100c405bd848d2dceec39901bc23889e6e (block 56007222)
    mint Arc 0xe0f075a680fda2971427fee399a0b1dd0266f0ec7f8d978955478be4a8e41f0c (block 30675142) fee 0 finality 2000

Iris check 0x7ee477fd54a99931df4b5f991f87cfd8efa4aaa4b25a1dd388c46a51efba0123: nonce 0x676af00bdcd7a52488fd13919c56ed99a3ddba47262773e7441aac0cbc3fb507 confirmed

Iris check 0x9c3d2515031e6184c3dc5535b6724bd194b8f46a9cd4a3277b8644a11abe9d0a: nonce 0xa6e7584d688dfc0828042e7c13e006c37a5b22f03f65597fb815224f01ecd754 confirmed

Three of the four scans crossed a window boundary. The Base mint scan covered 30,001 blocks in four requests, the Base burn scan 18,001 blocks in two, and the Arc mint scan 11,838 blocks in two. The tracker matched 11 transfers. We then looked up every source transaction in Iris, and all 11 returned the destination hash the tracker had paired with it.

Here is the 10,000 USDC transfer from the introduction. The burn on Base is 0x3e19d1ce... in block 56004962, and the mint on Arc is 0x877c43b2... in block 30674871. The Arc receipt contains a system Transfer from the zero address of 0x21e19e0c9bab2400000 (10,000 x 10^18), an ERC-20 Transfer of 0x2540be400 (10,000 x 10^6), MintAndWithdraw and MessageReceived.

What the Matched Transfers Show

Time from burn to mint for the 11 matched transfers:

DirectionTransfersFinalityFee paidBurn to mint
Arc to Base5Standard (2000)0, except 0.054082 USDC with the Forwarding Service6 to 36 seconds
Base to Arc4Fast (1000)0.003052 to 0.13 USDC6 to 58 seconds
Base to Arc2Standard (2000)017 min 29 s and 22 min 44 s

Arc settles on commit, so Circle attests a standard transfer out of Arc in about the time it takes to see the block. Circle lists Arc as a source chain where Fast Transfer is not needed. The 15 USDC transfer above asked for Fast finality (minFinalityThreshold 1000), and Circle attested it at 2000 anyway. Its fee came from the Forwarding Service. Its hook data starts with the ASCII bytes cctp-forward.

Transfers into Arc depend on the source chain. A standard transfer from Base waits for the batch that contains the burn to finalize on Ethereum. That wait accounts for the 17 and 23 minutes. Fast Transfers from Base skip that wait for a fee. If your app credits users when USDC arrives on Arc, show inbound standard transfers as pending for up to about 30 minutes.

The burn left without a mint is the third state. 0x458510de... burned 2.56923 USDC on Arc at 09:33 UTC for a recipient on Base. Iris reports it as complete with nonce 0x286e0fc0..., and usedNonces on Base still returned 0 at 11:08 UTC, more than 90 minutes later. The USDC is gone from Arc and will not exist on Base until someone submits the attested message. A tracker that only counted mints would miss it, and a ledger that only counted burns would treat it as delivered.

Going to Production

The tracker above is a one-shot scan. A service that runs all day needs a few changes:

  • Checkpoint by block, per chain. Store the last block scanned on Arc and on Base, and scan forward from there. Keep unmatched burns in a table and retry them on each pass instead of rescanning old ranges.
  • Key rows on (transactionHash, logIndex). A rerun of the same window then inserts nothing new. A burn transaction can contain more than one DepositForBurn, so the transaction hash alone is not unique.
  • Give inbound transfers time. Use a slack window of at least 40 minutes for standard transfers from Ethereum and OP Stack chains. Circle's finality table lists attestation times per source chain, and some, such as Linea, take hours.
  • Handle Iris rate limits. Iris is a shared public API. Call it only for burns that have no mint after the slack window, not for every transfer, and back off on HTTP 429.
  • Add the other chains. CCTP links Arc to about 30 other domains. In the 10,000 Arc blocks before our run, burns from Arc went to nine of them, most often Base, Ethereum and Arbitrum. Each new EVM chain is one more Side with its RPC URL and domain number. Check its addresses on Circle's list first: a few chains, such as EDGE, use different CCTP contracts, and the sender filter in mints.ts would drop their mints. Non-EVM domains encode addresses differently and need their own decoder.
  • Watch reorgs on the other chain. Arc blocks are final on commit, so an Arc log never disappears. Base and Ethereum logs can, until their blocks finalize. Scan those chains to the finalized tag, or recheck recent ranges.

For a live feed, subscribe to MessageReceived on both chains over WebSocket. On every reconnect, run the paged backfill from the last checkpoint. The financial apps guide uses the same pattern for USDC transfers.

Next Steps

  • Add Ethereum as a third Side with domain 0 and the same contract addresses, and run the tracker over Arc to Ethereum and back.
  • Join matched transfers to Arc's system emitter logs to reconcile CCTP mints against the native USDC balance of each recipient.
  • Alert on attested burns that stay unminted for more than an hour, and submit the mint for your own users with receiveMessage.

The Arc RPC provider comparison covers which endpoints offer archive data for backfills, and the eth_getLogs cost guide explains how providers bill log queries. Connection details and supported methods are in the Arc docs and Base docs.

This guide ran on Dwellir's Arc and Base mainnet endpoints, billed at one credit per response for every method. To run it yourself, create a free Dwellir API key.

FAQ

Is CCTP live on Arc mainnet?

Yes. Arc mainnet (chain ID 5042) runs CCTP V2 as domain 26. TokenMessengerV2 is at 0x28b5a0e9C621a5BadaA536219b3a228C8168cf5d and MessageTransmitterV2 at 0x81D40F21F12A8F0E3252Bccb954D722d4c464B64, the same addresses CCTP V2 uses on Base, Ethereum and most of the other EVM chains it supports. Arc testnet uses different addresses.

Why can't I match a CCTP burn and mint by nonce?

In CCTP V2, Circle's attestation service assigns the nonce after the burn. The MessageSent event on the source chain contains 32 zero bytes where the nonce goes, and only the destination's MessageReceived event has the actual value. Match the legs on the fields both contain: source and destination domain, burn token, sender, mint recipient, amount, max fee and hook data. Use Circle's Iris API when you need the nonce for a specific burn.

How long does a CCTP transfer from Arc take?

Seconds. Arc blocks are final when they commit, so Circle attests a standard transfer from Arc almost at once. In a one-hour sample on 11 November 2026, five Arc to Base transfers minted on Base 6 to 36 seconds after the burn. Base to Arc standard transfers took 17 and 23 minutes because they wait for Base's batch to finalize on Ethereum. Fast Transfers from Base took 6 to 58 seconds and paid a fee.

What does it mean when a CCTP burn has no mint?

Either Circle has not attested it yet, or Circle attested it and nobody has called receiveMessage on the destination chain. Look up the burn in Circle's Iris API. A complete status gives you the nonce, and usedNonces(nonce) on the destination MessageTransmitterV2 returns 0 until the mint happens. Standard transfers without the Forwarding Service need the recipient or an app to submit the mint.

What eth_getLogs block range does this need?

Dwellir's Arc and Base endpoints return at most 10,000 blocks per eth_getLogs request and reject larger ranges with error -32005. At 0.5 second blocks that is about 83 minutes of Arc, and at 0.2 seconds about 33 minutes of Base. The tracker walks each range in 10,000-block windows and halves any window that hits a range, result count or response size limit.

Sources

We read chain data from Dwellir's Arc and Base mainnet endpoints on 11 November 2026. We checked every matched pair against Circle's Iris API the same day.

Start free.1 response = 1 credit.

100,000 responses a day at 20 per second, on 150+ networks. Upgrade when you need more.