diff --git a/packages/checkout/widgets-lib/src/components/NetworkSwitchDrawer/NetworkSwitchDrawer.tsx b/packages/checkout/widgets-lib/src/components/NetworkSwitchDrawer/NetworkSwitchDrawer.tsx
index e4ef9b4709..a2295caa1a 100644
--- a/packages/checkout/widgets-lib/src/components/NetworkSwitchDrawer/NetworkSwitchDrawer.tsx
+++ b/packages/checkout/widgets-lib/src/components/NetworkSwitchDrawer/NetworkSwitchDrawer.tsx
@@ -8,7 +8,7 @@ import {
Heading,
} from '@biom3/react';
import {
- useCallback, useMemo, useEffect,
+ useCallback, useMemo, useEffect, useState,
} from 'react';
import { useTranslation } from 'react-i18next';
import { ChainId, Checkout, WrappedBrowserProvider } from '@imtbl/checkout-sdk';
@@ -17,6 +17,7 @@ import { FooterLogo } from '../Footer/FooterLogo';
import { getChainNameById } from '../../lib/chains';
import {
isMetaMaskProvider,
+ isPassportProvider,
isWalletConnectProvider,
} from '../../lib/provider';
import { getRemoteImage } from '../../lib/utils';
@@ -38,6 +39,7 @@ export function NetworkSwitchDrawer({
onNetworkSwitch,
}: NetworkSwitchDrawerProps) {
const { t } = useTranslation();
+ const [switchFailed, setSwitchFailed] = useState(false);
const ethImageUrl = getRemoteImage(
checkout.config.environment ?? Environment.PRODUCTION,
@@ -54,12 +56,21 @@ export function NetworkSwitchDrawer({
const handleSwitchNetwork = useCallback(async () => {
if (!checkout) return;
- const switchNetworkResult = await checkout.switchNetwork({
- provider,
- chainId: targetChainId,
- });
- if (onNetworkSwitch) {
- onNetworkSwitch(switchNetworkResult.provider);
+ try {
+ const switchNetworkResult = await checkout.switchNetwork({
+ provider,
+ chainId: targetChainId,
+ });
+ if (onNetworkSwitch) {
+ onNetworkSwitch(switchNetworkResult.provider);
+ }
+ } catch (err) {
+ // Without this catch the rejection is unhandled: the click handler returns
+ // a floating promise, so a failed switch surfaces only as a console error
+ // and floods error reporting instead of telling the user anything.
+ // eslint-disable-next-line no-console
+ console.error(err);
+ setSwitchFailed(true);
}
}, [checkout, provider, onNetworkSwitch, targetChainId]);
@@ -76,13 +87,31 @@ export function NetworkSwitchDrawer({
);
const walletDisplayName = useMemo(() => {
+ if (isPassportProvider(provider)) return 'Passport wallet';
if (isMetaMaskProvider(provider)) return 'MetaMask wallet';
if (isWalletConnect && walletConnectPeerName) return walletConnectPeerName;
return 'wallet';
}, [provider, isWalletConnect, walletConnectPeerName]);
+ // Passport rejects wallet_switchEthereumChain by design (see `switchWalletNetwork`
+ // in checkout-sdk), so offering a switch button leads the user to a dead end.
+ const cannotSwitch = isPassportProvider(provider);
+
const requireManualSwitch = isWalletConnect && isMetaMaskMobileWalletPeer;
+ const bodyTextKey = useMemo(() => {
+ if (cannotSwitch) return 'drawers.networkSwitch.unsupportedSwitch.body';
+ if (switchFailed) return 'drawers.networkSwitch.switchFailed.body';
+ if (requireManualSwitch) return 'drawers.networkSwitch.manualSwitch.body';
+ return 'drawers.networkSwitch.controlledSwitch.body';
+ }, [cannotSwitch, switchFailed, requireManualSwitch]);
+
+ // Clear a previous failure when the drawer is reopened or the target changes,
+ // so a stale error message doesn't carry into a fresh attempt.
+ useEffect(() => {
+ if (visible) setSwitchFailed(false);
+ }, [visible, provider, targetChainId]);
+
// Image preloading - load images into browser when component mounts
// show cached images when drawer is made visible
useEffect(() => {
@@ -140,8 +169,11 @@ export function NetworkSwitchDrawer({
wallet: walletDisplayName,
})}
- {/** MetaMask mobile requires manual switch */}
- {requireManualSwitch && (
+ {/**
+ * Copy depends on whether the wallet can switch at all (Passport cannot),
+ * whether a previous attempt failed, and whether the wallet requires a
+ * manual switch (MetaMask mobile over WalletConnect).
+ */}
- {t('drawers.networkSwitch.manualSwitch.body', {
- chain: targetChainName,
- })}
-
- )}
- {!requireManualSwitch && (
-
- {t('drawers.networkSwitch.controlledSwitch.body', {
+ {t(bodyTextKey, {
chain: targetChainName,
+ wallet: walletDisplayName,
})}
- )}
- {!requireManualSwitch && (
+ {!requireManualSwitch && !cannotSwitch && (
)}
diff --git a/packages/checkout/widgets-lib/src/lib/chains.test.ts b/packages/checkout/widgets-lib/src/lib/chains.test.ts
index 9ec482c46f..39baffd1d5 100644
--- a/packages/checkout/widgets-lib/src/lib/chains.test.ts
+++ b/packages/checkout/widgets-lib/src/lib/chains.test.ts
@@ -1,5 +1,7 @@
import { ChainId, ChainName, ChainSlug } from '@imtbl/checkout-sdk';
-import { getChainIdBySlug, getChainNameById, getChainSlugById } from './chains';
+import {
+ getChainIdBySlug, getChainNameById, getChainSlugById, parseChainId,
+} from './chains';
describe('getChainNameById', () => {
const tests = [
@@ -48,3 +50,42 @@ describe('getChainIdBySlug', () => {
});
});
});
+
+describe('parseChainId', () => {
+ // Regression: a lint autofix once rewrote `parseInt(chainId)` to
+ // `parseInt(chainId, 10)`, which returns 0 for the hex values EIP-695
+ // mandates. Every chain then read as "wrong network" and the bridge became
+ // unusable. These cases pin the hex handling down.
+ const hexCases = [
+ { value: '0x343b', expected: ChainId.IMTBL_ZKEVM_MAINNET },
+ { value: '0x34a1', expected: ChainId.IMTBL_ZKEVM_TESTNET },
+ { value: '0x1', expected: ChainId.ETHEREUM },
+ { value: '0xaa36a7', expected: ChainId.SEPOLIA },
+ ];
+
+ hexCases.forEach(({ value, expected }) => {
+ it(`should parse hex ${value} as ${expected}`, () => {
+ expect(parseChainId(value)).toEqual(expected);
+ });
+ });
+
+ it('should parse a decimal string', () => {
+ expect(parseChainId('13371')).toEqual(ChainId.IMTBL_ZKEVM_MAINNET);
+ });
+
+ it('should pass through a number', () => {
+ expect(parseChainId(13371)).toEqual(ChainId.IMTBL_ZKEVM_MAINNET);
+ });
+
+ it('should parse a bigint', () => {
+ expect(parseChainId(BigInt(13371))).toEqual(ChainId.IMTBL_ZKEVM_MAINNET);
+ });
+
+ const invalidCases = [null, undefined, '', 'not-a-chain', '0x', 0, -1, 1.5];
+
+ invalidCases.forEach((value) => {
+ it(`should return null for ${JSON.stringify(value)}`, () => {
+ expect(parseChainId(value)).toBeNull();
+ });
+ });
+});
diff --git a/packages/checkout/widgets-lib/src/lib/chains.ts b/packages/checkout/widgets-lib/src/lib/chains.ts
index 70f99668d8..22d278cbaa 100644
--- a/packages/checkout/widgets-lib/src/lib/chains.ts
+++ b/packages/checkout/widgets-lib/src/lib/chains.ts
@@ -1,5 +1,26 @@
import { ChainId, ChainName, ChainSlug } from '@imtbl/checkout-sdk';
+/**
+ * Parse a chain id as returned by an EIP-1193 provider (e.g. `eth_chainId`).
+ *
+ * EIP-695 specifies a hex-encoded quantity such as `0x343b`, but providers are
+ * inconsistent and may return a decimal string or a number instead. `Number()`
+ * handles all three.
+ *
+ * Do NOT reach for `parseInt(value, 10)` here: it silently returns 0 for hex
+ * input, which reads as "wrong network" for every chain. Use this helper so the
+ * radix decision lives in one tested place rather than at each call site.
+ *
+ * Returns `null` when the value cannot be parsed, so callers can tell an
+ * unknown chain apart from a legitimately parsed id.
+ */
+export function parseChainId(chainId: unknown): ChainId | null {
+ if (chainId === null || chainId === undefined || chainId === '') return null;
+ const parsed = Number(chainId);
+ if (!Number.isInteger(parsed) || parsed <= 0) return null;
+ return parsed as ChainId;
+}
+
export function getChainNameById(chainId: ChainId): ChainName {
switch (chainId) {
case ChainId.ETHEREUM: return ChainName.ETHEREUM;
diff --git a/packages/checkout/widgets-lib/src/locales/en.json b/packages/checkout/widgets-lib/src/locales/en.json
index 69659e5527..275e1cfee7 100644
--- a/packages/checkout/widgets-lib/src/locales/en.json
+++ b/packages/checkout/widgets-lib/src/locales/en.json
@@ -1237,7 +1237,14 @@
"controlledSwitch": {
"body": "You'll need to switch to the {{chain}} network to proceed"
},
- "switchButton": "Switch to {{chain}}"
+ "unsupportedSwitch": {
+ "body": "Your {{wallet}} can't switch networks. Go back and choose a different wallet to use the {{chain}} network."
+ },
+ "switchFailed": {
+ "body": "We couldn't switch to the {{chain}} network. Check your {{wallet}} and try again."
+ },
+ "switchButton": "Switch to {{chain}}",
+ "retryButton": "Try again"
},
"walletConnectionError": {
"unableToConnect": {
diff --git a/packages/checkout/widgets-lib/src/widgets/bridge/components/BridgeReviewSummary.tsx b/packages/checkout/widgets-lib/src/widgets/bridge/components/BridgeReviewSummary.tsx
index 48a3394db2..149eafa8ab 100644
--- a/packages/checkout/widgets-lib/src/widgets/bridge/components/BridgeReviewSummary.tsx
+++ b/packages/checkout/widgets-lib/src/widgets/bridge/components/BridgeReviewSummary.tsx
@@ -22,6 +22,7 @@ import {
isWalletConnectProvider,
} from '../../../lib/provider';
import { calculateCryptoToFiat, getChainImage, isNativeToken } from '../../../lib/utils';
+import { parseChainId } from '../../../lib/chains';
import {
DEFAULT_QUOTE_REFRESH_INTERVAL,
DEFAULT_TOKEN_DECIMALS,
@@ -391,7 +392,7 @@ export function BridgeReviewSummary() {
return;
}
const currentChainId = await provider.send('eth_chainId', []);
- const parsedChainId = parseInt(String(currentChainId), 10);
+ const parsedChainId = parseChainId(currentChainId);
if (parsedChainId !== from?.network) {
setShowSwitchNetworkDrawer(true);
return;
diff --git a/packages/checkout/widgets-lib/src/widgets/connect/components/WalletList.tsx b/packages/checkout/widgets-lib/src/widgets/connect/components/WalletList.tsx
index 8ccff73ff0..3831809636 100644
--- a/packages/checkout/widgets-lib/src/widgets/connect/components/WalletList.tsx
+++ b/packages/checkout/widgets-lib/src/widgets/connect/components/WalletList.tsx
@@ -51,6 +51,7 @@ import { BrowserWalletItem } from './BrowserWalletItem';
import { identifyUser } from '../../../lib/analytics/identifyUser';
import { NonPassportWarningDrawer } from './NonPassportWarningDrawer';
import { removeSpace } from '../../../lib/utils';
+import { parseChainId } from '../../../lib/chains';
export interface WalletListProps {
targetWalletRdns?: string;
@@ -136,11 +137,10 @@ export function WalletList(props: WalletListProps) {
const handleConnectViewUpdate = async (provider: WrappedBrowserProvider) => {
const isPassport = isPassportProvider(provider);
const chainId = await provider.send!('eth_chainId', []);
- // eslint-disable-next-line radix
- const parsedChainId = parseInt(chainId.toString());
+ const parsedChainId = parseChainId(chainId);
if (
parsedChainId !== targetChainId
- && !allowedChains?.includes(parsedChainId)
+ && !(parsedChainId && allowedChains?.includes(parsedChainId))
) {
// TODO: What do we do with Passport here as it can't connect to L1
if (isPassport) {
diff --git a/packages/checkout/widgets-lib/src/widgets/connect/views/ReadyToConnect.tsx b/packages/checkout/widgets-lib/src/widgets/connect/views/ReadyToConnect.tsx
index 1609b5f1f3..3df944b728 100644
--- a/packages/checkout/widgets-lib/src/widgets/connect/views/ReadyToConnect.tsx
+++ b/packages/checkout/widgets-lib/src/widgets/connect/views/ReadyToConnect.tsx
@@ -20,6 +20,7 @@ import { ViewContext, ViewActions } from '../../../context/view-context/ViewCont
import { isMetaMaskProvider, isPassportProvider } from '../../../lib/provider';
import { UserJourney, useAnalytics } from '../../../context/analytics-provider/SegmentAnalyticsProvider';
import { identifyUser } from '../../../lib/analytics/identifyUser';
+import { parseChainId } from '../../../lib/chains';
export interface ReadyToConnectProps {
targetChainId: ChainId;
@@ -89,9 +90,8 @@ export function ReadyToConnect({ targetChainId, allowedChains }: ReadyToConnectP
// eslint-disable-next-line @typescript-eslint/no-shadow
const handleConnectViewUpdate = async (provider: WrappedBrowserProvider) => {
const chainId = await provider.send!('eth_chainId', []);
- // eslint-disable-next-line radix
- const parsedChainId = parseInt(chainId.toString());
- if (parsedChainId !== targetChainId && !allowedChains?.includes(parsedChainId)) {
+ const parsedChainId = parseChainId(chainId);
+ if (parsedChainId !== targetChainId && !(parsedChainId && allowedChains?.includes(parsedChainId))) {
// TODO: What do we do with Passport here as it can't connect to L1
if (isPassport) {
viewDispatch({
diff --git a/packages/checkout/widgets-lib/src/widgets/connect/views/SwitchNetworkZkEVM.tsx b/packages/checkout/widgets-lib/src/widgets/connect/views/SwitchNetworkZkEVM.tsx
index a37c2c59cd..d9bdc47323 100644
--- a/packages/checkout/widgets-lib/src/widgets/connect/views/SwitchNetworkZkEVM.tsx
+++ b/packages/checkout/widgets-lib/src/widgets/connect/views/SwitchNetworkZkEVM.tsx
@@ -3,6 +3,7 @@ import {
} from 'react';
import { useTranslation } from 'react-i18next';
import { isWalletConnectProvider } from '../../../lib/provider';
+import { parseChainId } from '../../../lib/chains';
import { SimpleTextBody } from '../../../components/Body/SimpleTextBody';
import { FooterButton } from '../../../components/Footer/FooterButton';
import { HeaderNavigation } from '../../../components/Header/HeaderNavigation';
@@ -34,8 +35,7 @@ export function SwitchNetworkZkEVM() {
const checkCorrectNetwork = async () => {
const currentChainId = await provider.send('eth_chainId', []);
- // eslint-disable-next-line radix
- const parsedChainId = Number(currentChainId.toString());
+ const parsedChainId = parseChainId(currentChainId);
if (parsedChainId === checkout.config.l2ChainId) {
connectDispatch({
payload: {
@@ -76,7 +76,7 @@ export function SwitchNetworkZkEVM() {
if (!provider.send) return;
const currentChainId = await provider.send('eth_chainId', []) as `0x${string}`;
- const parsedChainId = Number(currentChainId);
+ const parsedChainId = parseChainId(currentChainId);
if (parsedChainId === checkout.config.l2ChainId) {
connectDispatch({
diff --git a/packages/checkout/widgets-lib/src/widgets/swap/components/SwapForm.tsx b/packages/checkout/widgets-lib/src/widgets/swap/components/SwapForm.tsx
index 3f433b122a..2651cda590 100644
--- a/packages/checkout/widgets-lib/src/widgets/swap/components/SwapForm.tsx
+++ b/packages/checkout/widgets-lib/src/widgets/swap/components/SwapForm.tsx
@@ -47,6 +47,7 @@ import { ConnectLoaderContext } from '../../../context/connect-loader-context/Co
import useDebounce from '../../../lib/hooks/useDebounce';
import { CancellablePromise } from '../../../lib/async/cancellablePromise';
import { isPassportProvider } from '../../../lib/provider';
+import { parseChainId } from '../../../lib/chains';
import { formatSwapFees } from '../functions/swapFees';
import { processGasFree } from '../functions/processGasFree';
import { processSecondaryFees } from '../functions/processSecondaryFees';
@@ -885,8 +886,7 @@ export function SwapForm({
try {
// check for switch network here
const currentChainId = await (provider.provider as any).send('eth_chainId', []);
- // eslint-disable-next-line radix
- const parsedChainId = parseInt(currentChainId.toString());
+ const parsedChainId = parseChainId(currentChainId);
if (parsedChainId !== checkout.config.l2ChainId) {
setShowNetworkSwitchDrawer(true);
return;