Monitor the native USDC Transfer event from the system address 0xffff…fffe
to capture every native USDC movement in one stream (Arc’s EIP-7708
implementation). Index the ERC-20 USDC contract, 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 native USDC Transfer events (EIP-7708)
Every native USDC movement emits a standard ERC-20 Transfer log from the
system address 0xfffffffffffffffffffffffffffffffffffffffe (Arc’s
EIP-7708 implementation). This single
stream covers native sends, the native leg of ERC-20 transfers, and mint and
burn, with values in 18 decimals. It is the single source of truth for USDC
balance changes.
The ERC-20 USDC contract at 0x3600…0000 also emits its own Transfer (6
decimals) for ERC-20-interface activity, so an ERC-20 transfer() produces a
log from both emitters. Distinguish them by emitter address and never count the
same movement twice. For the complete event matrix, the mint and burn mapping,
and the historical pre-Zero5 events, see
USDC system events.
| Property | Value |
|---|
| Emitter | 0xfffffffffffffffffffffffffffffffffffffffe |
| Event | Transfer(address indexed from, address indexed to, uint256 value) |
| topic0 | 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef |
| Decimals | 18 |
import { Interface, JsonRpcProvider, Log } from "ethers";
// Native USDC system emitter (EIP-7708 Transfer logs, 18 decimals)
const NATIVE_USDC_EMITTER = "0xfffffffffffffffffffffffffffffffffffffffe";
// ERC-20 USDC contract (emits its own 6-decimal Transfer and blocklist events)
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: NATIVE_USDC_EMITTER,
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; // 18 decimals (native)
await saveTransfer({
blockNumber: log.blockNumber,
txHash: log.transactionHash,
logIndex: log.index,
from,
to,
value,
});
}
}
Filtering the native system emitter (0xffff…fffe) captures all native USDC
movements in one stream: native sends, the native leg of ERC-20 transfers, and
mint and burn. If you also index the ERC-20 USDC contract (0x3600…0000) for
its 6-decimal Transfer events, match on the emitter address so you do not
count ERC-20 transfers twice.
To backfill history across the Zero5 hard fork, read the historical
NativeCoin* events from 0x1800…0000 for blocks before activation and
Transfer from 0xffff…fffe at and after it. See USDC system
events for
signatures and the activation reference.
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 |
|---|
| Native USDC (system) | 0xfffffffffffffffffffffffffffffffffffffffe | Transfer (18 decimals, EIP-7708) |
| USDC (ERC-20) | 0x3600000000000000000000000000000000000000 | Transfer (6 decimals), Blocklisted, UnBlocklisted |
| EURC | 0x89B50855Aa3bE2F677cD6303Cec089B5F319D72a | Transfer |
| Memo | 0x9702466268ccF55eAB64cdf484d272Ac08d3b75b | MemoSent |
| TokenMessengerV2 | 0x8FE6B999Dc680CcFDD5Bf7EB0974218be2542DAA | DepositForBurn |
| MessageTransmitterV2 | 0xE737e5cEBEEBa77EFE34D4aa090756590b1CE275 | MessageReceived |
See also