Two emitters, one balance
| Source | Emitter | Decimals | Covers |
|---|---|---|---|
| Native system (EIP-7708) | 0xffff…fffe |
18 | Every real movement |
ERC-20 NativeFiatToken |
0x3600…0000 |
6 | ERC-20 interface calls only |
Both use topic0
0xddf252ad…3b3ef. A plain native send emits one log; an ERC-20
transfer() emits two. Match on the emitter address, and never mix the
6-decimal and 18-decimal values.
What it costs to get wrong
Measured over 150 consecutive blocks of live Arc Testnet traffic.
raw logs
native emitter (18 dec) 1589
ERC-20 emitter (6 dec) 1073
canonical movements 1589
initiated via ERC-20 1066 (logged twice by the chain)
plain native sends 523 (logged once)
sub-6-decimal dust 57 (invisible to balanceOf)
ERC-20 logs the native emitter deliberately omits
self-transfers 7 (from == to, no balance change)
zero-value 0
what a naive indexer gets wrong
transfer rows 2662 vs 1589 real (+67.5% phantom rows)
volume, no dedup 11507.064102 USDC (+83.1%)
volume, canonical 6284.280334 USDC
✓ every value-moving ERC-20 log paired with its native log
The volume figure is the realistic failure: an indexer that normalises each emitter's decimals correctly but never deduplicates still overstates flow by 83%.
The exceptions that look like bugs
The native emitter records every movement — with two documented omissions, both of which the ERC-20 contract logs anyway because the token standard requires it:
- Zero-value transfers emit no native log.
- Self-transfers (
from == to) emit no native log.
Seven self-transfers appeared in that 150-block window. An indexer trusting the
ERC-20 stream alone invents balance changes for every one of them. arc-index
classifies them as expected rather than flagging them, and reserves
anomalies for what should be impossible: a value-moving ERC-20 log
with no native counterpart.
Proving an index, not trusting it
node src/cli.ts reconcile --address 0x… --blocks 60
balance before 3065983.987369 USDC
balance after 3065987.839446 USDC
actual delta 3.852077 USDC (eth_getBalance)
indexed delta 3.852077 USDC (6 movements)
residual 0 USDC
✓ exact: every wei of movement is accounted for by logs
Replay every indexed movement for an address and compare against the node. Three outcomes, and the middle one matters:
- exact — every wei is explained by logs.
-
fees-only — a negative residual. EIP-7708 does not emit a
Transferfor fee payment, so this is the expected signature of a correct index on a fee payer, not a bug. - mismatch — value arrived that no log explains. The index is incomplete.
The rule ships where you build indexers
The dedup logic is a pure function over normalised logs, with no RPC or framework dependency, so all three consumers agree on what a movement is.
Subsquid (SQD)
A batch processor is the natural fit: SQD hands you whole blocks, so both streams for a transaction are always in the same batch and the rule applies with no buffering.
const processor = new EvmBatchProcessor()
.setRpcEndpoint(ARC_RPC_SETTINGS)
.setFields({ log: REQUIRED_LOG_FIELDS })
.addLog(arcUsdcLogRequest()); // one request, both emitters
processor.run(new TypeormDatabase(), async (ctx) => {
const result = movementsFromBatch(ctx.blocks);
await ctx.store.insert(toRows(result).map((r) => new UsdcMovement(r)));
});
94 EVM networks are listed as of August 2026, none of them Arc. Ingestion is
RPC-only — do not call setGateway, there is nothing to point it at.
Ponder
Ponder is event-at-a-time, so there is no batch in which to pair the two logs. The fix follows from what the chain guarantees:
- The native emitter logs every real movement — indexing it alone already yields each movement exactly once.
- The ERC-20 emitter adds no movements, only the knowledge that one was ERC-20-initiated.
- Arc emits the native log first, before any other log in the transaction.
So: native events create every row, and ERC-20 events update the row they duplicate. Point 3 is what makes that safe — the row always exists by the time the marker arrives, which makes double-counting structurally impossible rather than merely avoided.
Matching uses (txHash, from, to, value × 10¹²) ordered by
logIndex, so two identical transfers in one transaction pair up in log
order, exactly as the batch dedup does.
Operational limits worth knowing
-
eth_getLogscaps differ per endpoint and none are documented: 30,000 onrpc.testnet.arc.io, 10,000 on the dRPC mirror, 50,000+ on Blockdaemon. Requests are chunked to fit the strictest, and split recursively on failure. -
The RPC sits behind Cloudflare and returns 429 on bursts, even at
concurrency 4. The shared client rate-limits itself and backs off adaptively;
override with
ARC_RPC_RPS(default 12). -
Before the Zero5 hard fork, testnet emitted
NativeCoin*events from a different precompile. Backfills crossing that boundary need both shapes.
Tests
29 tests across the core and both adapters, all against canned logs with no network: the pairing rule, one-to-one log consumption, dust truncation, the self-transfer and zero-value exceptions, and that net deltas sum to zero.