Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand All @@ -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,
Expand All @@ -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]);

Expand All @@ -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(() => {
Expand Down Expand Up @@ -140,8 +169,11 @@ export function NetworkSwitchDrawer({
wallet: walletDisplayName,
})}
</Heading>
{/** 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).
*/}
<Body
size="medium"
weight="regular"
Expand All @@ -151,26 +183,11 @@ export function NetworkSwitchDrawer({
paddingX: 'base.spacing.x6',
}}
>
{t('drawers.networkSwitch.manualSwitch.body', {
chain: targetChainName,
})}
</Body>
)}
{!requireManualSwitch && (
<Body
size="medium"
weight="regular"
sx={{
color: 'base.color.text.body.secondary',
textAlign: 'center',
paddingX: 'base.spacing.x6',
}}
>
{t('drawers.networkSwitch.controlledSwitch.body', {
{t(bodyTextKey, {
chain: targetChainName,
wallet: walletDisplayName,
})}
</Body>
)}
</Box>

<Box sx={{
Expand All @@ -180,16 +197,19 @@ export function NetworkSwitchDrawer({
width: '100%',
}}
>
{!requireManualSwitch && (
{!requireManualSwitch && !cannotSwitch && (
<Button
size="large"
variant="primary"
sx={{ width: '100%', marginBottom: 'base.spacing.x2' }}
onClick={handleSwitchNetwork}
>
{t('drawers.networkSwitch.switchButton', {
chain: targetChainName,
})}
{t(
switchFailed
? 'drawers.networkSwitch.retryButton'
: 'drawers.networkSwitch.switchButton',
{ chain: targetChainName },
)}
</Button>
)}
<FooterLogo />
Expand Down
43 changes: 42 additions & 1 deletion packages/checkout/widgets-lib/src/lib/chains.test.ts
Original file line number Diff line number Diff line change
@@ -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 = [
Expand Down Expand Up @@ -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();
});
});
});
21 changes: 21 additions & 0 deletions packages/checkout/widgets-lib/src/lib/chains.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
9 changes: 8 additions & 1 deletion packages/checkout/widgets-lib/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down
Loading