From b0e6e9b15a5f21d2ae04a3f336de27dfeeb55e79 Mon Sep 17 00:00:00 2001 From: candyburst Date: Sun, 31 May 2026 19:29:41 +0530 Subject: [PATCH 1/7] docs: add MetaMask integration guide with USDC token setup 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 #97 Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 1 + docs/metamask-integration.md | 119 +++++++++++++++++++++++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 docs/metamask-integration.md diff --git a/README.md b/README.md index 98318c4..51f88da 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ Arc is an open EVM-compatible layer 1 built on [Malachite](https://github.com/ci - 🚀 **[Execution](crates/node/README.md)** - Execution binary and configuration - 🗳️ **[Consensus](crates/malachite-app/README.md)** - Consensus binary and configuration +- 🦊 **[MetaMask Integration](docs/metamask-integration.md)** - Connect MetaMask and register USDC token - More: see Arc [developer docs](https://docs.arc.network/arc/concepts/welcome-to-arc) for guides, APIs, and specs ## Install and Run a Node diff --git a/docs/metamask-integration.md b/docs/metamask-integration.md new file mode 100644 index 0000000..5eaebd2 --- /dev/null +++ b/docs/metamask-integration.md @@ -0,0 +1,119 @@ +# MetaMask Integration Guide + +Guide for integrating Arc Testnet with MetaMask, including USDC token setup. + +## Add Arc Testnet to MetaMask + +Use `wallet_addEthereumChain` to add Arc Testnet: + +```typescript +await window.ethereum.request({ + method: "wallet_addEthereumChain", + params: [{ + chainId: "0x4CF252", // 5042002 in hex + chainName: "Arc Testnet", + nativeCurrency: { + name: "USDC", + symbol: "USDC", + decimals: 6, + }, + rpcUrls: ["https://rpc.arc.network"], + blockExplorerUrls: ["https://testnet.arcscan.app"], + }], +}); +``` + +## Register USDC Token + +**Important:** MetaMask does not automatically show USDC in the token list for custom chains. After adding the network, you must register USDC using `wallet_watchAsset`: + +```typescript +await window.ethereum.request({ + method: "wallet_watchAsset", + params: { + type: "ERC20", + options: { + address: "0x3600000000000000000000000000000000000000", + symbol: "USDC", + decimals: 6, + image: "https://cryptologos.cc/logos/usd-coin-usdc-logo.png", + }, + }, +}); +``` + +MetaMask will show a confirmation dialog. Once accepted, USDC will appear in the user's token list with the correct balance. + +## Complete Onboarding Flow + +Recommended sequence for DApp wallet connection: + +```typescript +async function connectWallet() { + try { + // 1. Request account access + const accounts = await window.ethereum.request({ + method: "eth_requestAccounts", + }); + + // 2. Add Arc Testnet network + await window.ethereum.request({ + method: "wallet_addEthereumChain", + params: [{ + chainId: "0x4CF252", + chainName: "Arc Testnet", + nativeCurrency: { + name: "USDC", + symbol: "USDC", + decimals: 6, + }, + rpcUrls: ["https://rpc.arc.network"], + blockExplorerUrls: ["https://testnet.arcscan.app"], + }], + }); + + // 3. Register USDC token + await window.ethereum.request({ + method: "wallet_watchAsset", + params: { + type: "ERC20", + options: { + address: "0x3600000000000000000000000000000000000000", + symbol: "USDC", + decimals: 6, + image: "https://cryptologos.cc/logos/usd-coin-usdc-logo.png", + }, + }, + }); + + console.log("Wallet connected:", accounts[0]); + return accounts[0]; + } catch (error) { + console.error("Wallet connection failed:", error); + throw error; + } +} +``` + +## Why This Matters + +Without the `wallet_watchAsset` call: +- Users see no USDC balance in MetaMask after receiving tokens +- Users assume transactions failed +- DApps appear broken + +Every DApp on Arc Testnet that involves USDC transfers should include this step in their onboarding flow. + +## Contract Addresses + +**Arc Testnet:** +- USDC: `0x3600000000000000000000000000000000000000` +- Chain ID: `5042002` (hex: `0x4CF252`) +- RPC: `https://rpc.arc.network` +- Explorer: `https://testnet.arcscan.app` + +## References + +- [MetaMask wallet_watchAsset documentation](https://docs.metamask.io/wallet/reference/wallet_watchasset/) +- [MetaMask wallet_addEthereumChain documentation](https://docs.metamask.io/wallet/reference/wallet_addethereumchain/) +- [Arc Network documentation](https://docs.arc.network/) From 832571e315028c274d7ce51c39d1bcd0589840a4 Mon Sep 17 00:00:00 2001 From: zkasuran Date: Tue, 9 Jun 2026 02:22:23 +0530 Subject: [PATCH 2/7] docs: fix Arc Testnet chain config and document switch-chain workaround 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 #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 (#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 #89). --- docs/metamask-integration.md | 67 ++++++++++++++++++++++++++++-------- 1 file changed, 53 insertions(+), 14 deletions(-) diff --git a/docs/metamask-integration.md b/docs/metamask-integration.md index 5eaebd2..b67a3e2 100644 --- a/docs/metamask-integration.md +++ b/docs/metamask-integration.md @@ -10,14 +10,51 @@ Use `wallet_addEthereumChain` to add Arc Testnet: await window.ethereum.request({ method: "wallet_addEthereumChain", params: [{ - chainId: "0x4CF252", // 5042002 in hex + chainId: "0x4CEF52", // 5042002 in hex chainName: "Arc Testnet", nativeCurrency: { - name: "USDC", - symbol: "USDC", - decimals: 6, + name: "ETH", + symbol: "ETH", + decimals: 18, }, - rpcUrls: ["https://rpc.arc.network"], + rpcUrls: ["https://rpc.drpc.testnet.arc.network"], + blockExplorerUrls: ["https://testnet.arcscan.app"], + }], +}); +``` + +### Why the native currency is "ETH" and not "USDC" + +Arc pays gas in USDC, but the `nativeCurrency` here still has to be `{ name: "ETH", symbol: "ETH", decimals: 18 }`. MetaMask only supports 18-decimal native currencies and validates that `decimals` equals 18, so a config with `decimals: 6` is rejected. If you pass `symbol: "USDC"` with `decimals: 18` the network is accepted but MetaMask shows the gas balance 10^12 times too high. + +The practical consequences: + +- MetaMask labels gas costs in "ETH" (for example "0.000000021 ETH") rather than USDC. Show the real USDC gas estimate in your own DApp UI if that matters to your users. +- This only affects the native gas display. The USDC ERC-20 token you register below keeps `decimals: 6` and shows the correct balance. + +See issue [#95](https://github.com/circlefin/arc-node/issues/95) for the full background. + +## Switching to Arc Testnet + +Do not use `wallet_switchEthereumChain` to move the user to Arc Testnet. On Arc Testnet it fails silently or throws a `4902` (chain not found) even after the network has already been added (issue [#89](https://github.com/circlefin/arc-node/issues/89)). + +Call `wallet_addEthereumChain` instead. It both adds the network if it is missing and switches to it if it is already present, so it is reliable in both cases: + +```typescript +// Unreliable on Arc Testnet, may resolve without switching or throw 4902 +// await window.ethereum.request({ +// method: "wallet_switchEthereumChain", +// params: [{ chainId: "0x4CEF52" }], +// }); + +// Reliable, works whether the network is already added or not +await window.ethereum.request({ + method: "wallet_addEthereumChain", + params: [{ + chainId: "0x4CEF52", + chainName: "Arc Testnet", + nativeCurrency: { name: "ETH", symbol: "ETH", decimals: 18 }, + rpcUrls: ["https://rpc.drpc.testnet.arc.network"], blockExplorerUrls: ["https://testnet.arcscan.app"], }], }); @@ -42,7 +79,7 @@ await window.ethereum.request({ }); ``` -MetaMask will show a confirmation dialog. Once accepted, USDC will appear in the user's token list with the correct balance. +MetaMask will show a confirmation dialog. Once accepted, USDC will appear in the user's token list with the correct balance. Note that USDC uses `decimals: 6` here, which is correct for the ERC-20 token and separate from the 18-decimal native currency above. ## Complete Onboarding Flow @@ -56,18 +93,18 @@ async function connectWallet() { method: "eth_requestAccounts", }); - // 2. Add Arc Testnet network + // 2. Add Arc Testnet network (also switches to it if already added) await window.ethereum.request({ method: "wallet_addEthereumChain", params: [{ - chainId: "0x4CF252", + chainId: "0x4CEF52", chainName: "Arc Testnet", nativeCurrency: { - name: "USDC", - symbol: "USDC", - decimals: 6, + name: "ETH", + symbol: "ETH", + decimals: 18, }, - rpcUrls: ["https://rpc.arc.network"], + rpcUrls: ["https://rpc.drpc.testnet.arc.network"], blockExplorerUrls: ["https://testnet.arcscan.app"], }], }); @@ -108,10 +145,12 @@ Every DApp on Arc Testnet that involves USDC transfers should include this step **Arc Testnet:** - USDC: `0x3600000000000000000000000000000000000000` -- Chain ID: `5042002` (hex: `0x4CF252`) -- RPC: `https://rpc.arc.network` +- Chain ID: `5042002` (hex: `0x4CEF52`) +- RPC: `https://rpc.drpc.testnet.arc.network` - Explorer: `https://testnet.arcscan.app` +The public RPC `https://rpc.testnet.arc.network` works too, but `rpc.drpc.testnet.arc.network` returns `Access-Control-Allow-Origin: *`, which is the safest choice for browser DApps calling the endpoint directly (see issue [#90](https://github.com/circlefin/arc-node/issues/90)). + ## References - [MetaMask wallet_watchAsset documentation](https://docs.metamask.io/wallet/reference/wallet_watchasset/) From a83f48dbc5bbd4f8986fc04eddcf3c3054499f1d Mon Sep 17 00:00:00 2001 From: Asuran Date: Wed, 10 Jun 2026 06:04:22 +0530 Subject: [PATCH 3/7] docs: note watchAsset ordering and dead explorer URLs (review) --- docs/metamask-integration.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/metamask-integration.md b/docs/metamask-integration.md index b67a3e2..ef08de4 100644 --- a/docs/metamask-integration.md +++ b/docs/metamask-integration.md @@ -81,6 +81,8 @@ await window.ethereum.request({ MetaMask will show a confirmation dialog. Once accepted, USDC will appear in the user's token list with the correct balance. Note that USDC uses `decimals: 6` here, which is correct for the ERC-20 token and separate from the 18-decimal native currency above. +**Order matters:** call `wallet_watchAsset` only after the `wallet_addEthereumChain` promise has resolved. If it fires while the user is still on a different network, MetaMask registers USDC against the wrong chain and the balance never shows up. The onboarding flow below awaits the chain add before registering the token for this reason. + ## Complete Onboarding Flow Recommended sequence for DApp wallet connection: @@ -151,6 +153,8 @@ Every DApp on Arc Testnet that involves USDC transfers should include this step The public RPC `https://rpc.testnet.arc.network` works too, but `rpc.drpc.testnet.arc.network` returns `Access-Control-Allow-Origin: *`, which is the safest choice for browser DApps calling the endpoint directly (see issue [#90](https://github.com/circlefin/arc-node/issues/90)). +`https://testnet.arcscan.app` is the only block explorer that currently resolves. The older `explorer.testnet.arc.network` and `explorer.arc.io` hosts are dead, so use `testnet.arcscan.app` in the `blockExplorerUrls` field and in any transaction-link examples. + ## References - [MetaMask wallet_watchAsset documentation](https://docs.metamask.io/wallet/reference/wallet_watchasset/) From b33caaac8b9e30046f87cf1c738bc3aed1e2816a Mon Sep 17 00:00:00 2001 From: Asuran Date: Wed, 10 Jun 2026 06:05:36 +0530 Subject: [PATCH 4/7] docs: correct explorer.arc.io status (gated, not dead) --- docs/metamask-integration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/metamask-integration.md b/docs/metamask-integration.md index ef08de4..c47f4e5 100644 --- a/docs/metamask-integration.md +++ b/docs/metamask-integration.md @@ -153,7 +153,7 @@ Every DApp on Arc Testnet that involves USDC transfers should include this step The public RPC `https://rpc.testnet.arc.network` works too, but `rpc.drpc.testnet.arc.network` returns `Access-Control-Allow-Origin: *`, which is the safest choice for browser DApps calling the endpoint directly (see issue [#90](https://github.com/circlefin/arc-node/issues/90)). -`https://testnet.arcscan.app` is the only block explorer that currently resolves. The older `explorer.testnet.arc.network` and `explorer.arc.io` hosts are dead, so use `testnet.arcscan.app` in the `blockExplorerUrls` field and in any transaction-link examples. +`https://testnet.arcscan.app` is the only public block explorer that currently works, so use it in the `blockExplorerUrls` field and in any transaction-link examples. `explorer.testnet.arc.network` no longer resolves, and `explorer.arc.io` resolves but sits behind Circle's internal Cloudflare Access login rather than serving a public explorer. ## References From b879bebc5fabb81298f36d83275ae974d7d41ed0 Mon Sep 17 00:00:00 2001 From: zkasuran Date: Thu, 11 Jun 2026 04:57:04 +0530 Subject: [PATCH 5/7] docs: wait for RPC transport after chain switch before sending tx (#130) --- docs/metamask-integration.md | 40 +++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/docs/metamask-integration.md b/docs/metamask-integration.md index c47f4e5..41ca11f 100644 --- a/docs/metamask-integration.md +++ b/docs/metamask-integration.md @@ -60,6 +60,42 @@ await window.ethereum.request({ }); ``` +## Wait for the RPC Transport Before Sending Transactions + +`wallet_addEthereumChain` resolving does not mean transactions route to Arc Testnet yet. For a short window (roughly 200 to 500 ms) MetaMask already reports the new chain from `eth_chainId` while its internal JSON-RPC router still points at the previous endpoint. A transaction sent in that window lands on the old chain (issue [#130](https://github.com/circlefin/arc-node/issues/130)). The reported case was a CCTP `receiveMessage` meant for Arc Testnet that landed on Base Sepolia instead and reverted with `"Invalid destination domain"`. + +Polling `eth_chainId` does not close the gap because it flips before the routing does. Verify through `provider.getNetwork()` instead. It goes through the same transport as `eth_sendTransaction`, so once it returns the Arc Testnet chain ID, transactions route there too: + +```typescript +import { ethers } from "ethers"; + +// After wallet_addEthereumChain resolves, wait for the provider +// transport to catch up before sending any transaction. +async function waitForProviderChain( + expectedChainId: number, + retries = 6, +): Promise { + 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 stabilize. Switch manually and retry."); +} + +await window.ethereum.request({ + method: "wallet_addEthereumChain", + params: [arcTestnetConfig], +}); +await waitForProviderChain(5042002); +// Now safe to send transactions +``` + +Construct a fresh `BrowserProvider` on each attempt. ethers caches the network per provider instance, so reusing one instance can keep returning the stale chain. + +The wait is only needed when a transaction follows the network switch in the same flow. Reading balances or registering tokens is not affected. + ## Register USDC Token **Important:** MetaMask does not automatically show USDC in the token list for custom chains. After adding the network, you must register USDC using `wallet_watchAsset`: @@ -134,6 +170,8 @@ async function connectWallet() { } ``` +If the flow continues straight into a transaction, run `waitForProviderChain` from the section above between steps 2 and 3. + ## Why This Matters Without the `wallet_watchAsset` call: @@ -153,7 +191,7 @@ Every DApp on Arc Testnet that involves USDC transfers should include this step The public RPC `https://rpc.testnet.arc.network` works too, but `rpc.drpc.testnet.arc.network` returns `Access-Control-Allow-Origin: *`, which is the safest choice for browser DApps calling the endpoint directly (see issue [#90](https://github.com/circlefin/arc-node/issues/90)). -`https://testnet.arcscan.app` is the only public block explorer that currently works, so use it in the `blockExplorerUrls` field and in any transaction-link examples. `explorer.testnet.arc.network` no longer resolves, and `explorer.arc.io` resolves but sits behind Circle's internal Cloudflare Access login rather than serving a public explorer. +`https://testnet.arcscan.app` is the only public block explorer that currently works, so use it in the `blockExplorerUrls` field and in any transaction-link examples. `explorer.testnet.arc.network` no longer resolves. `explorer.arc.io` resolves but sits behind Circle's internal Cloudflare Access login rather than serving a public explorer. ## References From 6cf6c883e4ceb68c2d6a89ef4d721033f1eb3299 Mon Sep 17 00:00:00 2001 From: zkasuran <289388318+zkasuran@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:21:04 +0530 Subject: [PATCH 6/7] docs: make the fresh-BrowserProvider requirement explicit in the loop The prose said to construct a fresh provider per attempt, the code example did not. A reader adapting the loop can hoist the provider out and then wait through every retry for a cached network value that never changes. The comment now sits on the line that matters and the note explains the failure. --- docs/metamask-integration.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/metamask-integration.md b/docs/metamask-integration.md index 41ca11f..0fa0427 100644 --- a/docs/metamask-integration.md +++ b/docs/metamask-integration.md @@ -77,6 +77,8 @@ async function waitForProviderChain( ): Promise { for (let i = 0; i < retries; i++) { if (i > 0) await new Promise((r) => setTimeout(r, 500 * i)); + // Must be a new instance each iteration: ethers caches the network per + // BrowserProvider, so a reused one keeps returning the stale chain. const provider = new ethers.BrowserProvider(window.ethereum); const network = await provider.getNetwork(); if (Number(network.chainId) === expectedChainId) return; @@ -92,7 +94,7 @@ await waitForProviderChain(5042002); // Now safe to send transactions ``` -Construct a fresh `BrowserProvider` on each attempt. ethers caches the network per provider instance, so reusing one instance can keep returning the stale chain. +Construct a fresh `BrowserProvider` on each attempt, as the comment in the loop says. ethers v6 caches the network from the first `getNetwork()` call on an instance, so a reused provider keeps returning the stale chain and the loop burns every retry and throws even after the transport has switched. A `getProvider()` helper that always returns `new ethers.BrowserProvider(window.ethereum)`, never a cached singleton, gives the same guarantee. The wait is only needed when a transaction follows the network switch in the same flow. Reading balances or registering tokens is not affected. From 0389a3c104ac7ca930b849f31edbca5d3b58d66d Mon Sep 17 00:00:00 2001 From: zkasuran <289388318+zkasuran@users.noreply.github.com> Date: Sun, 2 Aug 2026 13:37:43 +0530 Subject: [PATCH 7/7] docs: correct the Arc Testnet values and cover the viem provider trap Re-verified every hard value in the guide against Circle's Arc docs, this repo's own config and the live testnet, then folded in the viem half of the review on #100. - nativeCurrency is USDC with 18 decimals, not ETH. The native balance carries 18 decimals (1 USDC = 1e18 base units) while the ERC-20 interface at 0x3600000000000000000000000000000000000000 reports 6 and truncates, both over one balance. MetaMask only requires decimals to equal 18 and accepts any 1 to 6 character symbol, so the ETH workaround in #95 is not needed. - RPC hosts point at the arc.io domain Circle documents now. All four published testnet endpoints answer eth_chainId with 0x4cef52. - Fixed the two MetaMask reference links, which 404 since their docs moved under json-rpc-methods, and pointed the Arc docs link at docs.arc.io to match the domain migration in #70. - Corrected the ethers caching note. A reused BrowserProvider throws "network changed" unless it was built with the "any" network, and one pinned to a network or built with staticNetwork stops re-reading at all. - Added the viem equivalent, where the trap is client.chain.id being fixed at construction while getChainId() reads live. - The onboarding flow now checks for window.ethereum and treats the 4001 user rejection as an outcome rather than a failure. --- docs/metamask-integration.md | 114 +++++++++++++++++++++++++---------- 1 file changed, 81 insertions(+), 33 deletions(-) diff --git a/docs/metamask-integration.md b/docs/metamask-integration.md index 0fa0427..39fb68e 100644 --- a/docs/metamask-integration.md +++ b/docs/metamask-integration.md @@ -13,26 +13,30 @@ await window.ethereum.request({ chainId: "0x4CEF52", // 5042002 in hex chainName: "Arc Testnet", nativeCurrency: { - name: "ETH", - symbol: "ETH", + name: "USDC", + symbol: "USDC", decimals: 18, }, - rpcUrls: ["https://rpc.drpc.testnet.arc.network"], + rpcUrls: ["https://rpc.drpc.testnet.arc.io"], blockExplorerUrls: ["https://testnet.arcscan.app"], }], }); ``` -### Why the native currency is "ETH" and not "USDC" +### Why the native currency is USDC with 18 decimals + +Arc pays gas in USDC and the native balance carries 18 decimals, so `{ name: "USDC", symbol: "USDC", decimals: 18 }` is the config to send. Circle's [Connect to Arc](https://docs.arc.io/arc/references/connect-to-arc) reference publishes those values. So do viem's built in `arcTestnet` chain and this repo's own [chain config](../scripts/hardhat/chains/config.ts). -Arc pays gas in USDC, but the `nativeCurrency` here still has to be `{ name: "ETH", symbol: "ETH", decimals: 18 }`. MetaMask only supports 18-decimal native currencies and validates that `decimals` equals 18, so a config with `decimals: 6` is rejected. If you pass `symbol: "USDC"` with `decimals: 18` the network is accepted but MetaMask shows the gas balance 10^12 times too high. +MetaMask requires `nativeCurrency.decimals` to be exactly 18 and rejects anything else with `invalidParams` ("Expected the number 18 for 'nativeCurrency.decimals'"). It only requires the symbol to be a 1 to 6 character string, so `USDC` is accepted. -The practical consequences: +The part that trips people up is that one balance is exposed at two precisions: -- MetaMask labels gas costs in "ETH" (for example "0.000000021 ETH") rather than USDC. Show the real USDC gas estimate in your own DApp UI if that matters to your users. -- This only affects the native gas display. The USDC ERC-20 token you register below keeps `decimals: 6` and shows the correct balance. +- The native balance, the one that pays gas, has 18 decimals. `eth_getBalance` returned `74183684466781322809` for an account holding 74.183684466781322809 USDC. +- The ERC-20 interface at `0x3600000000000000000000000000000000000000` reports `decimals() = 6` and truncates. `balanceOf` returned `74183684` for that same account in the same block. -See issue [#95](https://github.com/circlefin/arc-node/issues/95) for the full background. +That is why `decimals: 6` is wrong here as well as rejected: MetaMask would render the gas balance 10^12 times too high. Register the ERC-20 with `decimals: 6` as shown below and keep the two views apart when you format amounts yourself. + +Issue [#95](https://github.com/circlefin/arc-node/issues/95) asks for this to be documented and suggests passing `symbol: "ETH"` to satisfy MetaMask. The decimals requirement in that issue is real. The symbol part is not: Circle's own MetaMask instructions use `USDC`. ## Switching to Arc Testnet @@ -53,8 +57,8 @@ await window.ethereum.request({ params: [{ chainId: "0x4CEF52", chainName: "Arc Testnet", - nativeCurrency: { name: "ETH", symbol: "ETH", decimals: 18 }, - rpcUrls: ["https://rpc.drpc.testnet.arc.network"], + nativeCurrency: { name: "USDC", symbol: "USDC", decimals: 18 }, + rpcUrls: ["https://rpc.drpc.testnet.arc.io"], blockExplorerUrls: ["https://testnet.arcscan.app"], }], }); @@ -64,7 +68,7 @@ await window.ethereum.request({ `wallet_addEthereumChain` resolving does not mean transactions route to Arc Testnet yet. For a short window (roughly 200 to 500 ms) MetaMask already reports the new chain from `eth_chainId` while its internal JSON-RPC router still points at the previous endpoint. A transaction sent in that window lands on the old chain (issue [#130](https://github.com/circlefin/arc-node/issues/130)). The reported case was a CCTP `receiveMessage` meant for Arc Testnet that landed on Base Sepolia instead and reverted with `"Invalid destination domain"`. -Polling `eth_chainId` does not close the gap because it flips before the routing does. Verify through `provider.getNetwork()` instead. It goes through the same transport as `eth_sendTransaction`, so once it returns the Arc Testnet chain ID, transactions route there too: +Polling `eth_chainId` on its own does not close the gap, since it flips before the routing does. Retry with a backoff, re-read the chain live on every attempt, then refuse to send if the wallet never settles: ```typescript import { ethers } from "ethers"; @@ -78,7 +82,7 @@ async function waitForProviderChain( for (let i = 0; i < retries; i++) { if (i > 0) await new Promise((r) => setTimeout(r, 500 * i)); // Must be a new instance each iteration: ethers caches the network per - // BrowserProvider, so a reused one keeps returning the stale chain. + // BrowserProvider, so a reused one goes stale or throws "network changed". const provider = new ethers.BrowserProvider(window.ethereum); const network = await provider.getNetwork(); if (Number(network.chainId) === expectedChainId) return; @@ -94,13 +98,42 @@ await waitForProviderChain(5042002); // Now safe to send transactions ``` -Construct a fresh `BrowserProvider` on each attempt, as the comment in the loop says. ethers v6 caches the network from the first `getNetwork()` call on an instance, so a reused provider keeps returning the stale chain and the loop burns every retry and throws even after the transport has switched. A `getProvider()` helper that always returns `new ethers.BrowserProvider(window.ethereum)`, never a cached singleton, gives the same guarantee. +Construct a fresh `BrowserProvider` on each attempt, as the comment in the loop says. ethers caches the network from the first `getNetwork()` call on an instance. Later calls compare that cached value against a fresh `eth_chainId`. A provider that was not created with the `"any"` network then throws `network changed: => ` rather than reporting the new chain (`AbstractProvider.getNetwork`, ethers 6.17.0). A provider pinned to a network or created with `staticNetwork: true` is worse: it stops re-reading altogether and returns the stale chain for as long as it lives. A `getProvider()` helper that always returns `new ethers.BrowserProvider(window.ethereum)`, never a cached singleton, keeps this right everywhere. + +`getNetwork()` on a fresh instance sends `eth_chainId` over the same EIP-1193 channel a hand written poll would use, so the guarantee comes from the backoff and the fresh read rather than from the method you call. Keep the throw at the end. A flow that gives up loudly is better than one that puts a transaction on the wrong chain. The wait is only needed when a transaction follows the network switch in the same flow. Reading balances or registering tokens is not affected. +### The same trap in viem + +viem has the same failure in a different place. `client.chain` is the chain object passed at construction, so `client.chain.id` is a static config value that never follows the wallet. Read the chain with `getChainId()`, which sends `eth_chainId` on every call: + +```typescript +import { createPublicClient, custom } from "viem"; +import { arcTestnet } from "viem/chains"; + +const client = createPublicClient({ + chain: arcTestnet, + transport: custom(window.ethereum), +}); + +const configured = client.chain.id; // 5042002 from the chain object, never re-read +const live = await client.getChainId(); // eth_chainId through the wallet + +async function waitForWalletChain(expectedChainId: number, retries = 6): Promise { + for (let i = 0; i < retries; i++) { + if (i > 0) await new Promise((r) => setTimeout(r, 500 * i)); + if ((await client.getChainId()) === expectedChainId) return; + } + throw new Error("Network did not stabilize. Switch manually and retry."); +} +``` + +Unlike ethers, the client can be reused across attempts. `getChainId()` dedupes only the requests that are in flight at the same moment, so each awaited call reaches the wallet (`withDedupe` clears its cache entry as soon as the promise settles). Arc Testnet ships in viem as `arcTestnet`, so there is no chain definition to hand write. + ## Register USDC Token -**Important:** MetaMask does not automatically show USDC in the token list for custom chains. After adding the network, you must register USDC using `wallet_watchAsset`: +**Important:** MetaMask's automatic token detection does not cover custom networks, so USDC does not appear in the token list on its own (issue [#97](https://github.com/circlefin/arc-node/issues/97)). After adding the network, register it with `wallet_watchAsset`: ```typescript await window.ethereum.request({ @@ -127,6 +160,10 @@ Recommended sequence for DApp wallet connection: ```typescript async function connectWallet() { + if (!window.ethereum) { + throw new Error("No wallet detected. Install MetaMask to continue."); + } + try { // 1. Request account access const accounts = await window.ethereum.request({ @@ -140,11 +177,11 @@ async function connectWallet() { chainId: "0x4CEF52", chainName: "Arc Testnet", nativeCurrency: { - name: "ETH", - symbol: "ETH", + name: "USDC", + symbol: "USDC", decimals: 18, }, - rpcUrls: ["https://rpc.drpc.testnet.arc.network"], + rpcUrls: ["https://rpc.drpc.testnet.arc.io"], blockExplorerUrls: ["https://testnet.arcscan.app"], }], }); @@ -163,10 +200,14 @@ async function connectWallet() { }, }); - console.log("Wallet connected:", accounts[0]); return accounts[0]; } catch (error) { - console.error("Wallet connection failed:", error); + // 4001 is the EIP-1193 code for the user declining a prompt. All three + // calls above can raise it, so treat it as an outcome to handle and not + // an error to report. + if ((error as { code?: number }).code === 4001) { + return null; + } throw error; } } @@ -174,29 +215,36 @@ async function connectWallet() { If the flow continues straight into a transaction, run `waitForProviderChain` from the section above between steps 2 and 3. -## Why This Matters +`window.ethereum` is missing when no wallet is installed, so check it before the first call. Returning `null` on `4001` lets the caller re-prompt instead of surfacing a stack trace to someone who pressed cancel. If several wallet extensions are installed they compete for `window.ethereum`. ethers exposes `BrowserProvider.discover()` for the EIP-6963 announcement flow when you need to pick a specific one. -Without the `wallet_watchAsset` call: -- Users see no USDC balance in MetaMask after receiving tokens -- Users assume transactions failed -- DApps appear broken +## One Balance, Two Views -Every DApp on Arc Testnet that involves USDC transfers should include this step in their onboarding flow. +The native balance and the ERC-20 at `0x3600000000000000000000000000000000000000` are the same funds, exposed twice. Once the network is registered with `symbol: "USDC"`, MetaMask reads the native row as the user's USDC balance at 18 decimals. Registering the token adds a second row for that same balance at 6 decimals, which is the view DApps actually transact against, since transfers, approvals and allowances all go through the ERC-20 interface. + +Two things follow. The two rows are one balance, so never add them together. Read `decimals()` rather than assuming which view an amount came from, which is the advice Circle gives on its [contract addresses](https://docs.arc.io/arc/references/contract-addresses) page. + +Without the `wallet_watchAsset` call the token row never appears. A user who receives USDC sees nothing in their token list and assumes the transfer failed. Every DApp on Arc Testnet that moves USDC should include the call in its onboarding flow. ## Contract Addresses **Arc Testnet:** -- USDC: `0x3600000000000000000000000000000000000000` +- USDC ERC-20 interface: `0x3600000000000000000000000000000000000000`, `decimals() = 6` - Chain ID: `5042002` (hex: `0x4CEF52`) -- RPC: `https://rpc.drpc.testnet.arc.network` +- Native currency: USDC, 18 decimals +- RPC: `https://rpc.drpc.testnet.arc.io` - Explorer: `https://testnet.arcscan.app` -The public RPC `https://rpc.testnet.arc.network` works too, but `rpc.drpc.testnet.arc.network` returns `Access-Control-Allow-Origin: *`, which is the safest choice for browser DApps calling the endpoint directly (see issue [#90](https://github.com/circlefin/arc-node/issues/90)). +Circle publishes four testnet RPC hosts, `rpc.testnet.arc.io` plus dRPC, QuickNode and Blockdaemon variants of it ([RPC endpoints](https://docs.arc.io/arc/references/rpc-endpoints)). All four answered `eth_chainId` with `0x4cef52`. `rpc.drpc.testnet.arc.io` and `rpc.blockdaemon.testnet.arc.io` return `Access-Control-Allow-Origin: *`, while `rpc.testnet.arc.io` and the QuickNode host reflect the request origin instead, so dRPC is the simplest default for a page that calls the RPC directly from the browser (see issue [#90](https://github.com/circlefin/arc-node/issues/90)). This only matters for requests your own code makes. MetaMask sends the calls behind `rpcUrls` from the extension, where page CORS does not apply. + +The older `*.testnet.arc.network` hosts still answer, but arc.io is the domain Circle documents now. -`https://testnet.arcscan.app` is the only public block explorer that currently works, so use it in the `blockExplorerUrls` field and in any transaction-link examples. `explorer.testnet.arc.network` no longer resolves. `explorer.arc.io` resolves but sits behind Circle's internal Cloudflare Access login rather than serving a public explorer. +`https://testnet.arcscan.app` is the public block explorer, so use it in the `blockExplorerUrls` field and in any transaction-link examples. `explorer.testnet.arc.network` has no DNS record. `explorer.arc.io` redirects to Circle's Cloudflare Access sign in rather than serving a public explorer. Endpoints and explorer hosts last checked 2026-08-02. ## References -- [MetaMask wallet_watchAsset documentation](https://docs.metamask.io/wallet/reference/wallet_watchasset/) -- [MetaMask wallet_addEthereumChain documentation](https://docs.metamask.io/wallet/reference/wallet_addethereumchain/) -- [Arc Network documentation](https://docs.arc.network/) +- [MetaMask wallet_addEthereumChain](https://docs.metamask.io/wallet/reference/json-rpc-methods/wallet_addethereumchain/) +- [MetaMask wallet_watchAsset](https://docs.metamask.io/wallet/reference/json-rpc-methods/wallet_watchasset/) +- [EIP-1193 provider errors](https://eips.ethereum.org/EIPS/eip-1193#provider-errors), where `4001` is defined +- [Connect to Arc](https://docs.arc.io/arc/references/connect-to-arc), the published Arc Testnet parameters +- [Arc contract addresses](https://docs.arc.io/arc/references/contract-addresses) +- [Arc documentation](https://docs.arc.io/)