Detect USDC deposits on Arc by generating addresses, monitoring the native USDC
Transfer event from the system emitter 0xffff…fffe (Arc’s
EIP-7708 implementation), crediting
after a single block confirmation (deterministic finality guarantees no reorgs),
and sweeping funds into a hot wallet.
Prerequisites
Before you begin:
- Access to an Arc RPC endpoint (
https://rpc.testnet.arc.network) or WebSocket
(wss://rpc.testnet.arc.network)
- An HD wallet library for generating deposit addresses (for example,
ethers
or viem)
- Familiarity with Ethereum JSON-RPC methods and event log filtering
- A database to track processed deposits and prevent double-crediting
Steps
Step 1. Generate deposit addresses
Arc uses standard Ethereum addresses (0x-prefixed, 20 bytes, EIP-55 checksum).
Derive deposit addresses using the same HD wallet approach as Ethereum—one
unique address per user.
import { HDNodeWallet, Mnemonic } from "ethers";
// Derive a deposit address for a given user index
function getDepositAddress(mnemonic: string, userIndex: number): string {
const hdNode = HDNodeWallet.fromMnemonic(
Mnemonic.fromPhrase(mnemonic),
`m/44'/60'/0'/0/${userIndex}`,
);
return hdNode.address;
}
Store the mapping between user IDs and their derived address index. Never expose
the mnemonic or private keys in client-side code.
Step 2. Subscribe to new blocks
Use eth_subscribe("newHeads") over WebSocket for real-time block
notifications, or poll eth_blockNumber over HTTP as a fallback.
import { WebSocketProvider, JsonRpcProvider } from "ethers";
// Option A: WebSocket subscription (recommended)
const wsProvider = new WebSocketProvider("wss://rpc.testnet.arc.network");
wsProvider.on("block", async (blockNumber: number) => {
console.log(`New block: ${blockNumber}`);
await processBlock(blockNumber);
});
// Option B: HTTP polling fallback
const httpProvider = new JsonRpcProvider("https://rpc.testnet.arc.network");
let lastProcessedBlock = await httpProvider.getBlockNumber();
setInterval(async () => {
const currentBlock = await httpProvider.getBlockNumber();
for (let block = lastProcessedBlock + 1; block <= currentBlock; block++) {
await processBlock(block);
}
lastProcessedBlock = currentBlock;
}, 2000);
Step 3. Detect incoming transfers with the native USDC Transfer event
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 plain native sends and the native leg of ERC-20 transfers, with
values in 18 decimals. Filter it to catch every deposit, including native
sends that emit no event on the ERC-20 contract.
Do not filter the ERC-20 USDC contract (0x3600…0000) for deposit detection.
Its Transfer events cover only ERC-20-interface activity, so a plain native
send produces no log there and the deposit is missed. Filter the system
emitter (0xffff…fffe) instead. See USDC system
events for the full event matrix.
Event signature:
Transfer(address indexed from, address indexed to, uint256 value)
Topic0: 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef
Native USDC system emitter: 0xfffffffffffffffffffffffffffffffffffffffe
Use eth_getLogs to filter for transfers to your deposit addresses:
import { Interface, Log, JsonRpcProvider } from "ethers";
// Native USDC system emitter (EIP-7708 Transfer logs, 18 decimals)
const NATIVE_USDC_EMITTER = "0xfffffffffffffffffffffffffffffffffffffffe";
const TRANSFER_TOPIC =
"0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef";
const provider = new JsonRpcProvider("https://rpc.testnet.arc.network");
const erc20Interface = new Interface([
"event Transfer(address indexed from, address indexed to, uint256 value)",
]);
async function processBlock(blockNumber: number): Promise<void> {
const logs = await provider.getLogs({
address: NATIVE_USDC_EMITTER,
topics: [
TRANSFER_TOPIC,
null, // any sender
null, // any recipient—filter client-side for your addresses
],
fromBlock: blockNumber,
toBlock: blockNumber,
});
for (const log of logs) {
const parsed = erc20Interface.parseLog({
topics: log.topics as string[],
data: log.data,
});
if (!parsed) continue;
const to = parsed.args.to as string;
const value = parsed.args.value as bigint; // 18 decimals (native)
// Check if the recipient is one of your deposit addresses
if (isDepositAddress(to)) {
await creditDeposit({
txHash: log.transactionHash,
logIndex: log.index,
to,
amount: value, // 18 decimals—convert to 6 for crediting (see Step 5)
blockNumber,
});
}
}
}
Do not credit the same deposit twice. An ERC-20 transfer() emits a log from
both the system emitter (0xffff…fffe, 18 decimals) and the ERC-20 contract
(0x3600…0000, 6 decimals); filter only the system emitter. Likewise, do not
reconcile eth_getBalance against Transfer events—they represent the same
balance.
Step 4. Confirm the deposit
Arc provides deterministic finality—once a transaction is included in a block,
it is final with no possibility of reorg. You can safely credit deposits after 1
confirmation.
import { JsonRpcProvider } from "ethers";
const provider = new JsonRpcProvider("https://rpc.testnet.arc.network");
async function isConfirmed(txHash: string): Promise<boolean> {
const receipt = await provider.getTransactionReceipt(txHash);
if (!receipt || receipt.status === 0) {
return false; // Transaction failed or not yet mined
}
// On Arc, 1 confirmation = final. No reorgs possible.
const currentBlock = await provider.getBlockNumber();
return currentBlock >= receipt.blockNumber;
}
Since Arc has deterministic finality, you do not need to wait for multiple
confirmations. Credit the user once the block containing their transaction is
produced.
Step 5. Handle decimal conversion
The native system Transfer event emits values with 18 decimals. Convert to
6-decimal USDC (divide by 10^12) before crediting user balances.
// Native system Transfer values use 18 decimals—convert to 6 for crediting
function formatDeposit(eventValue: bigint): string {
const sixDecimals = eventValue / 10n ** 12n; // 18-decimal native → 6-decimal USDC
const whole = sixDecimals / 1_000_000n;
const fractional = (sixDecimals % 1_000_000n).toString().padStart(6, "0");
return `${whole}.${fractional}`;
}
The native system Transfer event and eth_getBalance both use 18 decimals;
the ERC-20 contract’s Transfer and balanceOf use 6. Never mix
representations—convert 18-decimal values by dividing by 10^12 before
displaying or crediting.
Step 6. Sweep deposits to a hot wallet
Consolidate deposited funds from individual user addresses into your hot wallet.
Use EIP-1559 transactions with Arc’s minimum base fee of 20 Gwei.
import { Wallet, JsonRpcProvider, parseUnits } from "ethers";
const provider = new JsonRpcProvider("https://rpc.testnet.arc.network");
async function sweepDeposit(
depositPrivateKey: string,
hotWalletAddress: string,
amount: bigint,
): Promise<string> {
const wallet = new Wallet(depositPrivateKey, provider);
const feeData = await provider.getFeeData();
const maxFeePerGas = feeData.maxFeePerGas ?? parseUnits("30", "gwei");
const maxPriorityFeePerGas =
feeData.maxPriorityFeePerGas ?? parseUnits("1", "gwei");
// Estimate gas for an ERC-20 transfer (approximately 21,000 for native sends)
const gasLimit = 65_000n;
const tx = await wallet.sendTransaction({
to: hotWalletAddress,
value: amount * 10n ** 12n, // Convert 6-decimal amount to 18-decimal native value
type: 2, // EIP-1559
maxFeePerGas,
maxPriorityFeePerGas,
gasLimit,
});
const receipt = await tx.wait();
return receipt!.hash;
}
When sweeping via a native USDC send, convert from 6-decimal amounts to
18-decimal value fields. Alternatively, call the ERC-20 transfer function
on the USDC contract, which accepts 6-decimal amounts directly.
Common mistakes
Avoid these pitfalls when implementing deposits:
- Filtering the wrong emitter: Plain native USDC sends emit no
Transfer on
the ERC-20 contract (0x3600…0000). Filter the system emitter (0xffff…fffe)
so you do not miss native deposits.
- Double-counting: An ERC-20
transfer() logs from both emitters. Filter
only the system emitter, and do not reconcile eth_getBalance against
Transfer events for the same address.
- Decimal mismatch: The system emitter’s values use 18 decimals. Treating
them as 6-decimal amounts inflates credited balances by 10^12—divide by 10^12
first.
- Waiting for multiple confirmations: Arc has deterministic finality.
Waiting for 6+ confirmations adds unnecessary latency with no security
benefit.
- Hardcoded keys: Never embed private keys in source code. Use environment
variables or a secrets manager for sweep wallet credentials.