diff --git a/packages/checkout/widgets-lib/src/components/Hero/NoGasHero.tsx b/packages/checkout/widgets-lib/src/components/Hero/NoGasHero.tsx new file mode 100644 index 0000000000..8814e9774a --- /dev/null +++ b/packages/checkout/widgets-lib/src/components/Hero/NoGasHero.tsx @@ -0,0 +1,35 @@ +/* eslint-disable max-len */ +import { Box } from '@biom3/react'; + +export function NoGasHero() { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/packages/checkout/widgets-lib/src/components/NotEnoughGasDrawer/NotEnoughGasDrawer.tsx b/packages/checkout/widgets-lib/src/components/NotEnoughGasDrawer/NotEnoughGasDrawer.tsx new file mode 100644 index 0000000000..1e699aa193 --- /dev/null +++ b/packages/checkout/widgets-lib/src/components/NotEnoughGasDrawer/NotEnoughGasDrawer.tsx @@ -0,0 +1,97 @@ +import { + Body, + Box, + Button, + Drawer, + Heading, +} from '@biom3/react'; +import { useTranslation } from 'react-i18next'; +import { RouteData } from '../../lib/squid/types'; +import { NoGasHero } from '../Hero/NoGasHero'; + +export function NotEnoughGasDrawer({ + visible, + routeData, + onTryAgainClick, + onToolkitClick, +}: { + visible: boolean; + routeData?: RouteData; + onTryAgainClick: () => void; + onToolkitClick: () => void; +}) { + const { t } = useTranslation(); + const tokenName = routeData?.route.route.estimate.gasCosts[0].token.symbol ?? ''; + + return ( + + + + + + + {t('views.ADD_TOKENS.noGasDrawer.heading', { token: tokenName })} + + + + {t('views.ADD_TOKENS.noGasDrawer.body')} + + + + + + + + + + ); +} diff --git a/packages/checkout/widgets-lib/src/lib/squid/functions/sortRoutesByFastestTime.ts b/packages/checkout/widgets-lib/src/lib/squid/functions/sortRoutesByFastestTime.ts index 2d425fc91e..e43980a8af 100644 --- a/packages/checkout/widgets-lib/src/lib/squid/functions/sortRoutesByFastestTime.ts +++ b/packages/checkout/widgets-lib/src/lib/squid/functions/sortRoutesByFastestTime.ts @@ -4,8 +4,15 @@ export const sortRoutesByFastestTime = (routes: RouteData[]): RouteData[] => { if (!routes) return []; return routes.slice().sort((a, b) => { + // Prioritize isInsufficientGas = false + if (a.isInsufficientGas !== b.isInsufficientGas) { + return a.isInsufficientGas ? 1 : -1; + } + + // Sort by estimatedRouteDuration if isInsufficientGas is the same const timeA = a.route.route.estimate.estimatedRouteDuration; const timeB = b.route.route.estimate.estimatedRouteDuration; + return timeA - timeB; }); }; diff --git a/packages/checkout/widgets-lib/src/lib/squid/hooks/useRoutes.ts b/packages/checkout/widgets-lib/src/lib/squid/hooks/useRoutes.ts index 31ead28296..713c39ecb9 100644 --- a/packages/checkout/widgets-lib/src/lib/squid/hooks/useRoutes.ts +++ b/packages/checkout/widgets-lib/src/lib/squid/hooks/useRoutes.ts @@ -13,6 +13,7 @@ import { isPassportProvider } from '../../provider'; import { AmountData, RouteData, RouteResponseData, Token, } from '../types'; +import { SQUID_NATIVE_TOKEN } from '../config'; const BASE_SLIPPAGE = 0.02; @@ -293,28 +294,97 @@ export const useRoutes = () => { } }; - const getRoutes = async ( + const getRoutesWithFeesValidation = async ( squid: Squid, - amountDataArray: AmountData[], toTokenAddress: string, + balances: TokenBalance[], + fromAmountArray: AmountData[], ): Promise => { - const routePromises = amountDataArray.map((data) => getRoute( - squid, - data.fromToken, - data.toToken, - toTokenAddress, - data.fromAmount, - data.toAmount, - ).then((route) => ({ - amountData: { ...data, additionalBuffer: route.additionalBuffer }, - route: route.route, - }))); + const getGasCost = ( + route: RouteResponseData, + chainId: string | number, + ) => (route.route?.route.estimate.gasCosts || []) + .filter((gasCost) => gasCost.token.chainId === chainId.toString()) + .reduce( + (sum, gasCost) => sum + parseFloat(utils.formatUnits(gasCost.amount, gasCost.token.decimals)), + 0, + ); + + const getTotalFees = ( + route: RouteResponseData, + chainId: string | number, + ) => (route.route?.route.estimate.feeCosts || []) + .filter((fee) => fee.token.chainId === chainId.toString()) + .reduce( + (sum, fee) => sum + parseFloat(utils.formatUnits(fee.amount, fee.token.decimals)), + 0, + ); + + const findUserGasBalance = (chainId: string | number) => balances.find( + (balance: TokenBalance) => balance.address.toLowerCase() === SQUID_NATIVE_TOKEN.toLowerCase() + && balance.chainId.toString() === chainId.toString(), + ); + + const hasSufficientNativeTokenBalance = ( + userGasBalance: TokenBalance | undefined, + fromAmount: string, + fromToken: Token, + totalGasCost: number, + totalFeeCost: number, + ) => { + if (!userGasBalance) return false; + + const userBalance = parseFloat( + utils.formatUnits(userGasBalance.balance, userGasBalance.decimals), + ); + + // If the fromToken is the native token, validate balance for both fromAmount and gas + fee costs + // Otherwise, only validate balance for gas + fee costs + const requiredAmount = fromToken.address.toLowerCase() === SQUID_NATIVE_TOKEN.toLowerCase() + ? parseFloat(fromAmount) + totalGasCost + totalFeeCost + : totalGasCost + totalFeeCost; + + return userBalance >= requiredAmount; + }; - const routesData = await Promise.all(routePromises); + const routePromises = fromAmountArray.map(async (data: AmountData) => { + try { + const routeResponse = await getRoute( + squid, + data.fromToken, + data.toToken, + toTokenAddress, + data.fromAmount, + data.toAmount, + ); + + if (!routeResponse?.route) return null; + + const gasCost = getGasCost(routeResponse, data.balance.chainId); + const feeCost = getTotalFees(routeResponse, data.balance.chainId); + const userGasBalance = findUserGasBalance(data.balance.chainId); + + return { + amountData: data, + route: routeResponse.route, + isInsufficientGas: !hasSufficientNativeTokenBalance( + userGasBalance, + data.fromAmount, + data.fromToken, + gasCost, + feeCost, + ), + } as RouteData; + } catch (error) { + return null; + } + }); - return routesData.filter( - (route): route is RouteData => route?.route !== undefined, + const routesData = (await Promise.all(routePromises)).filter( + (route): route is RouteData => route !== null, ); + + return routesData; }; const fetchRoutesWithRateLimit = async ( @@ -330,7 +400,7 @@ export const useRoutes = () => { ): Promise => { const currentRequestId = ++latestRequestIdRef.current; - let amountDataArray = getSufficientFromAmounts( + let fromAmountDataArray = getSufficientFromAmounts( tokens, balances, toChanId, @@ -339,23 +409,28 @@ export const useRoutes = () => { ); if (!isSwapAllowed) { - amountDataArray = amountDataArray.filter( + fromAmountDataArray = fromAmountDataArray.filter( (amountData) => amountData.balance.chainId !== toChanId, ); } let allRoutes: RouteData[] = []; await Promise.all( - amountDataArray + fromAmountDataArray .reduce((acc, _, i) => { if (i % bulkNumber === 0) { - acc.push(amountDataArray.slice(i, i + bulkNumber)); + acc.push(fromAmountDataArray.slice(i, i + bulkNumber)); } return acc; - }, [] as (typeof amountDataArray)[]) - .map(async (slicedAmountDataArray) => { + }, [] as (typeof fromAmountDataArray)[]) + .map(async (slicedFromAmountDataArray) => { allRoutes.push( - ...(await getRoutes(squid, slicedAmountDataArray, toTokenAddress)), + ...(await getRoutesWithFeesValidation( + squid, + toTokenAddress, + balances, + slicedFromAmountDataArray, + )), ); await delay(delayMs); }), @@ -370,7 +445,6 @@ export const useRoutes = () => { } const sortedRoutes = sortRoutesByFastestTime(allRoutes); - // Only update routes if the request is the latest one if (currentRequestId === latestRequestIdRef.current) { setRoutes(sortedRoutes); diff --git a/packages/checkout/widgets-lib/src/lib/squid/types.ts b/packages/checkout/widgets-lib/src/lib/squid/types.ts index 29135f5f33..dd1ae23150 100644 --- a/packages/checkout/widgets-lib/src/lib/squid/types.ts +++ b/packages/checkout/widgets-lib/src/lib/squid/types.ts @@ -38,6 +38,7 @@ export type AmountData = { export type RouteData = { amountData: AmountData; route: RouteResponse; + isInsufficientGas: boolean; }; export type RouteResponseData = { diff --git a/packages/checkout/widgets-lib/src/locales/en.json b/packages/checkout/widgets-lib/src/locales/en.json index 0e2e77671e..81fbe2c213 100644 --- a/packages/checkout/widgets-lib/src/locales/en.json +++ b/packages/checkout/widgets-lib/src/locales/en.json @@ -866,7 +866,14 @@ "fiatPricePrefix": "≈ USD", "zeroFees": "Zero fees", "includedFees": "Fees" - } + }, + "noGasDrawer": { + "heading": "You'll need more {{token}} for gas", + "body": "Add more gas and return to complete this transaction, or try another option such as lowering your amount, using another wallet, or buying with card.", + "primaryAction": "Try another option", + "secondaryAction": "Add more {{token}}" + }, + "noGasRouteMessage": "Insufficient {{token}} for gas" } }, "footers": { diff --git a/packages/checkout/widgets-lib/src/locales/ja.json b/packages/checkout/widgets-lib/src/locales/ja.json index b8f4ce7e3e..37c7a48450 100644 --- a/packages/checkout/widgets-lib/src/locales/ja.json +++ b/packages/checkout/widgets-lib/src/locales/ja.json @@ -849,7 +849,14 @@ "fiatPricePrefix": "≈ USD", "zeroFees": "手数料無料", "includedFees": "手数料" - } + }, + "noGasDrawer": { + "heading": "{{token}} がガスにもっと必要です", + "body": "ガスを追加してこの取引を完了するか、金額を減らす、別のウォレットを使用する、またはカードで購入するなど、他のオプションを試してください。", + "primaryAction": "別のオプションを試す", + "secondaryAction": "{{token}} をさらに追加する" + }, + "noGasRouteMessage": "ガス用の {{token}} が不足しています" } }, "footers": { diff --git a/packages/checkout/widgets-lib/src/locales/ko.json b/packages/checkout/widgets-lib/src/locales/ko.json index 942ddf2424..6f65394d6a 100644 --- a/packages/checkout/widgets-lib/src/locales/ko.json +++ b/packages/checkout/widgets-lib/src/locales/ko.json @@ -846,7 +846,14 @@ "fiatPricePrefix": "≈ USD", "zeroFees": "수수료 없음", "includedFees": "수수료" - } + }, + "noGasDrawer": { + "heading": "가스에 {{token}}이(가) 더 필요합니다", + "body": "가스를 추가하고 이 거래를 완료하거나 금액을 줄이기, 다른 지갑 사용, 카드로 구매하기와 같은 다른 옵션을 시도해보세요.", + "primaryAction": "다른 옵션 시도하기", + "secondaryAction": "{{token}} 더 추가하기" + }, + "noGasRouteMessage": "가스에 필요한 {{token}}이(가) 부족합니다" } }, "footers": { diff --git a/packages/checkout/widgets-lib/src/locales/zh.json b/packages/checkout/widgets-lib/src/locales/zh.json index 2bf2e1a903..775b9b6bb5 100644 --- a/packages/checkout/widgets-lib/src/locales/zh.json +++ b/packages/checkout/widgets-lib/src/locales/zh.json @@ -846,7 +846,14 @@ "fiatPricePrefix": "≈ 美元", "zeroFees": "零手续费", "includedFees": "费用" - } + }, + "noGasDrawer": { + "heading": "您需要更多 {{token}} 用于燃料费", + "body": "添加更多燃料费后返回完成此交易,或者尝试其他选项,例如降低金额、使用其他钱包或使用银行卡购买。", + "primaryAction": "尝试其他选项", + "secondaryAction": "添加更多 {{token}}" + }, + "noGasRouteMessage": "用于燃料费的 {{token}} 不足" } }, "footers": { diff --git a/packages/checkout/widgets-lib/src/widgets/add-tokens/components/RouteOption.tsx b/packages/checkout/widgets-lib/src/widgets/add-tokens/components/RouteOption.tsx index 7e7311512a..f9c40f6731 100644 --- a/packages/checkout/widgets-lib/src/widgets/add-tokens/components/RouteOption.tsx +++ b/packages/checkout/widgets-lib/src/widgets/add-tokens/components/RouteOption.tsx @@ -98,6 +98,17 @@ export function RouteOption({ {`${t('views.ADD_TOKENS.fees.balance')} ${t('views.ADD_TOKENS.fees.fiatPricePrefix')} $${routeBalanceUsd}`} + {routeData.isInsufficientGas && ( + <> +
+ + {t('views.ADD_TOKENS.noGasRouteMessage', { + token: + routeData.route.route.estimate.gasCosts[0].token.symbol, + })} + + + )}
diff --git a/packages/checkout/widgets-lib/src/widgets/add-tokens/components/SelectedRouteOption.tsx b/packages/checkout/widgets-lib/src/widgets/add-tokens/components/SelectedRouteOption.tsx index d2a92d72f5..c0f003cfcf 100644 --- a/packages/checkout/widgets-lib/src/widgets/add-tokens/components/SelectedRouteOption.tsx +++ b/packages/checkout/widgets-lib/src/widgets/add-tokens/components/SelectedRouteOption.tsx @@ -182,6 +182,17 @@ export function SelectedRouteOption({ {`${t('views.ADD_TOKENS.fees.balance')} ${t( 'views.ADD_TOKENS.fees.fiatPricePrefix', )} $${routeBalanceUsd}`} + {routeData?.isInsufficientGas && ( + <> +
+ + {t('views.ADD_TOKENS.noGasRouteMessage', { + token: + routeData.route.route.estimate.gasCosts[0].token.symbol, + })} + + + )} diff --git a/packages/checkout/widgets-lib/src/widgets/add-tokens/components/TokenDrawerMenu.tsx b/packages/checkout/widgets-lib/src/widgets/add-tokens/components/TokenDrawerMenu.tsx index f9b075dc11..43f73da9cb 100644 --- a/packages/checkout/widgets-lib/src/widgets/add-tokens/components/TokenDrawerMenu.tsx +++ b/packages/checkout/widgets-lib/src/widgets/add-tokens/components/TokenDrawerMenu.tsx @@ -215,7 +215,7 @@ export function TokenDrawerMenu({ pos: 'relative', cursor: 'pointer', // eslint-disable-next-line @typescript-eslint/naming-convention - '&:hover > div:first-child': { + '&:hover > div:first-of-type': { boxShadow: ({ base }) => `0 0 0 ${base.border.size[200]} ${base.color.text.body.primary}`, }, }} diff --git a/packages/checkout/widgets-lib/src/widgets/add-tokens/utils/config.ts b/packages/checkout/widgets-lib/src/widgets/add-tokens/utils/config.ts index 467e9629b6..ed3c268bd4 100644 --- a/packages/checkout/widgets-lib/src/widgets/add-tokens/utils/config.ts +++ b/packages/checkout/widgets-lib/src/widgets/add-tokens/utils/config.ts @@ -9,3 +9,5 @@ export const BLOCK_TXN_ANIMATION = '/blocked.riv'; export const ERROR_TXN_ANIMATION = '/error.riv'; export const TOKEN_PRIORITY_ORDER = ['IMX', 'USDC', 'ETH']; + +export const TOOLKIT_SQUID_URL = 'https://toolkit.immutable.com/squid-bridge/'; diff --git a/packages/checkout/widgets-lib/src/widgets/add-tokens/views/AddTokens.tsx b/packages/checkout/widgets-lib/src/widgets/add-tokens/views/AddTokens.tsx index 584a7a0c5a..0ab31e100f 100644 --- a/packages/checkout/widgets-lib/src/widgets/add-tokens/views/AddTokens.tsx +++ b/packages/checkout/widgets-lib/src/widgets/add-tokens/views/AddTokens.tsx @@ -72,6 +72,8 @@ import { getFormattedAmounts } from '../functions/getFormattedNumber'; import { RouteData } from '../../../lib/squid/types'; import { SQUID_NATIVE_TOKEN } from '../../../lib/squid/config'; import { identifyUser } from '../../../lib/analytics/identifyUser'; +import { NotEnoughGasDrawer } from '../../../components/NotEnoughGasDrawer/NotEnoughGasDrawer'; +import { TOOLKIT_SQUID_URL } from '../utils/config'; interface AddTokensProps { checkout: Checkout; @@ -147,6 +149,7 @@ export function AddTokens({ const [fetchingRoutes, setFetchingRoutes] = useState(false); const [insufficientBalance, setInsufficientBalance] = useState(false); const [isAmountInputSynced, setIsAmountInputSynced] = useState(false); + const [showNotEnoughGasDrawer, setShowNotEnoughGasDrawer] = useState(false); const debouncedSetSelectedAmount = useRef( debounce((value: string) => { @@ -197,6 +200,7 @@ export function AddTokens({ hasEmbeddedSwap: !!route.route.route.estimate.actions.find( (action) => action.type === ActionType.SWAP, ), + isInsufficientGas: route.isInsufficientGas, }, }); } @@ -543,7 +547,11 @@ export function AddTokens({ const loading = (routeInputsReady || fetchingRoutes) && !(selectedRouteData || insufficientBalance); - const readyToReview = routeInputsReady && !!toAddress && !!selectedRouteData && !loading; + const readyToReview = routeInputsReady + && !!toAddress + && !!selectedRouteData + && !selectedRouteData.isInsufficientGas + && !loading; const handleWalletConnected = ( providerType: 'from' | 'to', @@ -595,6 +603,19 @@ export function AddTokens({ }); }, [id, experiments]); + useEffect(() => { + if (selectedRouteData?.isInsufficientGas) { + setShowNotEnoughGasDrawer(true); + } else { + setShowNotEnoughGasDrawer(false); + } + }, [selectedRouteData]); + + const handleToolkitClick = () => { + setShowNotEnoughGasDrawer(false); + window.open(TOOLKIT_SQUID_URL, '_blank'); + }; + return ( + setShowNotEnoughGasDrawer(false)} + onToolkitClick={handleToolkitClick} + /> ); } diff --git a/packages/checkout/widgets-lib/src/widgets/add-tokens/views/Review.tsx b/packages/checkout/widgets-lib/src/widgets/add-tokens/views/Review.tsx index d0c4a273e0..738b429f69 100644 --- a/packages/checkout/widgets-lib/src/widgets/add-tokens/views/Review.tsx +++ b/packages/checkout/widgets-lib/src/widgets/add-tokens/views/Review.tsx @@ -50,6 +50,7 @@ import { APPROVE_TXN_ANIMATION, EXECUTE_TXN_ANIMATION, FIXED_HANDOVER_DURATION, + TOOLKIT_SQUID_URL, } from '../utils/config'; import { useAnalytics, @@ -590,7 +591,7 @@ export function Review({ rc={( )} @@ -604,7 +605,7 @@ export function Review({ ), onPrimaryButtonClick: () => { window.open( - 'https://toolkit.immutable.com/squid-bridge/', + TOOLKIT_SQUID_URL, '_blank', 'noreferrer', );