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;