Skip to content

docs: add MetaMask integration guide with USDC token setup#100

Open
zkasuran wants to merge 5 commits into
circlefin:mainfrom
zkasuran:docs/metamask-usdc-integration
Open

docs: add MetaMask integration guide with USDC token setup#100
zkasuran wants to merge 5 commits into
circlefin:mainfrom
zkasuran:docs/metamask-usdc-integration

Conversation

@zkasuran

Copy link
Copy Markdown

Summary

Adds comprehensive MetaMask integration guide for Arc Testnet, including the critical wallet_watchAsset call to register USDC.

Problem

Issue #97 identified that there's no documented way to add Arc Testnet USDC to MetaMask's token list. Without this:

  • Users see no USDC balance after receiving tokens
  • Users assume transactions failed
  • DApps appear broken

Solution

New guide at docs/metamask-integration.md covering:

  1. Network setup with wallet_addEthereumChain
  2. USDC token registration with wallet_watchAsset (the missing piece)
  3. Complete onboarding flow combining both steps
  4. Contract addresses and chain configuration
  5. Why this matters section explaining the impact

Changes

  • ✅ New file: docs/metamask-integration.md
  • ✅ Updated README.md to link to new guide
  • ✅ Complete TypeScript code examples
  • ✅ Addresses issue reproduction steps

Testing

Code examples follow MetaMask's official API documentation:

Impact

Every DApp on Arc Testnet that involves USDC transfers benefits from this documentation.

Fixes #97

Adds comprehensive guide for integrating Arc Testnet with MetaMask,
including the critical wallet_watchAsset call to register USDC.

Without this step, users see no USDC balance in MetaMask after
receiving tokens, causing confusion and making DApps appear broken.

Includes:
- Complete onboarding flow with wallet_addEthereumChain
- wallet_watchAsset call for USDC token registration
- Contract addresses and chain configuration
- Why this matters section explaining the impact

Fixes circlefin#97

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@osr21

osr21 commented Jun 2, 2026

Copy link
Copy Markdown

Great guide — this addresses the exact pain point in #97.

One more MetaMask behaviour on Arc Testnet that's worth documenting alongside the wallet_watchAsset call: wallet_switchEthereumChain fails silently (tracked in #89).

Even after the network has been added, calling wallet_switchEthereumChain for Arc Testnet either resolves without switching or throws a 4902 even when the chain exists. The only reliable pattern is to always use wallet_addEthereumChain, which acts as both "add if missing" and "switch if present":

// ❌ Unreliable on Arc Testnet — may resolve without actually switching
await window.ethereum.request({
  method: 'wallet_switchEthereumChain',
  params: [{ chainId: '0x4CEF52' }],
});

// ✅ Reliable — works whether network is already added or not
await window.ethereum.request({
  method: 'wallet_addEthereumChain',
  params: [{
    chainId: '0x4CEF52',
    chainName: 'Arc Testnet',
    nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
    rpcUrls: ['https://rpc.drpc.testnet.arc.network'],
    blockExplorerUrls: ['https://testnet.arcscan.app'],
  }],
});

Note the nativeCurrency — Arc's gas is paid in USDC but MetaMask requires symbol: 'ETH' and decimals: 18 to accept the chain config (documented in #95). Using the actual USDC values causes MetaMask to reject the wallet_addEthereumChain call entirely.

Also: the rpcUrls entry matters here too — rpc.testnet.arc.network has CORS issues for browser DApps (#90), so rpc.drpc.testnet.arc.network is the reliable public endpoint to point users at.

Three corrections to the MetaMask guide, all verified against the repo
config, the MetaMask spec and the live testnet RPC:

- chainId was 0x4CF252 (5042002 transposed to 5042770). The testnet
  chain id is 5042002, which is 0x4CEF52 (hardhat.config.ts, defaults.rs).
- nativeCurrency was USDC/6 decimals. MetaMask only supports 18-decimal
  native currencies and rejects decimals != 18, so the add call has to
  use ETH/18 even though gas is paid in USDC (issue circlefin#95). The USDC
  ERC-20 watchAsset call keeps decimals: 6, which is correct.
- rpcUrls was https://rpc.arc.network, which does not resolve. Switched
  to https://rpc.drpc.testnet.arc.network, which returns chain id
  0x4cef52 and Access-Control-Allow-Origin: * for browser DApps (circlefin#90).

Also documents that wallet_switchEthereumChain fails silently or throws
4902 on Arc Testnet, and that wallet_addEthereumChain should be used as
both add and switch (issue circlefin#89).
@zkasuran

zkasuran commented Jun 8, 2026

Copy link
Copy Markdown
Author

Thanks @osr21, this was a good catch and it surfaced more than the switch-chain issue. I verified everything against the repo config, the MetaMask spec and the live testnet RPC, and pushed a fix. Three corrections plus your switch-chain note:

AI disclosure: these fixes were prepared with help from Claude (Anthropic). I verified each value against the repo, the MetaMask wallet_addEthereumChain docs and a live eth_chainId call to both RPC endpoints before pushing.

@osr21

osr21 commented Jun 9, 2026

Copy link
Copy Markdown

Thanks for the thorough fixes — the chainId, RPC, and nativeCurrency corrections all match what we see in a working implementation.

Two small additions:

wallet_watchAsset ordering — the call needs to happen after wallet_addEthereumChain fully resolves. If it fires while the user is still on a different network, MetaMask silently registers the USDC token on the wrong chain and the balance never appears. This is easy to hit in a combined "add network + add token" onboarding flow:

// ✅ Correct — await the chain switch first
await window.ethereum.request({ method: 'wallet_addEthereumChain', params: [arcConfig] });
await window.ethereum.request({ method: 'wallet_watchAsset', params: [usdcConfig] });

Dead block explorer URLs — worth a note in the guide that explorer.testnet.arc.network and explorer.arc.io no longer resolve. https://testnet.arcscan.app is the only working endpoint right now, so it should be the default in both the blockExplorerUrls field and any tx-link examples.

@zkasuran

Copy link
Copy Markdown
Author

Thanks @osr21, added both, pushed in b33caaa.

  • The Register USDC section now notes that wallet_watchAsset must be called only after the wallet_addEthereumChain promise resolves, since firing it while the user is still on another network registers USDC against the wrong chain and the balance never shows. The onboarding flow already awaits the chain add first, the reason is now spelled out.
  • Added the explorer note. I checked the three hosts directly: explorer.testnet.arc.network no longer resolves (no DNS), and explorer.arc.io does resolve but redirects to a Circle Cloudflare Access login rather than a public explorer, so testnet.arcscan.app is the only one that works publicly. The guide already pointed at arcscan throughout, the note makes that explicit for blockExplorerUrls and tx links.

AI disclosure: these doc edits were prepared with help from Claude (Anthropic). I verified the three explorer hosts directly (DNS and HTTP) before pushing; the wallet_watchAsset ordering note documents the behavior you reported.

@osr21

osr21 commented Jun 10, 2026

Copy link
Copy Markdown

The ordering and explorer fixes look correct — b33caaac matches what we see in a working flow.

One more edge case worth documenting here, since it bites right at this exact point in the onboarding flow: even after wallet_addEthereumChain fully resolves, there is a brief window where eth_chainId reports the new chain but transactions still route through the old RPC endpoint. (Filed as #130.)

We hit this in the Arc Relay Bridge on a Base Sepolia → Arc Testnet CCTP bridge:

  1. wallet_addEthereumChain called for Arc Testnet, promise resolved ✅
  2. eth_chainId confirmed 0x4CEF52
  3. BrowserProvider.getSigner() acquired
  4. receiveMessage submitted — landed on Base Sepolia and reverted with "Invalid destination domain"

The message itself was correct (decoded: source=6, dest=26). Only the submission chain was wrong.

Root cause: MetaMask updates its eth_chainId state before its internal RPC transport finishes switching. Polling eth_chainId directly doesn't catch this gap.

Fix: Verify via provider.getNetwork() rather than bare eth_chainId before submitting. getNetwork() goes through the same transport layer as eth_sendTransaction, so if it returns the right chain, the tx will too:

// After wallet_addEthereumChain resolves, verify the provider transport has caught up
async function waitForProviderChain(expectedChainId: number, retries = 6): Promise<void> {
  for (let i = 0; i < retries; i++) {
    if (i > 0) await new Promise(r => setTimeout(r, 500 * i));
    const provider = new ethers.BrowserProvider(window.ethereum);
    const network  = await provider.getNetwork();
    if (Number(network.chainId) === expectedChainId) return;
  }
  throw new Error('Network did not stabilise — please switch manually and retry.');
}

// Usage
await window.ethereum.request({ method: 'wallet_addEthereumChain', params: [arcConfig] });
await waitForProviderChain(5042002);   // ← this is the missing step
await window.ethereum.request({ method: 'wallet_watchAsset', params: [usdcConfig] });
// now safe to send transactions

A note in the guide's "send a transaction" section (or even a callout in the network setup section) would save a lot of head-scratching. Happy to draft a short addition if helpful.

@zkasuran

Copy link
Copy Markdown
Author

Thanks @osr21, that race is nasty and exactly the kind of thing this guide should cover. Added in b879beb: a section on the eth_chainId vs routing gap linking #130, your waitForProviderChain approach (with a note that each attempt needs a fresh BrowserProvider since ethers caches the network per instance) and a pointer from the onboarding flow for flows that go straight into a transaction.

@osr21

osr21 commented Jun 11, 2026

Copy link
Copy Markdown

Good addition in b879beb — the note about fresh BrowserProvider instances is the key detail that makes the retry loop actually work.

To confirm: our implementation in the Arc Relay Bridge already follows this pattern. Our getProvider() always returns new ethers.BrowserProvider(window.ethereum) — never a cached singleton — so each iteration of getSignerOnChain constructs a fresh instance:

// getProvider() — called inside each retry attempt
export async function getProvider(): Promise<ethers.BrowserProvider> {
  if (!window.ethereum) throw new Error("No wallet detected.");
  return new ethers.BrowserProvider(window.ethereum); // always a fresh instance
}

async function getSignerOnChain(expectedChainId: number, maxRetries = 6) {
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    if (attempt > 0) await new Promise(r => setTimeout(r, 500 * attempt));
    const provider = await getProvider(); // ← fresh BrowserProvider every time
    const signer   = await provider.getSigner();
    const network  = await provider.getNetwork(); // reads live from RPC, not cache
    if (Number(network.chainId) === expectedChainId) return signer;
  }
  throw new Error("Wallet did not stabilise on expected network.");
}

Worth spelling out for anyone adapting this: in ethers v6, BrowserProvider caches the network result from the first getNetwork() call on that instance. If you reuse the same provider object across retries, every subsequent getNetwork() call returns the stale cached value — the loop will spin forever even after the RPC transport has switched. A fresh new BrowserProvider(window.ethereum) per attempt bypasses the cache and forces a live eth_chainId round-trip.

If the guide's code example makes that explicit (even just a comment like // must be a new instance each iteration), it will save the next person from a hard-to-debug infinite wait.

@osr21

osr21 commented Jun 13, 2026

Copy link
Copy Markdown

im working on building various dapps on Arc Testnet, will update on any patches or comprehensive guides

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

docs: wallet_watchAsset call for USDC undocumented — USDC does not appear in MetaMask token list without it

3 participants