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.
| Property | Value |
|---|
| Contract | 0x3600000000000000000000000000000000000000 |
| Event | Transfer(address indexed from, address indexed to, uint256 value) |
| topic0 | 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef |
| Decimals | 6 |
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.
| Token | Contract | Decimals |
|---|
| EURC | 0x89B50855Aa3bE2F677cD6303Cec089B5F319D72a | 6 |
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.
| Property | Value |
|---|
| Contract | 0x9702466268ccF55eAB64cdf484d272Ac08d3b75b |
| Event | MemoSent(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.
| Event | Signature |
|---|
| Address blocked | Blocklisted(address indexed account) |
| Address unblocked | UnBlocklisted(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.
| Direction | Contract | Address | Event |
|---|
| Outbound (burn) | TokenMessengerV2 | 0x8FE6B999Dc680CcFDD5Bf7EB0974218be2542DAA | DepositForBurn |
| Inbound (mint) | MessageTransmitterV2 | 0xE737e5cEBEEBa77EFE34D4aa090756590b1CE275 | MessageReceived |
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
| Contract | Address | Events |
|---|
| USDC | 0x3600000000000000000000000000000000000000 | Transfer, Blocklisted, UnBlocklisted |
| EURC | 0x89B50855Aa3bE2F677cD6303Cec089B5F319D72a | Transfer |
| Memo | 0x9702466268ccF55eAB64cdf484d272Ac08d3b75b | MemoSent |
| TokenMessengerV2 | 0x8FE6B999Dc680CcFDD5Bf7EB0974218be2542DAA | DepositForBurn |
| MessageTransmitterV2 | 0xE737e5cEBEEBa77EFE34D4aa090756590b1CE275 | MessageReceived |
See also