Skip to content

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

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

docs: add MetaMask integration guide with USDC token setup#100
zkasuran wants to merge 8 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

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.
@zkasuran

Copy link
Copy Markdown
Author

Done in 6cf6c88.

The comment now sits on the line it applies to:

    // 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);

I also spelled out the failure in the note under the snippet: ethers v6 caches the network from the first getNetwork() call on an instance, so a hoisted provider burns every retry and throws even after the transport has switched. Your getProvider() shape gets a mention as the other way to guarantee it, since a helper that always returns a new instance is easier to keep right than a rule about one line in a loop.

@osr21

osr21 commented Jul 29, 2026

Copy link
Copy Markdown

The inline comment placement and caching explanation in 6cf6c88 look exactly right. Two additions that might be useful:


Same trap in viem

For anyone adapting this to viem or wagmi: createPublicClient has the same caching behaviour. client.chain.id is set at construction time and never updates, so a reused client returns the stale chain ID even after MetaMask has switched. The equivalent fix is to call await client.getChainId() (which always goes to RPC) rather than reading client.chain.id:

import { createPublicClient, custom } from 'viem'
import { arc } from './chains' // your Arc Testnet chain definition

// ❌ stale — reads the value baked in at construction
const staleChainId = client.chain.id;

// ✅ live — goes to RPC every time, equivalent to provider.getNetwork() in ethers
async function waitForViemChain(expectedChainId: number, retries = 6): Promise<void> {
  for (let i = 0; i < retries; i++) {
    if (i > 0) await new Promise(r => setTimeout(r, 500 * i));
    // Construct a fresh client each iteration for the same reason as BrowserProvider
    const client = createPublicClient({ transport: custom(window.ethereum) });
    const chainId = await client.getChainId(); // live RPC call
    if (chainId === expectedChainId) return;
  }
  throw new Error('Wallet did not stabilise on expected network.');
}

chainChanged as an alternative for event-driven flows

For flows that call wallet_addEthereumChain and then navigate or unmount rather than immediately sending a transaction, a listener can replace the polling loop entirely:

function onChainReady(expectedChainId: number): Promise<void> {
  return new Promise((resolve, reject) => {
    const timeout = setTimeout(() => {
      window.ethereum.removeListener('chainChanged', handler);
      reject(new Error('Chain switch timed out'));
    }, 30_000);

    function handler(chainIdHex: string) {
      if (parseInt(chainIdHex, 16) === expectedChainId) {
        clearTimeout(timeout);
        window.ethereum.removeListener('chainChanged', handler);
        resolve();
      }
    }

    window.ethereum.on('chainChanged', handler);
  });
}

// Usage: fire both in parallel; whichever settles the switch first wins
await Promise.race([
  window.ethereum.request({ method: 'wallet_addEthereumChain', params: [...] }),
  onChainReady(5042002),
]);
// transport is now stable — safe to build a fresh BrowserProvider here

The polling approach in the guide is cleaner for inline transaction flows. The event approach avoids repeated RPC calls for flows that naturally wait (page transitions, modal closes, etc.) — the two patterns complement each other depending on what immediately follows the chain switch.

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 circlefin#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 circlefin#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 circlefin#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.
@zkasuran

zkasuran commented Aug 2, 2026

Copy link
Copy Markdown
Author

Brought this up to date with main and folded in the viem half of the last review. Pushed in 0389a3c.

The conflict

One hunk, in README.md. #70 migrated the developer docs link from docs.arc.network to docs.arc.io. That link sits on the line directly below the bullet this PR adds, so the two edits landed in the same context block. Resolved by keeping both: the MetaMask bullet stays under Consensus, the docs link keeps upstream's docs.arc.io. Merged rather than rebased so the pushed history stays intact. The guide is indexed once in the README and nowhere else.

Nothing upstream duplicates the guide. ACCOUNTS.md does have a MetaMask section, but it covers the localdev node (http://localhost:8545, chain 1337) with manual UI steps, not Arc Testnet or the programmatic wallet_addEthereumChain flow. Worth noting it already tells people to enter USDC as the currency symbol, which is where the next part lands.

Re-verified values

The PR is two months old, so I checked every hard value again against Circle's Arc docs, this repo and the live network.

Value State Source
Chain ID 5042002 (0x4CEF52) unchanged crates/shared/src/chain_ids.rs, assets/testnet/genesis.json, hardhat.config.ts, scripts/hardhat/chains/testnet.ts, Connect to Arc, live eth_chainId returns 0x4cef52 on all four published endpoints
nativeCurrency USDC / USDC / 18 corrected, was ETH / ETH / 18 Connect to Arc lists USDC with 18 decimals, scripts/hardhat/chains/config.ts, crates/evm/src/log.rs (1_000_000_000_000_000_000u128, // 1 USDC (18 decimals)), viem's built in arcTestnet, the Arc Network Testnet entry in chainid.network/chains.json
USDC ERC-20 0x3600000000000000000000000000000000000000 unchanged contracts/scripts/Addresses.sol (FIAT_TOKEN_PROXY), scripts/genesis/addresses.ts, assets/testnet/genesis.json, contract addresses, live symbol() returns USDC
ERC-20 decimals 6 unchanged live decimals() returns 6, scripts/genesis/NativeFiatToken.ts writes 6 to the decimals slot, Circle's contract addresses page says the ERC-20 interface "Uses 6 decimals"
Native and ERC-20 are one balance at two precisions new to the guide live, same account and same block: eth_getBalance returned 74183684466781322809, balanceOf returned 74183684. Ratio 1e12 with truncation, matching Circle's note that both interfaces sit on one shared balance
RPC https://rpc.drpc.testnet.arc.io domain updated, was .arc.network Connect to Arc and RPC endpoints publish the arc.io hosts. All four answered eth_chainId with 0x4cef52. dRPC and Blockdaemon return Access-Control-Allow-Origin: *, the primary and QuickNode hosts reflect the request origin
Explorer https://testnet.arcscan.app unchanged Circle's reference page, viem's arcTestnet blockExplorers, the chain registry entry (EIP-3091), live 200 including a real /tx/<hash> path
explorer.arc.io gated, explorer.testnet.arc.network dead unchanged explorer.arc.io still 302s to Circle's Cloudflare Access sign in, explorer.testnet.arc.network still has no DNS record
wallet_addEthereumChain param shape unchanged, plus the real constraint EIP-3085 for the shape, MetaMask's own validator (app/scripts/lib/rpc-method-middleware/handlers/ethereum-chain-utils.js) for the rules it enforces
wallet_watchAsset param shape unchanged EIP-747: a single object parameter with type and options, not an array
4001 for a user rejection new to the guide EIP-1193 provider errors

Two things were stale beyond the conflict. The RPC hosts, per the table. And both MetaMask reference links in the guide now 404, because their docs moved the JSON-RPC methods under /wallet/reference/json-rpc-methods/. Fixed.

The nativeCurrency correction

This one contradicts #95 and my own earlier commit, so here is the evidence.

MetaMask's validator requires nativeCurrency.decimals to be exactly 18 ("Expected the number 18 for 'nativeCurrency.decimals' when 'nativeCurrency' is provided"). That part of #95 is right and decimals: 6 really is rejected. The symbol is the part that is free: the same function only requires a 1 to 6 character string, so USDC passes. It also lowercases chainId before validating, so 0x4CEF52 is fine as written.

18 decimals is not a workaround here, it is the correct scale. Arc's native balance carries 18 decimals and the ERC-20 view truncates it to 6, over one shared balance. On the live network that is 74183684466781322809 from eth_getBalance against 74183684 from balanceOf for the same account in the same block. So { symbol: "USDC", decimals: 18 } displays the true amount. It is decimals: 6 that would read 10^12 too high. Circle's own MetaMask instructions, viem's built in arcTestnet, this repo's scripts/hardhat/chains/config.ts and the public chain registry all carry USDC with 18 decimals.

One consequence needed a new section. With the network registered as USDC, MetaMask's native row already reads as the user's USDC balance, so the token registration adds a second row for the same funds at 6 decimals. That is still the view a DApp transacts against, since transfers, approvals and allowances go through the ERC-20 interface, but nobody should add the two rows together. The guide says so now and points at Circle's advice to read decimals() rather than assume.

On the last two suggestions

Took the viem trap. Rewritten rather than pasted, for two reasons. On a client built without a chain, client.chain is undefined, so client.chain.id throws instead of returning a stale value. The static value is the chain you passed in, which is where the trap actually lives. And a fresh client per attempt is not needed: getChainId() sends eth_chainId on every call. Its dedupe: true only shares requests that are in flight at the same moment, since withDedupe drops the cache entry as soon as the promise settles (viem 2.55.10). So the section keeps one client and re-reads through getChainId(). It also uses viem's built in arcTestnet instead of a hand written chain, because that ships with the right values. Left wagmi out, since I did not verify how useChainId behaves against its source and did not want to guess in a doc.

Left the chainChanged listener out. Two reasons. chainChanged fires on MetaMask's state flip, which is the same early signal #130 says arrives before the RPC router switches, so it does not close the gap that section exists for. And Promise.race([addEthereumChain, onChainReady]) settles as soon as the add call resolves, which is exactly the case the section warns about. It is a good pattern for reacting to a user switching networks on their own, which this guide does not cover.

Your inline comment placement from 6cf6c88 stayed. I only made the failure mode accurate: in ethers 6.17.0 a reused BrowserProvider compares its cached network against a fresh eth_chainId and throws network changed: <old> => <new> unless it was constructed with the "any" network, while a provider pinned to a network or built with staticNetwork: true stops re-reading and stays stale for good. Both break the loop, so a fresh instance per attempt is still the fix. I also dropped the claim that getNetwork() uses a different transport from a hand written poll. On a fresh instance it sends eth_chainId over the same EIP-1193 channel, so the guarantee comes from the backoff and the live re-read.

@osr21 if your bridge is running with symbol: "ETH" today, a check on the USDC / 18 config would be useful, since you have a real MetaMask in front of you and I am arguing from Circle's published values, the repo, the wallet's validator and the live balances rather than from a wallet UI.

Verification

  • tsc --noEmit with strict over all six TypeScript snippets from the guide, against ethers@6.17.0 and viem@2.55.10: 0 errors. That covers BrowserProvider.getNetwork(), createPublicClient, client.chain.id, client.getChainId() and the arcTestnet export.
  • All 15 links in the guide resolve. The RPC URL answers 405 to a GET by design, so it was checked with a JSON-RPC POST.
  • The repo's .pre-commit-config.yaml hooks that apply to Markdown all pass on the changed file (trailing whitespace, end of file, mixed line ending, byte order marker, merge conflict, case conflict) and none of them modified it. There is no Markdown lint, link check or docs build in ci.yml or the Makefile, so that is the whole set of gates a docs change has here. Prettier ignores **/*.md.
  • Current checks on 0389a3c: StepSecurity Required Checks passing, the four release jobs skipped. Public CI has not run on this PR.

Thanks @osr21 for the repeated reviews here. Every correction in the guide except the last one started as one of your reports.

@osr21

osr21 commented Aug 2, 2026

Copy link
Copy Markdown

Confirmed — and you were right to flag it.

nativeCurrency in the Arc Relay Bridge: symbol: "USDC", decimals: 18

Our chains.ts has:

ARC_TESTNET: {
  nativeCurrency: { name: 'USDC', symbol: 'USDC', decimals: 18 },
  ...
}

This is passed directly to wallet_addEthereumChain via chain.nativeCurrency. MetaMask accepts it — the chain registers without error. What we actually see in the wallet UI, consistent with what you documented: a native "USDC" row in MetaMask's asset list showing a badly scaled balance (because Arc's native balance from eth_getBalance is in 18-decimal terms while the human-readable USDC unit is 6 decimals, leaving a 10^12 factor), and then a second "USDC" ERC-20 row added by wallet_watchAsset at decimals: 6.

We didn't catch this through our DApp's own UI because the bridge queries the USDC balance directly via the ERC-20 interface and never reads MetaMask's native gas balance row. The broken display was entirely inside MetaMask, invisible to the app.

Fixing it — changing to { name: 'ETH', symbol: 'ETH', decimals: 18 } — matches the guide's recommendation and removes the duplicate row problem: MetaMask's native row shows "ETH" (cosmetically wrong but numerically consistent), and the wallet_watchAsset ERC-20 row is the only "USDC" entry.


On chainChanged — your reasoning is correct

chainChanged fires on MetaMask's state flip, which is the same early signal that arrives before the RPC router switches.

This is right, and Promise.race([addEthereumChain, onChainReady]) settling on addEthereumChain resolution is exactly the gap the section exists for. The polling approach is the correct fix for this flow; a chainChanged listener would just race to the same wrong moment. I misread the failure mode when I suggested it.


On the ethers v6 BrowserProvider error mode

The "network changed" throw you documented in 6.17.0 is accurate, and it's actually the better of the two failure modes (visible error vs silent stale read). Our bridge already handles this in waitWithRetry, which catches that error class and retries via a static JSON-RPC provider so a mid-flight chain switch doesn't surface as a false-negative bridge failure.

For getSignerOnChain specifically, the fresh-instance-per-attempt approach via getProvider() sidesteps the throw entirely: each new BrowserProvider reads eth_chainId from scratch, so the retry loop never hits a cached-then-invalidated state. The one nuance worth noting for anyone adapting the pattern: if the loop reuses a single instance instead, getNetwork() throws rather than returning stale data (in 6.17.0), so the caller needs an explicit catch around that call — otherwise the throw propagates as a user-facing error instead of triggering a retry. Fresh instances avoid both the stale-read and the throw path.

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

Labels

documentation Improvements or additions to documentation ecosystem Component: ecosystem

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

4 participants