Live on Robinhood Chain — launch tokens with locked liquidity via MintPlus →Arc is coming — Circle’s stablecoin L1, mainnet Sept 16 · Get ready →T-16
Arc

Front-End Development on Arc with viem and wagmi

Last verified: August 2026By the TrustSwap Team
Today on Arc: mainnet countdown, ARC token news, and every launch — covered daily. → Read today’s briefing

Building an Arc front end starts with a chain definition — a `defineChain` call with the RPC, chain ID, native currency and explorer, passed to a wagmi config like any other chain. Writing your own is the reliable path in Arc's first weeks, whether or not `viem/chains` has picked up an `arc` export by the time you install. The one field people get wrong is `nativeCurrency`: Arc's gas token is USDC with 18 decimals at the protocol level, not the 6 decimals of ERC-20 USDC elsewhere. Get that field right and every formatting helper downstream behaves.

This page covers Arc in its first weeks of mainnet. Where something is still settling we say so rather than guess, and we revise this page as the ecosystem fills in. Last reviewed: September 2026.

Should you define the chain yourself or import it?

Define it yourself, at least for now. viem/chains is a registry contributed to by chain teams and community pull requests, and a chain that launched on September 16, 2026 may or may not have landed in the version you install today — the viem changelog on GitHub is the record, and import { arc } from "viem/chains" failing to resolve is the fastest test. Where a first-party definition exists by the time you read this, prefer it. Until then a local defineChain costs twenty lines, removes a dependency on someone else's release cadence, and lets you source the mainnet chain ID and RPC from environment variables — the pattern you want anyway while those values are still being substituted in from Circle's documentation.

What does the Arc chain definition look like in viem?

viem's defineChain takes an object with id, name, nativeCurrency, rpcUrls and blockExplorers. Arc's testnet values are published — chain ID 5042002, RPC rpc.testnet.arc.io, explorer testnet.arcscan.app. Mainnet chain ID, RPC and explorer URL are not published in this guide and appear below as MAINNET-TBD; substitute the real values from Circle's official documentation before you ship to production, and never guess the chain ID.

// src/chains/arc.ts
import { defineChain } from "viem";

export const arcTestnet = defineChain({
  id: 5042002,
  name: "Arc Testnet",
  nativeCurrency: {
    // Gas on Arc is paid in USDC. 18 decimals at the protocol level —
    // NOT the 6 decimals of ERC-20 USDC on Ethereum or Base.
    name: "USD Coin",
    symbol: "USDC",
    decimals: 18,
  },
  rpcUrls: {
    default: { http: ["https://rpc.testnet.arc.io"] },
  },
  blockExplorers: {
    default: { name: "Arcscan", url: "https://testnet.arcscan.app" },
  },
  testnet: true,
});

export const arc = defineChain({
  // MAINNET-TBD — substitute the published mainnet chain ID. Do not guess.
  id: Number(import.meta.env.VITE_ARC_CHAIN_ID), // MAINNET-TBD
  name: "Arc",
  nativeCurrency: { name: "USD Coin", symbol: "USDC", decimals: 18 },
  rpcUrls: {
    // MAINNET-TBD — substitute the published mainnet RPC endpoint.
    default: { http: [import.meta.env.VITE_ARC_RPC_URL] },
  },
  blockExplorers: {
    // MAINNET-TBD — substitute the published mainnet explorer URL.
    default: { name: "Arcscan", url: import.meta.env.VITE_ARC_EXPLORER },
  },
  testnet: false,
});

A defineChain object is plain data, so it is worth exporting from one module and importing everywhere. The failure mode when it is duplicated is a front end that reads state from one RPC and submits transactions to another.

How do you set up clients and read contract state?

viem splits reads and writes across two client types: a publicClient for RPC reads and simulation, and a walletClient for signing. Both take the same chain object.

// src/lib/clients.ts
import { createPublicClient, createWalletClient, custom, http } from "viem";
import { arcTestnet } from "../chains/arc";

export const publicClient = createPublicClient({
  chain: arcTestnet,
  transport: http(), // uses chain.rpcUrls.default
});

export const getWalletClient = () =>
  createWalletClient({
    chain: arcTestnet,
    transport: custom(window.ethereum!),
  });
// Reading an ERC-20 balance and the native gas balance
import { erc20Abi, formatEther, formatUnits } from "viem";
import { publicClient } from "./lib/clients";

const account = "0x0000000000000000000000000000000000000000" as const;

// Native gas balance — USDC, 18 decimals. formatEther is correct.
const gas = await publicClient.getBalance({ address: account });
console.log(`${formatEther(gas)} USDC available for gas`);

// A token contract's own decimals still come from the contract.
const token = "0x0000000000000000000000000000000000000000" as const;
const [raw, decimals] = await publicClient.multicall({
  contracts: [
    { address: token, abi: erc20Abi, functionName: "balanceOf", args: [account] },
    { address: token, abi: erc20Abi, functionName: "decimals" },
  ],
  allowFailure: false,
});
console.log(formatUnits(raw, decimals));

Never hardcode a token's decimals. Read them from the contract. On a chain where the native unit is 18 decimals and the most widely bridged asset elsewhere is 6, a hardcoded constant will eventually be wrong.

That multicall call carries an assumption worth checking. viem batches the two reads into one RPC call only when it knows a Multicall3 address for the chain; otherwise it issues them separately, which works but costs the round-trip saving. Multicall3 sits at the same address on every chain that has it — 0xcA11bde05977b3631167028862bE2a173976CA11 — because the project deploys it with a keyless presigned transaction rather than a per-chain deploy, and its documentation reports the contract at that address on more than 250 chains.

Whether Arc is one of them is a call, not an assumption: the presigned transaction still has to be funded and broadcast on each chain. Run cast code 0xcA11bde05977b3631167028862bE2a173976CA11 --rpc-url <arc-rpc>. Anything other than 0x means it is deployed and you can add it to the chain definition.

// Only after confirming non-empty bytecode at the address.
contracts: {
  multicall3: {
    address: "0xcA11bde05977b3631167028862bE2a173976CA11",
    blockCreated: 0, // the block your `cast code` check confirms it from
  },
},

How do you wire up wagmi?

wagmi wraps viem with React hooks and connector management. The config takes your chain objects, a connector list, and a transport per chain. Everything else — useAccount, useReadContract, useWriteContract — is chain-agnostic once the config is right.

// src/wagmi.ts
import { createConfig, http } from "wagmi";
import { injected, walletConnect } from "wagmi/connectors";
import { arc, arcTestnet } from "./chains/arc";

export const config = createConfig({
  chains: [arc, arcTestnet],
  connectors: [
    injected(),
    walletConnect({ projectId: import.meta.env.VITE_WC_PROJECT_ID }),
  ],
  transports: {
    [arc.id]: http(import.meta.env.VITE_ARC_RPC_URL), // MAINNET-TBD
    [arcTestnet.id]: http("https://rpc.testnet.arc.io"),
  },
});

declare module "wagmi" {
  interface Register {
    config: typeof config;
  }
}

The walletConnect connector is the line most likely to misbehave in Arc's first months. WalletConnect sessions negotiate chains by CAIP-2 identifier, and a wallet that does not recognise the eip155:<arc-chain-id> namespace can refuse the session outright rather than degrading gracefully. How broadly Arc is recognised across the wallet population is changing week by week, and no version number settles it. Test against the wallets your users actually have, keep injected() in the list as the path that depends on no one's registry, and treat a rejected session as a normal UI state rather than an exception. Best wallets for Arc tracks which wallets have shipped support.

A write path, with the simulate-then-write pattern that catches reverts before the user signs:

// src/components/Transfer.tsx
import { parseUnits } from "viem";
import { useAccount, useSimulateContract, useWriteContract,
         useWaitForTransactionReceipt } from "wagmi";
import { erc20Abi } from "viem";

export function Transfer({ token, to, amount, decimals }: Props) {
  const { address, chainId } = useAccount();

  const { data: sim, error } = useSimulateContract({
    address: token,
    abi: erc20Abi,
    functionName: "transfer",
    args: [to, parseUnits(amount, decimals)],
    account: address,
  });

  const { writeContract, data: hash, isPending } = useWriteContract();
  const { isLoading, isSuccess } = useWaitForTransactionReceipt({
    hash,
    // Arc finality is deterministic and sub-second; one confirmation
    // is sufficient, unlike probabilistic-finality chains.
    confirmations: 1,
  });

  if (error) return <p>Cannot send: {error.shortMessage}</p>;

  return (
    <button disabled={!sim || isPending || isLoading}
            onClick={() => writeContract(sim!.request)}>
      {isPending ? "Confirm in wallet" : isSuccess ? "Sent" : "Send"}
    </button>
  );
}

The wallet's confirmation modal is where your chain definition becomes visible to the user, and two of its fields come from the object you wrote rather than from the wallet. Check both during development: the network name at the top of the prompt should read "Arc" and not a bare chain ID, and the estimated fee should be denominated in USDC. A fee line reading "ETH" means either the wallet is inferring the symbol from the 18-decimal native unit or your nativeCurrency.symbol never reached it — check your own definition first.

How do you prompt a user to add or switch to Arc?

Most users will not have Arc configured. wagmi's useSwitchChain handles both cases: if the wallet knows the chain it switches, and if it does not, the connector falls back to an wallet_addEthereumChain request built from your chain object — which is exactly why the nativeCurrency and blockExplorers fields matter. A wrong decimals value here produces a wallet that displays the user's gas balance off by a factor of a trillion.

import { useAccount, useSwitchChain } from "wagmi";
import { arc } from "../chains/arc";

export function NetworkGate({ children }: { children: React.ReactNode }) {
  const { chainId, isConnected } = useAccount();
  const { switchChain, isPending } = useSwitchChain();

  if (!isConnected) return <ConnectButton />;
  if (chainId !== arc.id) {
    return (
      <button disabled={isPending} onClick={() => switchChain({ chainId: arc.id })}>
        Switch to Arc
      </button>
    );
  }
  return <>{children}</>;
}

Gate every write path on the connected chain ID. Do not gate on the connector's reported network name, which is not standardised across wallets — and note that some wallets currently mislabel Arc's native balance as "ETH" because they infer the symbol from the 18-decimal native unit rather than reading it from the chain metadata. The 18-decimals explainer covers why. If you are writing user-facing setup instructions to accompany your app, adding Arc to MetaMask is the page to link rather than duplicating.

What about gas estimation and fee display?

Estimate as you would anywhere, then format with 18 decimals and label the result in dollars — because on Arc it literally is dollars. Testnet transactions averaged roughly $0.004 as of August 2026, which means most apps can show an exact fee rather than a range and skip the "gas is expensive right now" UX entirely.

import { formatEther } from "viem";
import { publicClient } from "./lib/clients";

const gas = await publicClient.estimateContractGas({
  address: token, abi: erc20Abi, functionName: "transfer",
  args: [to, value], account,
});
const { maxFeePerGas, gasPrice } = await publicClient.estimateFeesPerGas();
// Handles both fee markets: EIP-1559 chains return maxFeePerGas,
// legacy chains return gasPrice. Do not assume which one Arc gives you.
const feeUsdc = formatEther(gas * (maxFeePerGas ?? gasPrice ?? 0n));
console.log(`≈ $${Number(feeUsdc).toFixed(4)}`);

The nullish fallback there is deliberate. viem's estimateFeesPerGas returns EIP-1559 fields when the chain serves eth_feeHistory and a legacy gasPrice when it does not; which of those Arc mainnet answers with is Circle's developer documentation to state, not ours. Handling both costs one operator and removes the failure mode where an undefined maxFeePerGas multiplies out to a displayed fee of zero — a bug the user discovers only after signing.

Show it. A user who can see a four-tenths-of-a-cent fee before signing is a user who does not abandon the flow. It is also worth deciding early whether your app displays the fee as a USDC amount or as a dollar amount, because on Arc those are the same number and showing both reads as a bug. For the mechanics of how Arc's fee market works, see gas fees on Arc.

One consequence of dollar-denominated gas that is easy to miss: your error handling changes. On chains where fees spike, the standard defensive pattern is a retry with a bumped fee cap. On Arc the realistic failure is an empty gas balance, not an underpriced transaction — a user who has bridged tokens but holds no native USDC cannot transact at all. Check getBalance before you enable a submit button and tell the user what is missing, rather than surfacing a raw insufficient funds for gas string from the RPC.

How should you structure a testnet-first front end?

Ship against testnet first and make the environment switch a build-time concern rather than a runtime toggle. Read the chain ID, RPC URL and explorer URL from environment variables in every case, including testnet — that way promoting to mainnet is a change to a .env file and a redeploy, not a code edit under time pressure. A missing VITE_ARC_CHAIN_ID should crash the build, which is exactly the behaviour you want while mainnet values remain unpublished in your repo.

For the contracts your front end is calling, Hardhat and Foundry on Arc covers the deployment side of the same setup, and the chain ID in your viem definition must match the one in your Hardhat config exactly.

FAQ

Does viem or wagmi include Arc out of the box?

Check rather than assume either way. viem/chains is a community-maintained registry and Arc launched on 16 September 2026, so whether an arc export exists depends on the version you install and when a definition was merged — the viem changelog on GitHub is the record, and attempting the import is the fastest test. Where it resolves, use it; where it does not, define Arc with defineChain and pass the object to createConfig. The code in this guide works either way, because wagmi treats a hand-written chain object and an imported one identically.

What decimals should nativeCurrency use for Arc?

Eighteen. Arc's gas token is USDC with 18 decimals at the protocol level. ERC-20 USDC on Ethereum, Base and other chains uses 6 decimals, but that does not apply to Arc's native unit. Use formatEther and parseEther for native amounts.

How do I get the mainnet chain ID and RPC URL?

From Circle's official Arc documentation. This guide deliberately leaves them as MAINNET-TBD placeholders because publishing a guessed chain ID risks users signing transactions against the wrong replay-protection domain. Verify any value you find by calling eth_chainId on the RPC before shipping.

Do I need to wait for multiple confirmations on Arc?

No. Arc uses Malachite consensus with deterministic finality in roughly 780 milliseconds, so an included transaction is final rather than probabilistically settled. One confirmation is sufficient for front-end UX. Custodial systems crediting customer balances are a separate case and usually set that policy from an issuer's published guidance; Circle has not published a recommended confirmation depth for Arc as of this writing, so take the number from its documentation when it appears.

Can I reuse an existing Base or Ethereum front end on Arc?

Largely yes — Arc is EVM-compatible, so ABIs, hooks and contract calls port unchanged. The work is in the chain definition, the connector configuration, and every place your UI assumes 6-decimal USDC or an ETH-denominated fee string.

Shipping a token alongside your app? Team Finance covers minting, liquidity locks, vesting and multisender on Arc with flat USDC-quoted fees — start at /arc for the full builder hub.Open Team Finance →

Sources: Circle documentation and Arc developer docs (arc.io, developers.circle.com) for chain parameters, consensus and fee data; viem documentation (viem.sh), the viem changelog on GitHub (github.com/wevm/viem) and wagmi documentation (wagmi.sh) for API surface, chain registry and configuration syntax; the multicall3 project documentation (github.com/mds1/multicall3) for the canonical Multicall3 address and its keyless presigned deployment method; Arc testnet explorer testnet.arcscan.app.

Last verified: August 2026

Mainnet opens September 16. Be ready before it does.

Team Finance has secured $2.7B+ across 40,000+ projects since 2020. Mint the token, lock the liquidity, vest the team and run distribution — on a chain where the fees are quoted in dollars.

Launch a token on ArcLock your liquidityGet The Crypto App