Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.arc.io/llms.txt

Use this file to discover all available pages before exploring further.

Monitor a single Transfer event on the USDC contract to capture both native sends and ERC-20 transfers. Index Memo, blocklist, and CCTP events from their respective contracts for full transaction coverage. Skip reorg-handling logic entirely—Arc’s deterministic finality means every block is permanent. Use block number (not timestamp) as your ordering key because sub-second blocks can share the same block.timestamp.

Prerequisites

Before you begin:
  • Access to an Arc RPC endpoint (https://rpc.testnet.arc.network) or WebSocket (wss://rpc.testnet.arc.network)
  • Familiarity with Ethereum JSON-RPC methods (eth_getLogs, eth_subscribe)
  • A database or indexing pipeline that supports high-throughput block ingestion
  • TypeScript environment with ethers or viem installed

Steps

Step 1. Connect to the block stream

Use eth_subscribe("newHeads") over WebSocket for real-time block notifications. For historical back-fills, use eth_getLogs with fromBlock/toBlock ranges.
import { WebSocketProvider, JsonRpcProvider } from "ethers";

// Real-time streaming
const wsProvider = new WebSocketProvider("wss://rpc.testnet.arc.network");

wsProvider.on("block", async (blockNumber: number) => {
  await indexBlock(blockNumber);
});

// Historical backfill
const httpProvider = new JsonRpcProvider("https://rpc.testnet.arc.network");

async function backfill(startBlock: number, endBlock: number): Promise<void> {
  const BATCH_SIZE = 1000;
  for (let from = startBlock; from <= endBlock; from += BATCH_SIZE) {
    const to = Math.min(from + BATCH_SIZE - 1, endBlock);
    const logs = await httpProvider.getLogs({ fromBlock: from, toBlock: to });
    await processLogs(logs);
  }
}
Arc produces sub-second blocks. Your ingestion pipeline must handle bursts of many blocks per second without falling behind.

Step 2. Index unified USDC Transfer events

Arc emits a single Transfer event for all USDC movements—both native sends (value field transactions) and ERC-20 transfer() calls. This is the only event you need for complete USDC transfer coverage.
PropertyValue
Contract0x3600000000000000000000000000000000000000
EventTransfer(address indexed from, address indexed to, uint256 value)
topic00xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef
Decimals6
import { Interface, JsonRpcProvider, Log } from "ethers";

const USDC_ADDRESS = "0x3600000000000000000000000000000000000000";
const TRANSFER_TOPIC =
  "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef";

const erc20Interface = new Interface([
  "event Transfer(address indexed from, address indexed to, uint256 value)",
]);

const provider = new JsonRpcProvider("https://rpc.testnet.arc.network");

async function indexUsdcTransfers(
  fromBlock: number,
  toBlock: number,
): Promise<void> {
  const logs = await provider.getLogs({
    address: USDC_ADDRESS,
    topics: [TRANSFER_TOPIC],
    fromBlock,
    toBlock,
  });

  for (const log of logs) {
    const parsed = erc20Interface.parseLog({
      topics: log.topics as string[],
      data: log.data,
    });
    if (!parsed) continue;

    const from = parsed.args.from as string;
    const to = parsed.args.to as string;
    const value = parsed.args.value as bigint; // 6 decimals

    await saveTransfer({
      blockNumber: log.blockNumber,
      txHash: log.transactionHash,
      logIndex: log.index,
      from,
      to,
      value,
    });
  }
}
Because native USDC sends and ERC-20 transfers both emit the same event on the same contract, you do not need separate indexing logic for each. A single log filter captures all USDC movements.

Step 3. Index EURC and other ERC-20 token transfers

EURC and other ERC-20 tokens emit standard Transfer events from their own contract addresses. Index these separately from USDC.
TokenContractDecimals
EURC0x89B50855Aa3bE2F677cD6303Cec089B5F319D72a6
const EURC_ADDRESS = "0x89B50855Aa3bE2F677cD6303Cec089B5F319D72a";

async function indexEurcTransfers(
  fromBlock: number,
  toBlock: number,
): Promise<void> {
  const logs = await provider.getLogs({
    address: EURC_ADDRESS,
    topics: [TRANSFER_TOPIC],
    fromBlock,
    toBlock,
  });

  for (const log of logs) {
    const parsed = erc20Interface.parseLog({
      topics: log.topics as string[],
      data: log.data,
    });
    if (!parsed) continue;

    await saveTransfer({
      blockNumber: log.blockNumber,
      txHash: log.transactionHash,
      logIndex: log.index,
      from: parsed.args.from as string,
      to: parsed.args.to as string,
      value: parsed.args.value as bigint,
    });
  }
}

Step 4. Index Memo contract events

The Memo contract lets senders attach arbitrary data to USDC transfers for correlation and reconciliation. Index MemoSent events to store memo payloads alongside their corresponding transfers.
PropertyValue
Contract0x9702466268ccF55eAB64cdf484d272Ac08d3b75b
EventMemoSent(address indexed sender, address indexed recipient, uint256 amount, bytes memo)
const MEMO_CONTRACT = "0x9702466268ccF55eAB64cdf484d272Ac08d3b75b";

const memoInterface = new Interface([
  "event MemoSent(address indexed sender, address indexed recipient, uint256 amount, bytes memo)",
]);

async function indexMemoEvents(
  fromBlock: number,
  toBlock: number,
): Promise<void> {
  const logs = await provider.getLogs({
    address: MEMO_CONTRACT,
    fromBlock,
    toBlock,
  });

  for (const log of logs) {
    const parsed = memoInterface.parseLog({
      topics: log.topics as string[],
      data: log.data,
    });
    if (!parsed) continue;

    const sender = parsed.args.sender as string;
    const recipient = parsed.args.recipient as string;
    const amount = parsed.args.amount as bigint;
    const memo = parsed.args.memo as string; // hex-encoded bytes

    // Decode memo as UTF-8 text if applicable
    const memoText = Buffer.from(memo.slice(2), "hex").toString("utf-8");

    await saveMemo({
      blockNumber: log.blockNumber,
      txHash: log.transactionHash,
      logIndex: log.index,
      sender,
      recipient,
      amount,
      memo: memoText,
    });
  }
}
Correlate MemoSent events with Transfer events in the same transaction by matching on transactionHash. The memo provides context (such as an invoice ID or payment reference) for the corresponding transfer.

Step 5. Index blocklist events

The USDC contract emits Blocklisted and UnBlocklisted events when addresses are added to or removed from the blocklist. Track these to maintain an accurate set of restricted addresses.
EventSignature
Address blockedBlocklisted(address indexed account)
Address unblockedUnBlocklisted(address indexed account)
const blocklistInterface = new Interface([
  "event Blocklisted(address indexed account)",
  "event UnBlocklisted(address indexed account)",
]);

const BLOCKLISTED_TOPIC = blocklistInterface.getEvent("Blocklisted")!.topicHash;
const UNBLOCKLISTED_TOPIC =
  blocklistInterface.getEvent("UnBlocklisted")!.topicHash;

async function indexBlocklistEvents(
  fromBlock: number,
  toBlock: number,
): Promise<void> {
  const logs = await provider.getLogs({
    address: USDC_ADDRESS,
    topics: [[BLOCKLISTED_TOPIC, UNBLOCKLISTED_TOPIC]],
    fromBlock,
    toBlock,
  });

  for (const log of logs) {
    const parsed = blocklistInterface.parseLog({
      topics: log.topics as string[],
      data: log.data,
    });
    if (!parsed) continue;

    const account = parsed.args.account as string;
    const isBlocked = parsed.name === "Blocklisted";

    await updateBlocklist({
      blockNumber: log.blockNumber,
      txHash: log.transactionHash,
      account,
      isBlocked,
    });
  }
}

Step 6. Index CCTP crosschain events

The Cross-Chain Transfer Protocol (CCTP) uses two contracts on Arc: TokenMessengerV2 for outbound burns and MessageTransmitterV2 for inbound mints.
DirectionContractAddressEvent
Outbound (burn)TokenMessengerV20x8FE6B999Dc680CcFDD5Bf7EB0974218be2542DAADepositForBurn
Inbound (mint)MessageTransmitterV20xE737e5cEBEEBa77EFE34D4aa090756590b1CE275MessageReceived
const TOKEN_MESSENGER = "0x8FE6B999Dc680CcFDD5Bf7EB0974218be2542DAA";
const MESSAGE_TRANSMITTER = "0xE737e5cEBEEBa77EFE34D4aa090756590b1CE275";

const cctpInterface = new Interface([
  "event DepositForBurn(uint64 indexed nonce, address indexed burnToken, uint256 amount, address indexed depositor, bytes32 mintRecipient, uint32 destinationDomain, bytes32 destinationTokenMessenger, bytes32 destinationCaller)",
  "event MessageReceived(address indexed caller, uint32 sourceDomain, uint64 indexed nonce, bytes32 sender, bytes messageBody)",
]);

async function indexCctpEvents(
  fromBlock: number,
  toBlock: number,
): Promise<void> {
  // Index outbound burns
  const burnLogs = await provider.getLogs({
    address: TOKEN_MESSENGER,
    topics: [cctpInterface.getEvent("DepositForBurn")!.topicHash],
    fromBlock,
    toBlock,
  });

  for (const log of burnLogs) {
    const parsed = cctpInterface.parseLog({
      topics: log.topics as string[],
      data: log.data,
    });
    if (!parsed) continue;

    await saveCctpBurn({
      blockNumber: log.blockNumber,
      txHash: log.transactionHash,
      nonce: parsed.args.nonce,
      amount: parsed.args.amount,
      depositor: parsed.args.depositor,
      destinationDomain: parsed.args.destinationDomain,
    });
  }

  // Index inbound mints
  const mintLogs = await provider.getLogs({
    address: MESSAGE_TRANSMITTER,
    topics: [cctpInterface.getEvent("MessageReceived")!.topicHash],
    fromBlock,
    toBlock,
  });

  for (const log of mintLogs) {
    const parsed = cctpInterface.parseLog({
      topics: log.topics as string[],
      data: log.data,
    });
    if (!parsed) continue;

    await saveCctpMint({
      blockNumber: log.blockNumber,
      txHash: log.transactionHash,
      nonce: parsed.args.nonce,
      sourceDomain: parsed.args.sourceDomain,
    });
  }
}

Step 7. Use block number as your ordering key

Multiple blocks can share the same block.timestamp because Arc produces sub-second blocks that fall in the same wall-clock second. Always use blockNumber (and logIndex in a block) as your canonical ordering key.
interface IndexedEvent {
  blockNumber: number; // Primary ordering key
  logIndex: number; // Secondary ordering key in a block
  txHash: string;
  // ... event-specific fields
}

// Correct: order by block number
function compareEvents(a: IndexedEvent, b: IndexedEvent): number {
  if (a.blockNumber !== b.blockNumber) {
    return a.blockNumber - b.blockNumber;
  }
  return a.logIndex - b.logIndex;
}
Do not use block.timestamp for ordering. Two consecutive blocks (for example, block 100 and block 101) may both have timestamp = 1700000000. Sorting by timestamp produces ambiguous ordering.

Step 8. Simplify your pipeline—no reorg handling required

Arc provides deterministic finality. Once a block appears, it is permanent. You can remove the following from your indexing pipeline:
  • Reorg detection and rollback logic
  • Confirmation-depth delays (no need to wait for N confirmations)
  • Uncle/ommer block handling
  • Chain reorganization event listeners
// No need for confirmation buffers or reorg watchers.
// Process each block exactly once as it arrives.
async function indexBlock(blockNumber: number): Promise<void> {
  // This block is final—it will never be reverted.
  await indexUsdcTransfers(blockNumber, blockNumber);
  await indexEurcTransfers(blockNumber, blockNumber);
  await indexMemoEvents(blockNumber, blockNumber);
  await indexBlocklistEvents(blockNumber, blockNumber);
  await indexCctpEvents(blockNumber, blockNumber);

  await markBlockProcessed(blockNumber);
}
If your indexer restarts, resume from the last processed block number. You do not need to re-validate previously indexed blocks because they cannot be reverted.

Event reference

ContractAddressEvents
USDC0x3600000000000000000000000000000000000000Transfer, Blocklisted, UnBlocklisted
EURC0x89B50855Aa3bE2F677cD6303Cec089B5F319D72aTransfer
Memo0x9702466268ccF55eAB64cdf484d272Ac08d3b75bMemoSent
TokenMessengerV20x8FE6B999Dc680CcFDD5Bf7EB0974218be2542DAADepositForBurn
MessageTransmitterV20xE737e5cEBEEBa77EFE34D4aa090756590b1CE275MessageReceived

See also