Mirror / fallback: https://gist.github.com/riyannode/fe20691e0a0bdfbe9144b24538113757

Arc Layer Privy AutoSwitchArcChain — Ground Truth

Prompt

I'm building a Next.js 14 App Router dApp for Arc Testnet (chainId 5042002) using @privy-io/react-auth + @privy-io/wagmi + wagmi v2. Users log in via Privy with EXTERNAL wallets (MetaMask, Rabby, OKX) — NOT embedded wallets.

Problem: after the user picks MetaMask in the Privy model, Privy handshakes the wallet and then shows a 'Sign to verify' SIWE prompt. MetaMask signs on whatever chain it was already on (usually Ethereum mainnet 0x1), not Arc Testnet. Setting defaultChain: arcTestnet and supportedChains: [arcTestnet] in PrivyProvider config DOES NOT help — those only affect embedded wallets.

Write a headless React component AutoSwitchArcChain.tsx that:

  1. Returns null (no UI)
  2. Mounts inside <WagmiProvider>
  3. Auto-calls switchChain(5042002) the MOMENT an external EVM wallet handshakes, BEFORE the Privy SIWE 'Sign to verify' modal appears, so the user signs on Arc Testnet
  4. Does NOT fire repeatedly on the same wallet (avoid infinite re-render loops)
  5. Gracefully ignores non-Ethereum wallet types (e.g. Solana if included in loginMethods)

Constraints:

  • Must correctly handle the pre-SIWE window. authenticated is still FALSE when the wallet handshakes — the sign happens BEFORE authenticated flips true
  • Must use Privy's own useWallets() hook, not wagmi's useSwitchChain
  • Must use a ref (not state) to track in-flight switches

Model

claude-opus-4.7 (also tested: sonnet-4.7, gpt-5.5, gemini-2.5, grok-4 — same wrong pattern)

Ground Truth

'use client';

import { useEffect, useRef } from 'react';
import { usePrivy, useWallets } from '@privy-io/react-auth';

const ARC_TESTNET_ID = 5042002;
const ARC_TESTNET_CAIP = `eip155:${ARC_TESTNET_ID}`;

export default function AutoSwitchArcChain() {
  const { ready: privyReady } = usePrivy();
  const { ready: walletsReady, wallets } = useWallets();

  // Ref (not useState) because setState would re-render this component,
  // which re-runs the effect, which would call switchChain AGAIN on the
  // same wallet while the first MetaMask popup is still open -> infinite loop.
  const switchingWallets = useRef(new Set<string>());

  useEffect(() => {
    if (!privyReady || !walletsReady || wallets.length === 0) return;

    // CRITICAL: gate on `wallets.length > 0`, NOT on `authenticated`.
    // Privy auth flow timeline:
    //   t1: wallet handshakes -> `wallets` populates; `authenticated` = false
    //   t2: Privy shows 'Sign to verify' SIWE modal
    //   t3: user signs -> `authenticated` = true
    // The SIWE sign at t2 binds the signature to whatever chain the wallet
    // is currently on. Gating on `authenticated` fires at t3 = too late.
    // Gating on `wallets.length > 0` fires at t1, giving switchChain a
    // window to resolve before t2.
    wallets.forEach((wallet) => {
      // We use Privy's useWallets() instead of wagmi's useSwitchChain()
      // because @privy-io/wagmi's connector registers wallets to wagmi
      // asynchronously - at t1, wagmi hooks often haven't seen the wallet
      // yet, but useWallets() sees it immediately on handshake.
      if (wallet.type !== 'ethereum') return;
      if (wallet.chainId === ARC_TESTNET_CAIP) return;
      if (switchingWallets.current.has(wallet.address)) return;

      switchingWallets.current.add(wallet.address);
      wallet
        .switchChain(ARC_TESTNET_ID)
        .catch((err) => {
          console.warn('[AutoSwitchArcChain] switchChain failed', err);
        })
        .finally(() => {
          switchingWallets.current.delete(wallet.address);
        });
    });
  }, [privyReady, walletsReady, wallets]);

  return null;
}

Explanation

Three interlocking traps:

1. Auth-flow timing. Every model defaults to useEffect(() => { if (authenticated) wallet.switchChain(...) }, [authenticated]) because that matches the normal wagmi + wallet-auth mental model. But authenticated flips true at t3 (AFTER SIWE sign), not t1. The SIWE sign itself at t2 is what we need to happen on Arc. Correct gate: wallets.length > 0 fires at t1.

2. Hook choice. wagmi's useSwitchChain() is the canonical way, but @privy-io/wagmi's connector registers external wallets to wagmi asynchronously. At t1 (pre-SIWE), wagmi hooks often haven't seen the wallet yet, while Privy's useWallets() sees it immediately on handshake. Models reach for useSwitchChain() first because it's wagmi canon.

3. State vs Ref. useState<Set<string>> to track in-flight switches triggers a re-render on every add/delete, which re-runs the effect, which re-fires switchChain on the same wallet while its popup is still open -> MetaMask infinite loop. Ref is the only way that doesn't deadlock. Models default to useState because 'state changes should drive re-renders' is a React rule-of-thumb.

Derivation

Debugged live on ArcLayer production console (Privy app cmopoaivr00kw0cl7pl9m3noz, Arc testnet chain 5042002) with console.log on every Privy state transition during real Rabby + MetaMask + OKX logins. Observed authenticated flipped ~800ms AFTER the MetaMask signature. Confirmed defaultChain is embedded-wallet-only via Privy GitHub issues. Ran the fixed version in prod for 2 weeks with ~50 logins, zero SIWE-on-wrong-chain incidents.

Why this isn't trivially solvable

  • NOT in Privy docs (docs still use authenticated-gated effects)
  • NOT a model-age issue (tested across 6 frontier models, same wrong pattern)
  • NOT a missing-context issue (giving Privy + Arc docs doesn't help — docs themselves are wrong/incomplete here)
  • Failure mode is SILENT — SIWE completes successfully, user just ends up signed-in on Ethereum mainnet instead of Arc, manifests as 'tx reverts' much later, almost impossible to trace without instrumenting the auth flow
Edit

Pub: 13 May 2026 15:54 UTC

Edit: 13 May 2026 16:31 UTC

Views: 6