Agent Cookbook12 copy-ready prompts for AI coding agents.12 prompts for AI agentsSee recipes
All Blog Posts
Building Financial Applications on Arc in 2026: USDC Decimals, Fee Floors, Finality and Ledger Reconciliation

Building Financial Applications on Arc in 2026: USDC Decimals, Fee Floors, Finality and Ledger Reconciliation

By Ben Chatwin 27min read

Your reconciliation job reports that a treasury wallet received 3.2 USDC in one transaction. The explorer shows a single 1.6 USDC transfer. The indexer subscribed to every Transfer event in the block, and Arc emitted two for that payment: one from the USDC contract at 6 decimals and one from a system address at 18 decimals. Normalize both and sum them, and the ledger is off by exactly the payment amount.

A payout service fails more quietly. It signs a transaction with a fee copied from another chain's defaults, gets a hash back from eth_sendRawTransaction and waits. No receipt arrives, because Arc's mempool dropped the transaction for pricing below the 20 Gwei floor.

Both bugs come from treating Arc like any other EVM chain. Arc, Circle's USDC-native Layer 1, runs Solidity and works with viem, but it builds payment rules into the protocol: USDC is the gas token, fees have a floor, blocks are final on commit and native transfers emit logs. Your contracts barely change. The code that reads balances, prices transactions, tracks settlement and builds the ledger does, and this guide covers each change in TypeScript with viem, using values read from Arc mainnet on 13 October 2026.

What Changes When USDC Is the Gas Token

Arc mainnet launched on 16 September 2026 with chain ID 5042 (0x13b2); the testnet is 5042002. Blocks arrive about every 0.5 seconds. Everything in this table is live on mainnet today.

Arc behaviorWhat your application does
One USDC balance with an 18-decimal native interface and a 6-decimal ERC-20 interfaceStore amounts as 18-decimal integers. Multiply ERC-20 amounts by 10^12 on ingest
Minimum base fee 20 Gwei, EWMA-smoothed, paid to the block proposerClamp maxFeePerGas above the floor. Treat a missing receipt as a possible drop
Finality on commit, finalized equals latestOne receipt settles a payment. Store the block's arc_getCertificate
EIP-7708 Transfer logs for native USDC movementIndex the system emitter only
Block timestamps are non-decreasing and can repeatOrder events by block number and log index
eth_getLogs capped at 10,000 blocks per requestChunk every backfill
debug_* and trace_* on Dwellir's archive nodes, not on Circle's public RPCFind the failing call with callTracer, then replay with eth_call
Value transfers to the zero address, precompiles or blocklisted accounts revertSimulate before signing

The examples share one module that defines the chain and the clients. The RPC URLs come from environment variables:

BASH
ARC_RPC_URL=https://api-arc-mainnet.n.dwellir.com/YOUR_API_KEY
ARC_WS_URL=wss://api-arc-mainnet.n.dwellir.com/YOUR_API_KEY

Circle's public endpoint, https://rpc.mainnet.arc.io, works for development.

TYPESCRIPT
// arc.ts
import {
  createPublicClient,
  createWalletClient,
  defineChain,
  http,
  type Hex,
} from 'viem'
import { privateKeyToAccount } from 'viem/accounts'

export const arc = defineChain({
  id: 5042,
  name: 'Arc',
  // Native USDC is 18 decimals.
  nativeCurrency: { name: 'USDC', symbol: 'USDC', decimals: 18 },
  rpcUrls: {
    default: {
      http: [process.env.ARC_RPC_URL!],
      webSocket: [process.env.ARC_WS_URL!],
    },
  },
  blockExplorers: {
    default: { name: 'Arc Explorer', url: 'https://explorer.arc.io' },
  },
})

// ERC-20 interface, 6 decimals
export const USDC = '0x3600000000000000000000000000000000000000'
// EIP-7708 Transfer emitter
export const SYSTEM_EMITTER = '0xffffFFFfFFffffffffffffffFfFFFfffFFFfFFfE'

// viem polls every 4 s by default. Arc produces a block every ~0.5 s.
export const publicClient = createPublicClient({
  chain: arc,
  transport: http(),
  pollingInterval: 500,
})

export const account = privateKeyToAccount(process.env.PAYOUT_KEY as Hex)
export const wallet = createWalletClient({
  account,
  chain: arc,
  transport: http(),
})

One USDC Balance, Two Decimal Interfaces

eth_getBalance on Arc returns USDC in 18 decimals. Calling decimals() on the ERC-20 interface at 0x3600000000000000000000000000000000000000 returns 6. These are two views of one balance. There is no wrapped asset and no conversion step, and an account's native value divided by 10^12 equals its balanceOf.

The ERC-20 view truncates. An account holding 0.0000005 USDC natively has a balanceOf of zero, so a zero ERC-20 balance does not prove an account is empty. Read balances from the native interface and keep a single internal unit.

TYPESCRIPT
// balances.ts
import { erc20Abi, formatUnits, type Address } from 'viem'
import { publicClient, USDC } from './arc'

const SCALE = 10n ** 12n // 18-decimal native units per 6-decimal ERC-20 unit

// Convert an ERC-20 amount (event value, API input) into ledger units.
export const erc20ToNative = (amount: bigint) => amount * SCALE

export async function readUsdc(address: Address) {
  const [native, erc20] = await Promise.all([
    publicClient.getBalance({ address }), // 18 decimals, full precision
    publicClient.readContract({
      address: USDC,
      abi: erc20Abi,
      functionName: 'balanceOf',
      args: [address],
    }),
  ])
  return {
    ledgerWei: native, // the value you store
    display: formatUnits(native, 18), // format only at the edge
    // Sub-micro-USDC the ERC-20 view hides
    truncatedDust: native - erc20ToNative(erc20),
  }
}

Store every amount as an 18-decimal bigint, and convert 6-decimal values at the boundary where they enter your system, such as ERC-20 event values or amounts from a payments API. Never add an 18-decimal number to a 6-decimal number. The 3.2 USDC error in the introduction is a separate bug, double counting, covered in the indexing section below.

The same split applies to tooling that touches native value. ERC-4337 paymasters on Arc deposit to the EntryPoint in native USDC at 18 decimals, so a deposit of parseUnits('10', 6) funds the paymaster with 10^7 wei, or 0.00000000001 USDC. For more on how USDC compares with other dollar tokens, see stablecoins explained.

A Fee Policy Priced in USDC

You pay gas on Arc from the same balance you are moving, so fees show up directly in your ledger. A 21,000-gas native transfer at the 20 Gwei base fee costs 21,000 x 20 x 10^9 = 4.2 x 10^14 wei, or 0.00042 USDC.

Live readings on 13 October 2026:

ReadingValue
eth_gasPrice0x4a817cc80 (20 Gwei + 1,152 wei)
eth_maxPriorityFeePerGas0x480 (1,152 wei)
baseFeePerGas in eth_feeHistory0x4a817c800 (20 Gwei) on every block
gasUsedRatioabout 0.03 to 0.05
Latest header extraData0x00000004a817c800 (next block's base fee)

The protocol sets a minimum base fee of 20 Gwei and a maximum of 20,000 Gwei, with a 30M gas block limit. The base fee moves along an exponentially weighted moving average instead of jumping per block as on Ethereum, and it is paid to the block beneficiary instead of being burned. The tip is effectively zero. Arc also writes the next block's base fee into the latest header's extraData as an 8-byte big-endian integer, so you can price against the block that will include you.

TYPESCRIPT
// fees.ts
import { hexToBigInt, parseGwei, size } from 'viem'
import { publicClient } from './arc'

export const BASE_FEE_FLOOR = parseGwei('20')

export async function arcFeeParams() {
  const block = await publicClient.getBlock({ blockTag: 'latest' })
  // Next block's base fee, 8 bytes big-endian in extraData.
  const nextBaseFee =
    size(block.extraData) === 8
      ? hexToBigInt(block.extraData)
      : block.baseFeePerGas ?? BASE_FEE_FLOOR
  // 1,152 wei today
  const maxPriorityFeePerGas =
    await publicClient.estimateMaxPriorityFeePerGas()
  // 2x headroom absorbs EWMA drift. Never sign below the floor.
  const withHeadroom = nextBaseFee * 2n + maxPriorityFeePerGas
  const maxFeePerGas =
    withHeadroom > BASE_FEE_FLOOR ? withHeadroom : BASE_FEE_FLOOR
  return { maxFeePerGas, maxPriorityFeePerGas }
}

You pay the effective gas price (base fee plus tip), and maxFeePerGas only caps it, so the headroom costs nothing while the base fee sits at the floor. It does affect balance checks: the sender needs value + gasLimit x maxFeePerGas available. A "send entire balance" feature must subtract that reservation from the amount, because gas and value come from the same USDC.

Detecting silent drops

A transaction with maxFeePerGas below 20 Gwei never enters a block. eth_sendRawTransaction may still return a hash, and receipt polling waits forever. The payout function below bounds the wait, checks whether any earlier attempt landed late, and only then replaces the transaction at the same nonce with a higher fee.

TYPESCRIPT
// payout.ts
import {
  TransactionReceiptNotFoundError,
  WaitForTransactionReceiptTimeoutError,
  type Address,
  type Hash,
} from 'viem'
import { account, publicClient, wallet } from './arc'
import { arcFeeParams } from './fees'

export async function sendPayout(to: Address, amountWei: bigint) {
  const nonce = await publicClient.getTransactionCount({
    address: account.address,
    blockTag: 'pending',
  })
  let fees = await arcFeeParams()
  const sent: Hash[] = []

  for (let attempt = 1; attempt <= 3; attempt++) {
    const hash = await wallet.sendTransaction({
      to,
      value: amountWei,
      nonce,
      ...fees,
    })
    sent.push(hash)
    try {
      return await publicClient.waitForTransactionReceipt({
        hash,
        timeout: 10_000,
      }) // ~20 blocks
    } catch (err) {
      if (!(err instanceof WaitForTransactionReceiptTimeoutError)) throw err
    }
    // An earlier attempt may have been included after its own timeout.
    for (const prev of sent) {
      try {
        return await publicClient.getTransactionReceipt({ hash: prev })
      } catch (err) {
        if (!(err instanceof TransactionReceiptNotFoundError)) throw err
      }
    }
    // Nothing landed: replace at the same nonce with double the fee.
    fees = {
      maxFeePerGas: fees.maxFeePerGas * 2n,
      maxPriorityFeePerGas: fees.maxPriorityFeePerGas * 2n,
    }
  }
  throw new Error(
    `Payout to ${to} not included after 3 attempts at nonce ${nonce}`,
  )
}

Once a receipt arrives, record the actual cost as receipt.gasUsed * receipt.effectiveGasPrice. That value is in 18-decimal USDC and belongs in the ledger as a fee line against the paying account. Gas fees do not appear as Transfer events, so the receipt is the only place to capture them.

Payment States Without a Confirming Step

Arc runs Malachite BFT consensus. A block is final when more than two thirds of validators precommit to it, and there is no fork to roll back. On mainnet, eth_getBlockByNumber returns the same block for finalized, safe and latest. The Dwellir guides on blockchain finality and chain reorgs cover what this removes compared with probabilistic chains.

For a payment state machine, that means no confirming state and no N-block wait. A successful receipt moves a payment straight to settled. A reverted receipt moves it to failed, and a reverted transaction still pays gas, so the fee is recorded either way.

On a probabilistic chain a payment moves from submitted to confirming to settled. On Arc it moves from submitted straight to settled or failed.

You still need idempotency. Finality rules out reorgs, but your own retries, a webhook delivered twice or a worker that crashes between sending and recording can still duplicate a payment. Key payments by an internal ID and store the nonce and every hash you broadcast, as sendPayout does.

Each settled block also has a commit certificate. arc_getCertificate(height) returns the height, consensus round, block hash and the validator signatures that committed it. Block 25712880 had 12 signatures in round 0. Certificates are immutable, so fetch one once, store it with the settlement record as audit evidence and cache it forever. A height that is unknown or not yet finalized returns -32004 Certificate not found.

TYPESCRIPT
// settle.ts
import {
  toHex,
  type Address,
  type Hash,
  type Hex,
  type TransactionReceipt,
} from 'viem'
import { publicClient } from './arc'

export type ArcCertificate = {
  height: number
  round: number
  block_hash: Hex
  signatures: { address: Address; signature: string }[] // base64 signatures
}

export type Payment =
  | { id: string; status: 'created' }
  | { id: string; status: 'submitted'; nonce: number; txHashes: Hash[] }
  | {
      id: string
      status: 'settled'
      txHash: Hash
      blockNumber: bigint
      blockHash: Hash
      feeWei: bigint
      certificate: ArcCertificate
    }
  | {
      id: string
      status: 'failed'
      txHash: Hash
      blockNumber: bigint
      feeWei: bigint
    }

// arc_getCertificate is not in viem's public RPC schema,
// so cast request to a typed signature.
const arcRequest = publicClient.request as unknown as (args: {
  method: 'arc_getCertificate'
  params: [Hex]
}) => Promise<ArcCertificate>

export async function settle(
  id: string,
  receipt: TransactionReceipt,
): Promise<Payment> {
  const feeWei = receipt.gasUsed * receipt.effectiveGasPrice // 18-decimal USDC
  if (receipt.status === 'reverted') {
    return {
      id,
      status: 'failed',
      txHash: receipt.transactionHash,
      blockNumber: receipt.blockNumber,
      feeWei,
    }
  }
  const certificate = await arcRequest({
    method: 'arc_getCertificate',
    params: [toHex(receipt.blockNumber)],
  })
  return {
    id,
    status: 'settled',
    txHash: receipt.transactionHash,
    blockNumber: receipt.blockNumber,
    blockHash: receipt.blockHash,
    feeWei,
    certificate,
  }
}

Store the certificate as returned. This guide does not verify the signatures locally, because the exact signed byte format is not documented in a form we could confirm. As a cheap consistency check, the block hash in the certificate should match receipt.blockHash.

Settlement covers payments your own service sends. Incoming payments, and any reconciliation against the chain, come from logs.

Indexing USDC Movement Without Double Counting

Arc implements EIP-7708. Every native USDC movement emits a standard Transfer event (topic0 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef) from the system emitter 0xffffFFFfFFffffffffffffffFfFFFfffFFFfFFfE, with the value at 18 decimals. An ERC-20 transfer() or transferFrom() on the USDC contract moves the same balance, so it produces a system log too, plus its own 6-decimal Transfer from 0x3600...0000. A native send emits only the system log.

We tested this over 2,000 consecutive mainnet blocks. The two emitters produced 15,463 Transfer logs: 9,689 from the system emitter and 5,774 from the ERC-20 contract. Every ERC-20 log had a matching system log in the same transaction with the same from, to and value x 10^12, except 7. All 7 were zero-value transfers or self-transfers, and neither changes a balance.

The 1.6 USDC payment from the introduction shows the pair. Transaction 0x005a6796... carried a system log with value 0x16345785d8a00000 (1.6 x 10^18, or 1.6 USDC) and an ERC-20 log with value 0x186a00 (1,600,000, also 1.6 USDC).

The fix is to filter Transfer logs by the system emitter address. A topic0-only filter picks up both logs for every ERC-20 transfer. The emitter filter captures every balance-changing USDC movement exactly once, at full precision, with no conversion. Block rewards are not Transfer events either, and gas fees come from receipts as shown above.

An ERC-20 USDC transfer emits a system emitter log at 18 decimals and a duplicate ERC-20 log at 6 decimals. A native send emits only the system emitter log.

Chunked backfill

eth_getLogs on Dwellir's Arc endpoint enforces two limits per request. The range can span at most 10,000 blocks; a larger one returns -32005 with the message eth_getLogs range of 10001 blocks exceeds the 10000-block limit for this plan. Circle's documentation cites a 10,000-block limit as well, with error -32012. The response can also hold at most 20,000 logs; a query over that returns -32602 query exceeds max results 20000 with a suggested smaller range.

At 0.5 seconds per block, 10,000 blocks is about 83 minutes of chain history. The system emitter produced about 4.8 logs per block in our sample, so a full 10,000-block window of USDC transfers on mainnet today exceeds the result cap.

Close to the cap, the response is also larger than the 10 MB body limit viem's HTTP transport applies by default, which throws ResponseBodyTooLargeError. A backfill has to chunk by block range and split any chunk that hits either limit.

TYPESCRIPT
// backfill.ts
import {
  InvalidParamsRpcError,
  ResponseBodyTooLargeError,
  parseAbiItem,
  type Log,
} from 'viem'
import { publicClient, SYSTEM_EMITTER } from './arc'

export const transferEvent = parseAbiItem(
  'event Transfer(address indexed from, address indexed to, uint256 value)',
)
export type TransferLog = Log<bigint, number, false, typeof transferEvent, true>

const MAX_RANGE = 10_000n // inclusive window: fromBlock..fromBlock + 9,999

// Fetch one range. Halve it when the node's 20,000-log cap
// or viem's 10 MB body limit is hit.
async function getTransfers(
  fromBlock: bigint,
  toBlock: bigint,
): Promise<TransferLog[]> {
  try {
    return await publicClient.getLogs({
      // System emitter only: one log per movement, 18 decimals
      address: SYSTEM_EMITTER,
      event: transferEvent,
      fromBlock,
      toBlock,
      strict: true,
    })
  } catch (err) {
    const tooMany =
      err instanceof InvalidParamsRpcError &&
      err.details.includes('max results')
    if (
      !(tooMany || err instanceof ResponseBodyTooLargeError) ||
      fromBlock === toBlock
    )
      throw err
    const mid = (fromBlock + toBlock) / 2n
    return [
      ...(await getTransfers(fromBlock, mid)),
      ...(await getTransfers(mid + 1n, toBlock)),
    ]
  }
}

export async function backfill(
  fromBlock: bigint,
  toBlock: bigint,
  onChunk: (logs: TransferLog[], lastBlock: bigint) => Promise<void>,
) {
  for (let start = fromBlock; start <= toBlock; start += MAX_RANGE) {
    const end =
      start + MAX_RANGE - 1n < toBlock ? start + MAX_RANGE - 1n : toBlock
    const logs = await getTransfers(start, end)
    logs.sort((a, b) =>
      a.blockNumber === b.blockNumber
        ? a.logIndex - b.logIndex
        : a.blockNumber < b.blockNumber
        ? -1
        : 1,
    )
    // Persist logs and the checkpoint in one DB transaction
    await onChunk(logs, end)
  }
}

Order ledger events by block number and log index. Timestamps can repeat: blocks 25712962 and 25712963 both carry timestamp 1791874629, because sub-second blocks share one-second timestamps. Use (transactionHash, logIndex) as the primary key for ledger rows and insert with ON CONFLICT DO NOTHING. Re-running a chunk then becomes harmless, which the real-time section relies on.

Splitting on the result cap also bounds each response, so memory stays flat during a long backfill. If you index only your own addresses, add the address as an indexed from or to argument; those queries return far fewer logs and rarely need to split. The eth_getLogs cost guide shows how per-method pricing changes the bill for this access pattern.

Streaming Transfers Over WebSocket

A backfill catches up to the head. Staying there at two blocks per second needs a push feed, because polling either falls behind or wastes requests. Dwellir's Arc endpoint supports eth_subscribe over WSS, and viem's watchEvent uses a logs subscription when the transport is a WebSocket.

A subscription can drop. Track the highest block you have processed, and on every (re)start subscribe first, then backfill from that checkpoint to the current head. Subscribing before the backfill leaves no window where a block is covered by neither. Because ledger inserts are idempotent, the overlap between the two causes no duplicates.

TYPESCRIPT
// stream.ts
import { createPublicClient, webSocket } from 'viem'
import { arc, publicClient, SYSTEM_EMITTER } from './arc'
import { backfill, transferEvent, type TransferLog } from './backfill'

const wsClient = createPublicClient({ chain: arc, transport: webSocket() })

// persist must be idempotent and only ever move the stored checkpoint forward.
export async function streamTransfers(
  checkpoint: bigint,
  persist: (logs: TransferLog[], lastBlock: bigint) => Promise<void>,
): Promise<() => void> {
  let last = checkpoint
  let restarting = false

  const unwatch = wsClient.watchEvent({
    address: SYSTEM_EMITTER,
    event: transferEvent,
    strict: true,
    onLogs: async (logs) => {
      const highest = logs.reduce(
        (max, l) => (l.blockNumber > max ? l.blockNumber : max),
        last,
      )
      await persist(logs, highest)
      last = highest
    },
    onError: async (err) => {
      if (restarting) return
      restarting = true
      console.error('Subscription error, resuming from block', last, err)
      unwatch()
      await streamTransfers(last, persist) // resubscribe, then backfill the gap
    },
  })

  // Gap-fill from the checkpoint to the head while the subscription runs.
  const head = await publicClient.getBlockNumber()
  if (head > checkpoint) await backfill(checkpoint + 1n, head, persist)
  if (head > last) last = head
  return unwatch
}

The in-memory checkpoint only advances on blocks that contain transfers, so a restart may re-read a few blocks. The (transactionHash, logIndex) key absorbs that. If you prefer heads over logs, watchBlockNumber on the same client subscribes to newHeads, and you can run backfill(last + 1n, blockNumber, persist) on each new head.

Payment References and Compliance Checks

A ledger row records who paid whom. Matching that row to an invoice, and keeping it from failing on a blocklisted counterparty, needs two more Arc features.

Attaching an invoice ID with Memo

Arc's Memo contract at 0x5294E9927c3306DcBaDb03fe70b92e01cCede505 wraps any call with a reference. memo(address target, bytes data, bytes32 memoId, bytes memoData) emits BeforeMemo(memoIndex), then the target call's own events, then a Memo event that includes a hash of the call data and is queryable by memoId. It uses Arc's CallFrom precompile, so the target sees your EOA as msg.sender.

TYPESCRIPT
// memo.ts
import {
  encodeFunctionData,
  erc20Abi,
  keccak256,
  parseAbi,
  parseUnits,
  stringToHex,
  type Address,
} from 'viem'
import { USDC, wallet } from './arc'

const MEMO = '0x5294E9927c3306DcBaDb03fe70b92e01cCede505'
const memoAbi = parseAbi([
  'function memo(address target, bytes data, bytes32 memoId, bytes memoData)',
])

export async function payInvoice(
  recipient: Address,
  amountUsdc: string,
  invoiceId: string,
) {
  const transfer = encodeFunctionData({
    abi: erc20Abi,
    functionName: 'transfer',
    args: [recipient, parseUnits(amountUsdc, 6)], // ERC-20: 6 decimals
  })
  return wallet.writeContract({
    address: MEMO,
    abi: memoAbi,
    functionName: 'memo',
    args: [
      USDC,
      transfer,
      keccak256(stringToHex(invoiceId)),
      stringToHex(invoiceId),
    ],
  })
}

Your indexer still records the movement from the system emitter's log. Join the Memo event to it on transaction hash to attach the invoice ID. Memo does not work from smart contract wallets, including ERC-4337 accounts and Safes, so payers using those need another reference mechanism.

Blocklist and transfer rules

The chain enforces USDC's blocklist at three points. A blocklisted sender is rejected at the RPC before reaching the mempool and pays no gas. A sender blocklisted between submission and execution is rejected before execution. A transfer to or from a blocklisted address during execution reverts, is still included and consumes gas.

Arc also reverts value transfers to the zero address (Zero address not allowed), value sent to precompiles and burns via selfdestruct to self. Blob (type 3) transactions are not accepted.

Run eth_call or eth_estimateGas on every value transfer before signing. viem's sendTransaction estimates gas during preparation, so a reverting transfer throws before broadcast.

For screening ahead of time, subscribe to the Blocklisted and UnBlocklisted events on the USDC contract and keep a local list. The enterprise stablecoin infrastructure guide covers the wider compliance stack.

Debugging Failed Transactions

Simulation catches most bad transfers before they are signed. For the ones that still revert on chain, start with a trace. Dwellir's Arc endpoint serves the debug_* and trace_* namespaces, and rpc_modules lists debug and trace next to eth, net, web3 and rpc. Circle's public RPC answers both with "method not supported", so a backend that diagnoses its own failures needs a provider with tracing enabled.

debug_traceTransaction with the callTracer returns the call tree. Each frame that reverted carries an error, plus a revertReason when the contract supplied one. Following the reverted frames down from the top gives the path to the call where the failure started.

TYPESCRIPT
// trace.ts
import type { Address, Hash, Hex } from 'viem'
import { publicClient } from './arc'

type CallFrame = {
  type: string
  from: Address
  to?: Address
  input: Hex
  error?: string
  revertReason?: string
  calls?: CallFrame[]
}

// debug_traceTransaction is not in viem's public RPC schema, so type it here.
const traceRequest = publicClient.request as unknown as (args: {
  method: 'debug_traceTransaction'
  params: [Hash, { tracer: 'callTracer' }]
}) => Promise<CallFrame>

// Follow reverted frames down the tree. A revert stops execution, so the
// last reverted child is the one that propagated up.
function failurePath(frame: CallFrame): CallFrame[] {
  if (!frame.error) return []
  const inner = (frame.calls ?? []).filter((call) => call.error).at(-1)
  return [frame, ...(inner ? failurePath(inner) : [])]
}

export async function whereItFailed(hash: Hash) {
  const root = await traceRequest({
    method: 'debug_traceTransaction',
    params: [hash, { tracer: 'callTracer' }],
  })
  return {
    reason: root.revertReason ?? root.error,
    path: failurePath(root).map(
      (f) => `${f.type} ${f.to} ${f.input.slice(0, 10)}`,
    ),
  }
}

A recent mainnet revert shows why the path matters. The transaction failed with "ERC20: transfer amount exceeds balance", and its top-level frame repeats that reason without saying where it came from. The path runs five frames deep:

TEXT
CALL         0x0148e86cfb50c200575c26849c60b7314833b966 0xe9ae5c53
CALL         0xb3fa262d0fb521cc93be83d87b322b8a23daf3f0 0x513e1175
DELEGATECALL 0x3f7132904b72ebfcad1d3c6520d90e1a6f90b691 0x513e1175
CALL         0x3600000000000000000000000000000000000000 0x23b872dd
DELEGATECALL 0xc6ad664ac6679f4ce74e10e91449c93ec1ae3ca6 0x23b872dd

The failure is a USDC transferFrom (selector 0x23b872dd) on the ERC-20 interface at 0x3600...0000, which delegates to USDC's implementation contract. The second and third frames are a proxy and its implementation, so the caller was an upgradeable contract.

A trace shows where a transaction failed. It does not show whether the call was wrong or lost a race to another transaction in the same block. For that, replay it with eth_call twice: once against the parent block, which is the state the transaction was built for, and once against the block's own end state, which includes everything that ran before it in that block.

TYPESCRIPT
// debug.ts
import { BaseError, type Hash } from 'viem'
import { publicClient } from './arc'

export async function explainRevert(hash: Hash) {
  const tx = await publicClient.getTransaction({ hash })
  const replay = async (blockNumber: bigint) => {
    try {
      await publicClient.call({
        account: tx.from,
        to: tx.to ?? undefined,
        data: tx.input,
        value: tx.value,
        gas: tx.gas,
        blockNumber, // needs state at this height to be available
      })
      return 'succeeds'
    } catch (err) {
      return err instanceof BaseError ? err.shortMessage : String(err)
    }
  }
  return {
    // Fails here: the call itself is bad
    parentState: await replay(tx.blockNumber! - 1n),
    // Fails only here: an earlier tx in the block changed state
    blockEndState: await replay(tx.blockNumber!),
  }
}

We ran this against the 12 most recent reverted transactions on mainnet. All 12 succeeded on parent state and reverted on the block's end state, four of them with a readable reason string. They were automated calls to five contracts that lost a race to an earlier transaction in the same block, a common pattern with 0.5 second blocks. The traced revert above follows the same pattern: it succeeds on parent state and fails on the block's end state, so the balance it needed was spent earlier in the block. A payment that reverts on parent state points to a problem in the call itself, such as a blocklisted counterparty or a value sent to the zero address.

The end-state replay also includes transactions that ran after yours in the same block, so it only approximates the original execution context. Both replays read state at a past block, which needs archive state. Dwellir's Arc nodes are archive nodes, so the replays work at any height. For local testing before mainnet, Arc ships Foundry tooling that includes arc-anvil.

What Is Live and What Is Roadmap

Arc's feature list mixes shipped protocol features with announced plans. For a financial application, the distinction decides what you can build on today.

FeatureStatus on mainnet
USDC gas, fee floor, deterministic finality, EIP-7708 logsLive
Memo contract (0x5294E9927c3306DcBaDb03fe70b92e01cCede505)Live
CCTP V2 (Arc domain 26), TokenMessenger 0x28b5a0e9C621a5BadaA536219b3a228C8168cf5dLive
Circle GatewayLive
EURC (0xbEf5f6d51CB62b58e6A8f77868681825C6fe21c1), USYC for institutionsLive
StableFX RFQ with atomic settlement via FxEscrow (0xe2E5F173576B513d994073CCbDaCBE027d43DFe6)Live, institutional
ERC-4337 accounts and paymastersLive
Post-quantum signature verification precompile (SLH-DSA-SHA2-128s)Live
Native post-quantum wallet signingNot live
Arc Privacy SectorRoadmap, not available
Proof-of-stake validator setPlanned; validators are permissioned today

Post-quantum support on Arc today is a precompile that verifies SLH-DSA-SHA2-128s signatures, so a contract can check a post-quantum signature over a message it receives. Your users' accounts still sign transactions with secp256k1. Native SLH-DSA transaction signing depends on a draft Ethereum proposal and is not available, and post-quantum validator signatures are a longer-term goal.

The Privacy Sector is a design for confidential execution alongside the public EVM: same-block finality, default-deny visibility, atomic interaction between public and private state, written in Solidity. Arc's documentation states that privacy features are on the roadmap and not yet available, so a launch plan that depends on confidential balances needs a fallback.

Founding validators named at launch include BlackRock, DTCC, Visa, Mastercard, ICE and Standard Chartered. The set is permissioned today, and Circle plans to move to proof-of-stake later.

Running an Arc Payments Backend on Dwellir

Every pattern above lands on a few RPC methods: eth_getLogs for backfills, eth_subscribe for live transfers, eth_getTransactionReceipt for settlement, eth_getBalance and eth_call for reads and simulation, debug_traceTransaction for failures, and arc_getCertificate for audit evidence. Indexing Arc is eth_getLogs-heavy by construction, so method pricing matters more than usual.

Dwellir serves Arc mainnet over HTTPS and WSS at the endpoints used in the setup module. Billing is one request per response for every method, so a 10,000-block eth_getLogs costs the same as eth_blockNumber. The free tier includes 100,000 responses per day, and the Developer plan is $49 per month.

The endpoint runs archive nodes. Logs and receipts go back to genesis, which covers backfills, ledger rebuilds and receipt lookups for any historical payment. eth_call and eth_getBalance work at any past block height, so the debugging replays above and point-in-time balance checks for audits run against the same endpoint as everything else.

The Arc RPC provider comparison lists which providers offer archive state, and the stablechain RPC guide compares Arc with Tempo, Plasma and Stable.

To try the code in this guide, create a free Dwellir API key, set ARC_RPC_URL and ARC_WS_URL, and run the backfill over the last 10,000 blocks. The Arc documentation lists supported methods and connection details.

FAQ

How many decimals does USDC have on Arc?

Both 18 and 6, for the same balance. The native interface (eth_getBalance, msg.value, address.balance and transaction fees) uses 18 decimals. The ERC-20 interface at 0x3600000000000000000000000000000000000000 returns 6 from decimals(). They are one balance with no wrapper, and the native value divided by 10^12 gives the 6-decimal amount. Record balances and ledger amounts from the 18-decimal native value, because balanceOf truncates anything below one micro-USDC.

How many confirmations does Arc need?

One. Arc uses Malachite BFT consensus, which finalizes a block when more than two thirds of validators precommit to it, so there are no reorgs. On Arc mainnet the finalized, safe and latest block tags return the same block number. A successful receipt is settlement, and the block's arc_getCertificate response holds the validator signatures that committed it.

Why did my Arc transaction disappear with no receipt?

Its maxFeePerGas was almost certainly below Arc's 20 Gwei minimum base fee. The mempool drops such transactions, and eth_sendRawTransaction may still return a hash, but no receipt ever appears. Clamp maxFeePerGas to at least 20 Gwei with headroom before signing, and treat a receipt timeout as a possible drop: check whether any earlier attempt landed, then resend at the same nonce with a higher fee.

How do I track USDC transfers on Arc without double counting?

Index Transfer logs from the EIP-7708 system emitter 0xffffFFFfFFffffffffffffffFfFFFfffFFFfFFfE only. Every balance-changing USDC movement, native or ERC-20, produces one Transfer log there at 18 decimals. ERC-20 transfer and transferFrom calls also emit a 6-decimal Transfer from the USDC contract, so filtering on topic0 alone counts those movements twice. Gas fees are not Transfer events; derive them from receipt.gasUsed times receipt.effectiveGasPrice.

Does Arc support debug and trace methods?

Dwellir's Arc endpoint serves both namespaces: rpc_modules lists debug and trace, and debug_traceTransaction with the callTracer returns the call tree with a revert reason on the frame that failed. Circle's public RPC does not expose them. Support differs by provider, so check rpc_modules on the endpoint you plan to use. To tell a bad call from one that lost a race inside its block, replay it with eth_call against the parent block and against the block's end state.

Is post-quantum signing live on Arc?

Partly. Arc has a live precompile that verifies SLH-DSA-SHA2-128s signatures, so a contract can check a post-quantum signature over a message at the application layer. Native wallet signing with SLH-DSA is not live; it depends on a draft Ethereum proposal. Arc's Privacy Sector is on the roadmap and not yet available.

Can I attach a payment reference to an Arc transfer?

Yes, through Arc's Memo contract at 0x5294E9927c3306DcBaDb03fe70b92e01cCede505. Call memo(target, data, memoId, memoData) with the USDC transfer as the target call and an invoice ID as the memo. The contract emits BeforeMemo, the target's events and a Memo event queryable by memoId, and keeps the EOA as msg.sender. Smart contract wallets such as ERC-4337 accounts and Safes are not supported.

Sources

RPC readings were taken from Dwellir's Arc mainnet endpoint on 13 October 2026. The 2,000-block Transfer log comparison was run on the same day.

read another blog post