diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index ae3ec4c145..22ff3f4bb5 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -130,6 +130,12 @@ jobs: tag: ${{ contains(env.RELEASE_TYPE, 'alpha') && 'alpha' }} dry-run: ${{ env.DRY_RUN }} + - name: Generate last_updated.json + if: (env.DRY_RUN) == 'false' + run: | + echo "{\"timestamp\": \"$(date -u +"%Y-%m-%dT%H:%M:%SZ")\"}" > ./sdk/last_updated.json + cp ./sdk/last_updated.json ./sdk/dist/ + # ! Do NOT remove - this will cause a Sev 0 incident ! - name: Generate SDK attestation uses: actions/attest-build-provenance@v1 diff --git a/packages/checkout/sdk/src/widgets/version.ts b/packages/checkout/sdk/src/widgets/version.ts index 0e16aab7e4..6f22ce012d 100644 --- a/packages/checkout/sdk/src/widgets/version.ts +++ b/packages/checkout/sdk/src/widgets/version.ts @@ -84,33 +84,6 @@ export async function getLatestVersionFromNpm(): Promise { } } -/** - * Checks if the provided version is available on the CDN. - * @param {string} version - The version to check. - * @returns {Promise} A promise resolving to a boolean indicating if the version is available on the CDN. - */ -async function isVersionAvailableOnCDN(version: string): Promise { - const files = ['widgets-esm.js', 'widgets.js']; - const baseUrl = `https://cdn.jsdelivr.net/npm/@imtbl/sdk@${version}/dist/browser/checkout/`; - - try { - const checks = files.map(async (file) => { - const response = await fetch(`${baseUrl}${file}`, { method: 'HEAD' }); - if (!response.ok) { - return false; - } - return true; - }); - - const results = await Promise.all(checks); - const allFilesAvailable = results.every((isAvailable) => isAvailable); - - return allFilesAvailable; - } catch { - return false; - } -} - /** * Returns the latest compatible version based on the provided checkout version config. * If no compatible version markers are provided, it returns 'latest'. @@ -127,6 +100,40 @@ function latestCompatibleVersion( return 'latest'; } +/** + * Checks if the last_updated.json file exists on the CDN and validates its timestamp. + * @param {string} version - The version to check. + * @returns {Promise} A promise resolving to `true` if last_updated.json exists and is older than 15 minutes, `false` otherwise. + */ +async function checkLastUpdatedTimestamp(version: string): Promise { + const WAIT_TIME_IN_MINUTES = 45; + + const lastUpdatedJsonUrl = `https://cdn.jsdelivr.net/npm/@imtbl/sdk@${version}/dist/last_updated.json`; + + try { + const response = await fetch(lastUpdatedJsonUrl); + + if (!response.ok) { + return false; + } + + const lastUpdatedData = await response.json(); + + if (lastUpdatedData.timestamp) { + const timestamp = new Date(lastUpdatedData.timestamp); + const now = new Date(); + const diffInMs = now.getTime() - timestamp.getTime(); + const diffInMinutes = diffInMs / (1000 * 60); + + return diffInMinutes > WAIT_TIME_IN_MINUTES; + } + } catch (error) { + return false; + } + + return false; +} + /** * Determines the version of the widgets to use based on the provided validated build version and checkout version config. * If a version is provided in the widget init parameters, it uses that version. @@ -158,13 +165,14 @@ export async function determineWidgetsVersion( versionConfig.compatibleVersionMarkers, ); - // If `latest` is returned, query NPM registry for the actual latest version and check if it's available on the CDN + // If `latest` is returned, query NPM registry for the actual latest version and check timestamp if (compatibleVersion === 'latest') { const latestVersion = await getLatestVersionFromNpm(); - const isAvailable = await isVersionAvailableOnCDN(latestVersion); - if (isAvailable) { + + if (await checkLastUpdatedTimestamp(latestVersion)) { return latestVersion; } + return 'latest'; } diff --git a/packages/checkout/widgets-lib/src/components/RouteOptionsDrawer/RouteOption.tsx b/packages/checkout/widgets-lib/src/components/RouteOptionsDrawer/RouteOption.tsx index 8a21026ead..63ddb2c59c 100644 --- a/packages/checkout/widgets-lib/src/components/RouteOptionsDrawer/RouteOption.tsx +++ b/packages/checkout/widgets-lib/src/components/RouteOptionsDrawer/RouteOption.tsx @@ -29,7 +29,6 @@ export interface RouteOptionProps< rc?: RC; selected?: boolean; displayPriceDetails?: boolean; - displayInsufficientGasWarning?: boolean; } export function RouteOption({ @@ -42,7 +41,6 @@ export function RouteOption({ rc = , selected = false, displayPriceDetails = true, - displayInsufficientGasWarning = true, }: RouteOptionProps) { const { t } = useTranslation(); @@ -101,7 +99,7 @@ export function RouteOption({ {`${t('views.ADD_TOKENS.fees.balance')} ${t('views.ADD_TOKENS.fees.fiatPricePrefix')} $${routeBalanceUsd}`} - {displayInsufficientGasWarning && routeData.isInsufficientGas && ( + { displayPriceDetails && routeData.isInsufficientGas && ( <>
@@ -112,6 +110,19 @@ export function RouteOption({ )} + + { displayPriceDetails && routeData.isInsufficientBalance && ( + <> +
+ + {/* {t('views.ADD_TOKENS.noBalanceRouteMessage', { + token: fromToken.symbol, + })} */} + {' '} + Insufficient balance + + + )}
diff --git a/packages/checkout/widgets-lib/src/components/RouteOptionsDrawer/RouteOptions.tsx b/packages/checkout/widgets-lib/src/components/RouteOptionsDrawer/RouteOptions.tsx index 6907a8d28c..35214566ff 100644 --- a/packages/checkout/widgets-lib/src/components/RouteOptionsDrawer/RouteOptions.tsx +++ b/packages/checkout/widgets-lib/src/components/RouteOptionsDrawer/RouteOptions.tsx @@ -27,7 +27,6 @@ export interface OptionsProps { insufficientBalance?: boolean; selectedIndex: number; displayPriceDetails?: boolean; - displayInsufficientGasWarning?: boolean; } export function RouteOptions({ @@ -41,7 +40,6 @@ export function RouteOptions({ insufficientBalance, selectedIndex, displayPriceDetails, - displayInsufficientGasWarning, }: OptionsProps) { const { t } = useTranslation(); @@ -99,7 +97,6 @@ export function RouteOptions({ selected={index === selectedIndex} rc={} displayPriceDetails={displayPriceDetails} - displayInsufficientGasWarning={displayInsufficientGasWarning} /> ))} {noRoutes && ( diff --git a/packages/checkout/widgets-lib/src/components/RouteOptionsDrawer/RouteOptionsDrawer.tsx b/packages/checkout/widgets-lib/src/components/RouteOptionsDrawer/RouteOptionsDrawer.tsx index 7845fb8f79..3b209b05b7 100644 --- a/packages/checkout/widgets-lib/src/components/RouteOptionsDrawer/RouteOptionsDrawer.tsx +++ b/packages/checkout/widgets-lib/src/components/RouteOptionsDrawer/RouteOptionsDrawer.tsx @@ -26,7 +26,6 @@ type OptionsDrawerProps = { showBridgeOption?: boolean; insufficientBalance?: boolean; displayPriceDetails?: boolean; - displayInsufficientGasWarning?: boolean; }; export function RouteOptionsDrawer({ @@ -43,7 +42,6 @@ export function RouteOptionsDrawer({ showBridgeOption, insufficientBalance, displayPriceDetails, - displayInsufficientGasWarning, }: OptionsDrawerProps) { const { t } = useTranslation(); const { track } = useAnalytics(); @@ -131,7 +129,6 @@ export function RouteOptionsDrawer({ insufficientBalance={insufficientBalance} selectedIndex={selectedRouteIndex.current} displayPriceDetails={displayPriceDetails} - displayInsufficientGasWarning={displayInsufficientGasWarning} /> diff --git a/packages/checkout/widgets-lib/src/components/SelectedRouteOption/SelectedRouteOption.tsx b/packages/checkout/widgets-lib/src/components/SelectedRouteOption/SelectedRouteOption.tsx index 4fe8753462..095bb58d30 100644 --- a/packages/checkout/widgets-lib/src/components/SelectedRouteOption/SelectedRouteOption.tsx +++ b/packages/checkout/widgets-lib/src/components/SelectedRouteOption/SelectedRouteOption.tsx @@ -31,7 +31,6 @@ export interface SelectedRouteOptionProps { insufficientBalance?: boolean; showOnrampOption?: boolean; displayPriceDetails?: boolean; - displayInsufficientGasWarning?: boolean; } function SelectedRouteOptionContainer({ @@ -72,9 +71,9 @@ export function SelectedRouteOption({ insufficientBalance = false, showOnrampOption = false, displayPriceDetails = true, - displayInsufficientGasWarning = true, onClick, }: SelectedRouteOptionProps) { + console.log('SelectedRouteOption', routeData); const { t } = useTranslation(); const { fromToken } = routeData?.amountData ?? {}; @@ -188,14 +187,27 @@ export function SelectedRouteOption({ {`${t('views.ADD_TOKENS.fees.balance')} ${t( 'views.ADD_TOKENS.fees.fiatPricePrefix', )} $${routeBalanceUsd}`} - {displayInsufficientGasWarning && routeData?.isInsufficientGas && ( + { displayPriceDetails && routeData?.isInsufficientGas && ( + <> +
+ + {t('views.ADD_TOKENS.noGasRouteMessage', { + token: + routeData.route.route.estimate.gasCosts[0].token.symbol, + })} + + + )} + + { displayPriceDetails && routeData?.isInsufficientBalance && ( <>
- {t('views.ADD_TOKENS.noGasRouteMessage', { - token: - routeData.route.route.estimate.gasCosts[0].token.symbol, - })} + {/* {t('views.ADD_TOKENS.noBalanceRouteMessage', { + token: fromToken.symbol, + })} */} + {' '} + Insufficient balance )} 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 e43980a8af..020aeb7d2d 100644 --- a/packages/checkout/widgets-lib/src/lib/squid/functions/sortRoutesByFastestTime.ts +++ b/packages/checkout/widgets-lib/src/lib/squid/functions/sortRoutesByFastestTime.ts @@ -4,15 +4,25 @@ 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; + // 1.: Prioritise routes where both isInsufficientGas and isInsufficientBalance are false + if (a.isInsufficientGas !== b.isInsufficientGas || a.isInsufficientBalance !== b.isInsufficientBalance) { + if (!a.isInsufficientGas && !a.isInsufficientBalance) return -1; + if (!b.isInsufficientGas && !b.isInsufficientBalance) return 1; + if (a.isInsufficientGas && !a.isInsufficientBalance) return -1; // isInsufficientGas = true has higher priority + if (b.isInsufficientGas && !b.isInsufficientBalance) return 1; } - // Sort by estimatedRouteDuration if isInsufficientGas is the same + // 2.: Sort by estimatedRouteDuration const timeA = a.route.route.estimate.estimatedRouteDuration; const timeB = b.route.route.estimate.estimatedRouteDuration; - return timeA - timeB; + if (timeA !== timeB) return timeA - timeB; + + // 3.: Place isInsufficientGas before isInsufficientBalance + if (a.isInsufficientGas !== b.isInsufficientGas) { + return a.isInsufficientGas ? -1 : 1; + } + + return 0; }); }; 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 068edbbe1d..a5f26d772b 100644 --- a/packages/checkout/widgets-lib/src/lib/squid/hooks/useRoutes.ts +++ b/packages/checkout/widgets-lib/src/lib/squid/hooks/useRoutes.ts @@ -16,9 +16,37 @@ import { import { SquidPostHook } from '../../primary-sales'; import { SQUID_NATIVE_TOKEN } from '../config'; -const BASE_SLIPPAGE = 0.02; const MIN_BALANCE_FOR_ROUTES = 1; +const BASE_SLIPPAGE_HIGH_TIER = 0.005; +const BASE_SLIPPAGE_MEDIUM_TIER = 0.01; +const BASE_SLIPPAGE_LOW_TIER = 0.015; + +const SLIPPAGE_TIERS = { + high: { + threshold: 999, + value: BASE_SLIPPAGE_HIGH_TIER, + }, + medium: { + threshold: 99, + value: BASE_SLIPPAGE_MEDIUM_TIER, + }, + low: { + threshold: 0, + value: BASE_SLIPPAGE_LOW_TIER, + }, +} as const; + +const getSlippageTier = (usdAmount: number): number => { + if (usdAmount >= SLIPPAGE_TIERS.high.threshold) { + return SLIPPAGE_TIERS.high.value; + } + if (usdAmount >= SLIPPAGE_TIERS.medium.threshold) { + return SLIPPAGE_TIERS.medium.value; + } + return SLIPPAGE_TIERS.low.value; +}; + export const useRoutes = () => { const latestRequestIdRef = useRef(0); @@ -51,7 +79,7 @@ export const useRoutes = () => { chainId: string, ): Token | undefined => tokens.find( (value) => value.address.toLowerCase() === address.toLowerCase() - && value.chainId === chainId, + && value.chainId === chainId, ); const calculateFromAmount = ( @@ -66,7 +94,7 @@ export const useRoutes = () => { // Calculate the amount of fromToken needed to match this USD value const baseFromAmount = toAmountInUsd / fromToken.usdPrice; // Add a buffer for price fluctuations and fees - const fromAmountWithBuffer = baseFromAmount * (1 + BASE_SLIPPAGE + additionalBuffer); + const fromAmountWithBuffer = baseFromAmount * (1 + getSlippageTier(toAmountInUsd) + additionalBuffer); return fromAmountWithBuffer.toString(); }; @@ -74,9 +102,11 @@ export const useRoutes = () => { const calculateFromAmountFromRoute = ( exchangeRate: string, toAmount: string, + toAmountUSD?: string, ) => { + const toAmountUSDNumber = toAmountUSD ? parseFloat(toAmountUSD) : 0; const fromAmount = parseFloat(toAmount) / parseFloat(exchangeRate); - const fromAmountWithBuffer = fromAmount * (1 + BASE_SLIPPAGE); + const fromAmountWithBuffer = fromAmount * (1 + getSlippageTier(toAmountUSDNumber)); return fromAmountWithBuffer.toString(); }; @@ -111,10 +141,12 @@ export const useRoutes = () => { toAmount, balance, additionalBuffer, + isInsufficientBalance: false, + isInsufficientGas: false, }; }; - const getSufficientFromAmounts = ( + const getFromAmounts = ( tokens: Token[], balances: TokenBalance[], toChainId: string, @@ -124,24 +156,25 @@ export const useRoutes = () => { const filteredBalances = balances.filter( (balance) => !( balance.address.toLowerCase() === toTokenAddress.toLowerCase() - && balance.chainId === toChainId + && balance.chainId === toChainId ), ); - const amountDataArray: AmountData[] = filteredBalances - .map((balance) => getAmountData(tokens, balance, toAmount, toChainId, toTokenAddress)) - .filter((value) => value !== undefined); + return filteredBalances + .map((balance) => { + const amountData = getAmountData(tokens, balance, toAmount, toChainId, toTokenAddress); + if (!amountData) return null; - return amountDataArray.filter((data: AmountData) => { - const formattedBalance = utils.formatUnits( - data.balance.balance, - data.balance.decimals, - ); + const formattedBalance = parseFloat( + utils.formatUnits(balance.balance, balance.decimals), + ); - return ( - parseFloat(formattedBalance.toString()) > parseFloat(data.fromAmount) - ); - }); + return { + ...amountData, + isInsufficientBalance: formattedBalance < parseFloat(amountData.fromAmount), + }; + }) + .filter((data) => data !== null); }; const convertToFormattedAmount = (amount: string, decimals: number) => { @@ -208,7 +241,6 @@ export const useRoutes = () => { if (!routeResponse?.route?.estimate?.toAmount || !routeResponse?.route?.estimate?.toToken?.decimals) { throw new Error('Invalid route response or token decimals'); } - const toAmountInBaseUnits = utils.parseUnits(toAmount, routeResponse?.route.estimate.toToken.decimals); const routeToAmountInBaseUnits = BigNumber.from(routeResponse.route.estimate.toAmount); return routeToAmountInBaseUnits.gt(toAmountInBaseUnits); @@ -248,6 +280,7 @@ export const useRoutes = () => { const newFromAmount = calculateFromAmountFromRoute( routeResponse.route.estimate.exchangeRate, toAmount, + routeResponse.route.estimate.toAmountUSD, ); const newRoute = await getRouteWithRetry( @@ -375,16 +408,20 @@ export const useRoutes = () => { const feeCost = getTotalFees(routeResponse, data.balance.chainId); const userGasBalance = findUserGasBalance(data.balance.chainId); - return { - amountData: data, - route: routeResponse.route, - isInsufficientGas: !hasSufficientNativeTokenBalance( + const isInsufficientGas = !data.isInsufficientBalance + && !hasSufficientNativeTokenBalance( userGasBalance, data.fromAmount, data.fromToken, gasCost, feeCost, - ), + ); + + return { + amountData: data, + route: routeResponse.route, + isInsufficientGas, + isInsufficientBalance: data.isInsufficientBalance, } as RouteData; } catch (error) { return null; @@ -411,7 +448,7 @@ export const useRoutes = () => { ): Promise => { const currentRequestId = ++latestRequestIdRef.current; - let fromAmountDataArray = getSufficientFromAmounts( + let fromAmountDataArray = getFromAmounts( tokens, balances, toChanId, @@ -479,7 +516,7 @@ export const useRoutes = () => { let fromAmountDataArray = balances .filter((balance) => !( balance.address.toLowerCase() === toTokenAddress.toLowerCase() - && balance.chainId.toString() === toChainId + && balance.chainId.toString() === toChainId )) .map((balance) => { const fromToken = findToken(tokens, balance.address, balance.chainId.toString()); diff --git a/packages/checkout/widgets-lib/src/lib/squid/types.ts b/packages/checkout/widgets-lib/src/lib/squid/types.ts index dd1ae23150..210f2b4947 100644 --- a/packages/checkout/widgets-lib/src/lib/squid/types.ts +++ b/packages/checkout/widgets-lib/src/lib/squid/types.ts @@ -33,12 +33,15 @@ export type AmountData = { toAmount: string; balance: TokenBalance; additionalBuffer: number; + isInsufficientGas: boolean; + isInsufficientBalance: boolean; }; export type RouteData = { amountData: AmountData; route: RouteResponse; isInsufficientGas: boolean; + isInsufficientBalance: boolean; }; export type RouteResponseData = { diff --git a/packages/checkout/widgets-lib/src/widgets/add-tokens/AddTokensRoot.tsx b/packages/checkout/widgets-lib/src/widgets/add-tokens/AddTokensRoot.tsx index 782d1e79fa..7290d0d000 100644 --- a/packages/checkout/widgets-lib/src/widgets/add-tokens/AddTokensRoot.tsx +++ b/packages/checkout/widgets-lib/src/widgets/add-tokens/AddTokensRoot.tsx @@ -70,37 +70,35 @@ export class AddTokens extends Base { const { t } = i18n; this.reactRoot.render( - - - - - - + + + + + } - > - - - - - - - , + > + + + + + + , ); } } 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 c764f8966e..a8bf5d1480 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 @@ -203,6 +203,7 @@ export function AddTokens({ (action) => action.type === ActionType.SWAP, ), isInsufficientGas: route.isInsufficientGas, + isInsufficientBalance: route.isInsufficientBalance, }, }); } @@ -414,13 +415,14 @@ export function AddTokens({ preloadRoutes(); }, [balances, squid, selectedToken]); - const matchingRoute = useMemo( - () => routes.find( - (route) => route.amountData.fromToken.address === selectedRouteData?.amountData.fromToken.address - && route.amountData.fromToken.chainId === selectedRouteData?.amountData.fromToken.chainId, - ), - [routes, selectedRouteData], - ); + const matchingRoute = useMemo(() => { + if (!selectedRouteData || routes.length === 0) return undefined; + + return routes.find( + (route) => route.amountData.fromToken.address === selectedRouteData.amountData.fromToken.address + && route.amountData.fromToken.chainId === selectedRouteData.amountData.fromToken.chainId, + ); + }, [routes, selectedRouteData]); useEffect(() => { if (routes.length === 0) return; @@ -601,11 +603,6 @@ export function AddTokens({ [selectedToken, fromAddress, selectedAmount], ); - const shouldDisplayInsufficientGasWarning = useMemo( - () => !!(selectedRouteData?.isInsufficientGas && shouldDisplayRoutePriceDetails && matchingRoute), - [selectedRouteData, shouldDisplayRoutePriceDetails, matchingRoute], - ); - useEffect(() => { if (inputRef.current && !showInitialEmptyState) { inputRef.current.focus(); @@ -623,6 +620,7 @@ export function AddTokens({ && !!toAddress && !!selectedRouteData && !selectedRouteData.isInsufficientGas + && !insufficientBalance && !loading; const handleWalletConnected = ( @@ -676,12 +674,12 @@ export function AddTokens({ }, [id, experiments]); useEffect(() => { - if (shouldDisplayInsufficientGasWarning) { + if (selectedRouteData?.isInsufficientGas && shouldDisplayRoutePriceDetails) { setShowNotEnoughGasDrawer(true); } else { setShowNotEnoughGasDrawer(false); } - }, [shouldDisplayInsufficientGasWarning]); + }, [selectedRouteData]); const handleToolkitClick = () => { setShowNotEnoughGasDrawer(false); @@ -846,7 +844,6 @@ export function AddTokens({ insufficientBalance={insufficientBalance} showOnrampOption={shouldShowOnRampOption} displayPriceDetails={shouldDisplayRoutePriceDetails} - displayInsufficientGasWarning={shouldDisplayInsufficientGasWarning} /> )} @@ -918,7 +915,6 @@ export function AddTokens({ onRouteClick={handleRouteClick} insufficientBalance={insufficientBalance} displayPriceDetails={shouldDisplayRoutePriceDetails} - displayInsufficientGasWarning={shouldDisplayInsufficientGasWarning} />