diff --git a/apps/api/src/api/services/quote/core/squidrouter.ts b/apps/api/src/api/services/quote/core/squidrouter.ts index 480a0e81b..e6864782a 100644 --- a/apps/api/src/api/services/quote/core/squidrouter.ts +++ b/apps/api/src/api/services/quote/core/squidrouter.ts @@ -239,8 +239,18 @@ export async function calculateEvmBridgeAndNetworkFee(request: EvmBridgeRequest) outputTokenDecimals }; } catch (error) { - logger.error(`Error calculating EVM bridge and network fee: ${error instanceof Error ? error.message : String(error)}`); - // We assume that the error is due to a low input amount + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error(`Error calculating EVM bridge and network fee: ${errorMessage}`); + + // Check for specific SquidRouter error types + if (errorMessage.toLowerCase().includes("low liquidity") || errorMessage.toLowerCase().includes("reduce swap amount")) { + throw new APIError({ + message: QuoteError.LowLiquidity, + status: httpStatus.BAD_REQUEST + }); + } + + // Default to generic error for other cases throw new APIError({ message: QuoteError.InputAmountTooLow, status: httpStatus.BAD_REQUEST diff --git a/apps/frontend/src/components/AssetNumericInput/index.tsx b/apps/frontend/src/components/AssetNumericInput/index.tsx index f827d0bef..8a0dd1a4b 100644 --- a/apps/frontend/src/components/AssetNumericInput/index.tsx +++ b/apps/frontend/src/components/AssetNumericInput/index.tsx @@ -1,4 +1,4 @@ -import { EvmToken } from "@vortexfi/shared"; +import { EvmToken, Networks } from "@vortexfi/shared"; import type { ChangeEvent, FC } from "react"; import type { UseFormRegisterReturn } from "react-hook-form"; import { cn } from "../../helpers/cn"; @@ -24,6 +24,7 @@ interface AssetNumericInputProps { fallbackLogoURI?: string; registerInput: UseFormRegisterReturn; id: string; + network?: Networks; } export const AssetNumericInput: FC = ({ @@ -45,6 +46,7 @@ export const AssetNumericInput: FC = ({ assetIcon={assetIcon} fallbackLogoURI={rest.fallbackLogoURI} logoURI={rest.logoURI} + network={rest.network} onClick={onClick} tokenSymbol={tokenSymbol} /> diff --git a/apps/frontend/src/components/CurrencyExchange/index.tsx b/apps/frontend/src/components/CurrencyExchange/index.tsx index 43c6c1ebf..37ba70cf5 100644 --- a/apps/frontend/src/components/CurrencyExchange/index.tsx +++ b/apps/frontend/src/components/CurrencyExchange/index.tsx @@ -1,5 +1,7 @@ +import { Networks } from "@vortexfi/shared"; import Arrow from "../../assets/arrow.svg"; -import { useGetAssetIcon } from "../../hooks/useGetAssetIcon"; +import { useTokenIcon } from "../../hooks/useTokenIcon"; +import { TokenIconWithNetwork } from "../TokenIconWithNetwork"; interface CurrencyExchangeProps { inputAmount: string; @@ -10,6 +12,12 @@ interface CurrencyExchangeProps { layout?: "horizontal" | "vertical"; className?: string; showApproximation?: boolean; + inputNetwork?: Networks; + outputNetwork?: Networks; + inputIcon?: string; + outputIcon?: string; + inputFallbackIcon?: string; + outputFallbackIcon?: string; } export const CurrencyExchange = ({ @@ -20,10 +28,20 @@ export const CurrencyExchange = ({ showIcons = false, layout = "horizontal", className = "", - showApproximation = false + showApproximation = false, + inputNetwork, + outputNetwork, + inputIcon: inputIconProp, + outputIcon: outputIconProp, + inputFallbackIcon, + outputFallbackIcon }: CurrencyExchangeProps) => { - const inputIcon = useGetAssetIcon(inputCurrency.toLowerCase()); - const outputIcon = useGetAssetIcon(outputCurrency.toLowerCase()); + // Use useTokenIcon for fallback icons when explicit icon props aren't provided + const inputIconFallback = useTokenIcon(inputCurrency, inputNetwork); + const outputIconFallback = useTokenIcon(outputCurrency, outputNetwork); + + const inputIcon = inputIconProp ?? inputIconFallback.iconSrc; + const outputIcon = outputIconProp ?? outputIconFallback.iconSrc; if (layout === "vertical") { return ( @@ -31,14 +49,32 @@ export const CurrencyExchange = ({
You send
- {showIcons && {inputCurrency}} + {showIcons && ( + + )} {inputAmount} {inputCurrency.toUpperCase()}
You get
- {showIcons && {outputCurrency}} + {showIcons && ( + + )} {showApproximation && "~ "} {outputAmount} {outputCurrency.toUpperCase()}
diff --git a/apps/frontend/src/components/FiatIcon/index.tsx b/apps/frontend/src/components/FiatIcon/index.tsx index 8891b1187..ed379e8bc 100644 --- a/apps/frontend/src/components/FiatIcon/index.tsx +++ b/apps/frontend/src/components/FiatIcon/index.tsx @@ -1,13 +1,13 @@ import { FiatTokenDetails } from "@vortexfi/shared"; import { FC, HTMLAttributes } from "react"; -import { useGetAssetIcon } from "../../hooks/useGetAssetIcon"; +import { useTokenIcon } from "../../hooks/useTokenIcon"; interface Props extends HTMLAttributes { fiat: FiatTokenDetails; } export const FiatIcon: FC = ({ fiat, ...props }) => { - const iconSrc = useGetAssetIcon(fiat.assetSymbol.toLowerCase()); + const { iconSrc } = useTokenIcon(fiat); if (iconSrc) return {fiat.fiat.name}; diff --git a/apps/frontend/src/components/ListItem/index.tsx b/apps/frontend/src/components/ListItem/index.tsx index 525326fdb..57b913178 100644 --- a/apps/frontend/src/components/ListItem/index.tsx +++ b/apps/frontend/src/components/ListItem/index.tsx @@ -3,7 +3,7 @@ import { FiatToken, isFiatToken, OnChainToken, OnChainTokenDetails } from "@vort import { memo } from "react"; import { useTranslation } from "react-i18next"; import { getTokenDisabledReason, isFiatTokenDisabled } from "../../config/tokenAvailability"; -import { useGetAssetIcon } from "../../hooks/useGetAssetIcon"; +import { useTokenIcon } from "../../hooks/useTokenIcon"; import { TokenIconWithNetwork } from "../TokenIconWithNetwork"; import { ExtendedTokenDefinition } from "../TokenSelection/TokenSelectionList/hooks/useTokenSelection"; import { UserBalance } from "../UserBalance"; @@ -16,9 +16,10 @@ interface ListItemProps { export const ListItem = memo(function ListItem({ token, isSelected, onSelect }: ListItemProps) { const { t } = useTranslation(); - const fiatIcon = useGetAssetIcon(token.assetIcon); - const tokenIcon = token.logoURI ?? fiatIcon; const isFiat = isFiatToken(token.type); + // Use assetIcon for fiat lookup, with network for on-chain tokens + const iconInfo = useTokenIcon(token.assetIcon, isFiat ? undefined : token.network); + const tokenIcon = token.logoURI ?? iconInfo.iconSrc; const isDisabled = isFiat && isFiatTokenDisabled(token.type as FiatToken); const disabledReason = isFiat && isDisabled ? t(getTokenDisabledReason(token.type as FiatToken)) : undefined; diff --git a/apps/frontend/src/components/QuoteSummary/index.tsx b/apps/frontend/src/components/QuoteSummary/index.tsx index aaa0ae41a..478539b3b 100644 --- a/apps/frontend/src/components/QuoteSummary/index.tsx +++ b/apps/frontend/src/components/QuoteSummary/index.tsx @@ -1,19 +1,36 @@ -import { QuoteResponse } from "@vortexfi/shared"; +import { QuoteResponse, RampDirection } from "@vortexfi/shared"; import { useRef } from "react"; import { useTranslation } from "react-i18next"; -import { useGetAssetIcon } from "../../hooks/useGetAssetIcon"; +import { useTokenIcon } from "../../hooks/useTokenIcon"; import { CollapsibleCard, CollapsibleDetails, CollapsibleSummary, useCollapsibleCard } from "../CollapsibleCard"; import { CurrencyExchange } from "../CurrencyExchange"; import { ToggleButton } from "../ToggleButton"; +import { TokenIconWithNetwork } from "../TokenIconWithNetwork"; import { TransactionId } from "../TransactionId"; interface QuoteSummaryProps { quote: QuoteResponse; } +/** + * Hook to get token icons for both currencies in a quote. + * Determines which currency is on-chain based on ramp type. + */ +function useQuoteTokenIcons(quote: QuoteResponse) { + const isOfframp = quote.rampType === RampDirection.SELL; + + // For offramp: input is on-chain (has network), output is fiat (no network) + // For onramp: input is fiat (no network), output is on-chain (has network) + const inputIcon = useTokenIcon(quote.inputCurrency, isOfframp ? quote.network : undefined); + const outputIcon = useTokenIcon(quote.outputCurrency, !isOfframp ? quote.network : undefined); + + return { inputIcon, outputIcon }; +} + const QuoteSummaryCore = ({ quote }: { quote: QuoteResponse }) => { const { t } = useTranslation(); const { toggle, isExpanded, detailsId } = useCollapsibleCard(); + const { inputIcon, outputIcon } = useQuoteTokenIcons(quote); return ( <> @@ -24,8 +41,14 @@ const QuoteSummaryCore = ({ quote }: { quote: QuoteResponse }) => { { const QuoteSummaryDetails = ({ quote }: { quote: QuoteResponse }) => { const { t } = useTranslation(); - const inputIcon = useGetAssetIcon(quote.inputCurrency.toLowerCase()); - const outputIcon = useGetAssetIcon(quote.outputCurrency.toLowerCase()); + const { inputIcon, outputIcon } = useQuoteTokenIcons(quote); return (
@@ -53,15 +75,29 @@ const QuoteSummaryDetails = ({ quote }: { quote: QuoteResponse }) => {
{t("components.quoteSummary.youSend")}
- {quote.inputCurrency} + {quote.inputAmount} {quote.inputCurrency.toUpperCase()}
{t("components.quoteSummary.youReceive")}
- {quote.outputCurrency}~ {quote.outputAmount}{" "} - {quote.outputCurrency.toUpperCase()} + + ~ {quote.outputAmount} {quote.outputCurrency.toUpperCase()}
diff --git a/apps/frontend/src/components/Ramp/Offramp/index.tsx b/apps/frontend/src/components/Ramp/Offramp/index.tsx index c89325b17..b76a383d5 100644 --- a/apps/frontend/src/components/Ramp/Offramp/index.tsx +++ b/apps/frontend/src/components/Ramp/Offramp/index.tsx @@ -5,11 +5,11 @@ import { FormProvider } from "react-hook-form"; import { useTranslation } from "react-i18next"; import { useEventsContext } from "../../../contexts/events"; import { useNetwork } from "../../../contexts/network"; -import { getTokenLogoURIs } from "../../../helpers/tokenHelpers"; import { useQuoteForm } from "../../../hooks/quote/useQuoteForm"; import { useQuoteService } from "../../../hooks/quote/useQuoteService"; import { useRampSubmission } from "../../../hooks/ramp/useRampSubmission"; import { useRampValidation } from "../../../hooks/ramp/useRampValidation"; +import { useTokenIcon } from "../../../hooks/useTokenIcon"; import { useVortexAccount } from "../../../hooks/useVortexAccount"; import { getEvmTokenConfig } from "../../../services/tokens"; import { useFeeComparisonStore } from "../../../stores/feeComparison"; @@ -75,16 +75,17 @@ export const Offramp = () => { const handleBalanceClick = useCallback((amount: string) => form.setValue("inputAmount", amount), [form]); - const { logoURI, fallbackLogoURI } = getTokenLogoURIs(fromToken); + const fromIconInfo = useTokenIcon(fromToken); const WithdrawNumericInput = useMemo( () => ( <> openTokenSelectModal("from")} registerInput={form.register("inputAmount")} @@ -96,7 +97,7 @@ export const Offramp = () => { ), - [form, fromToken, openTokenSelectModal, handleInputChange, handleBalanceClick, isDisconnected, logoURI, fallbackLogoURI] + [form, fromToken, openTokenSelectModal, handleInputChange, handleBalanceClick, isDisconnected, fromIconInfo] ); const ReceiveNumericInput = useMemo( diff --git a/apps/frontend/src/components/Ramp/Onramp/index.tsx b/apps/frontend/src/components/Ramp/Onramp/index.tsx index 10b7f98da..1453685a9 100644 --- a/apps/frontend/src/components/Ramp/Onramp/index.tsx +++ b/apps/frontend/src/components/Ramp/Onramp/index.tsx @@ -5,11 +5,11 @@ import { FormProvider } from "react-hook-form"; import { useTranslation } from "react-i18next"; import { useEventsContext } from "../../../contexts/events"; import { useNetwork } from "../../../contexts/network"; -import { getTokenLogoURIs } from "../../../helpers/tokenHelpers"; import { useQuoteForm } from "../../../hooks/quote/useQuoteForm"; import { useQuoteService } from "../../../hooks/quote/useQuoteService"; import { useRampSubmission } from "../../../hooks/ramp/useRampSubmission"; import { useRampValidation } from "../../../hooks/ramp/useRampValidation"; +import { useTokenIcon } from "../../../hooks/useTokenIcon"; import { getEvmTokenConfig } from "../../../services/tokens"; import { useFeeComparisonStore } from "../../../stores/feeComparison"; import { useFiatToken, useInputAmount, useOnChainToken } from "../../../stores/quote/useQuoteFormStore"; @@ -84,33 +84,25 @@ export const Onramp = () => { [form, fromToken, openTokenSelectModal, handleInputChange] ); - const { logoURI, fallbackLogoURI } = getTokenLogoURIs(toToken); + const toIconInfo = useTokenIcon(toToken); const ReceiveNumericInput = useMemo( () => ( openTokenSelectModal("to")} readOnly={true} registerInput={form.register("outputAmount")} tokenSymbol={toToken.assetSymbol} /> ), - [ - toToken.networkAssetIcon, - toToken.assetSymbol, - form, - quoteLoading, - toAmount, - openTokenSelectModal, - logoURI, - fallbackLogoURI - ] + [toToken.networkAssetIcon, toToken.assetSymbol, form, quoteLoading, toAmount, openTokenSelectModal, toIconInfo] ); const handleConfirm = useCallback(() => { diff --git a/apps/frontend/src/components/TokenImage/index.tsx b/apps/frontend/src/components/TokenIcon/index.tsx similarity index 86% rename from apps/frontend/src/components/TokenImage/index.tsx rename to apps/frontend/src/components/TokenIcon/index.tsx index 405e54dcb..e21211417 100644 --- a/apps/frontend/src/components/TokenImage/index.tsx +++ b/apps/frontend/src/components/TokenIcon/index.tsx @@ -1,15 +1,15 @@ -import { FC, memo, useEffect, useState } from "react"; +import { FC, memo, useState } from "react"; import placeholderIcon from "../../assets/coins/placeholder.svg"; import { cn } from "../../helpers/cn"; -interface TokenImageProps { +interface TokenIconProps { src: string; fallbackSrc?: string; alt: string; className?: string; } -export const TokenImage: FC = memo(function TokenImage({ src, fallbackSrc, alt, className }) { +export const TokenIcon: FC = memo(function TokenIcon({ src, fallbackSrc, alt, className }) { const [isLoading, setIsLoading] = useState(true); const [imgError, setImgError] = useState(false); const [fallbackError, setFallbackError] = useState(false); diff --git a/apps/frontend/src/components/TokenIconWithNetwork/index.tsx b/apps/frontend/src/components/TokenIconWithNetwork/index.tsx index a1a98572f..8e77602e8 100644 --- a/apps/frontend/src/components/TokenIconWithNetwork/index.tsx +++ b/apps/frontend/src/components/TokenIconWithNetwork/index.tsx @@ -2,7 +2,7 @@ import { Networks } from "@vortexfi/shared"; import { FC, memo } from "react"; import { cn } from "../../helpers/cn"; import { NETWORK_ICONS } from "../../hooks/useGetNetworkIcon"; -import { TokenImage } from "../TokenImage"; +import { TokenIcon } from "../TokenIcon"; interface TokenIconWithNetworkProps { iconSrc: string; @@ -26,11 +26,11 @@ export const TokenIconWithNetwork: FC = memo(function return (
- + {shouldShowOverlay && ( {`${network} void; disabled?: boolean; + network?: Networks; } -export function AssetButton({ assetIcon, tokenSymbol, onClick, disabled, logoURI, fallbackLogoURI }: AssetButtonProps) { - const localIcon = useGetAssetIcon(assetIcon); - const primaryIcon = logoURI ?? localIcon; +export function AssetButton({ + assetIcon, + tokenSymbol, + onClick, + disabled, + logoURI, + fallbackLogoURI, + network +}: AssetButtonProps) { + const fallbackIcon = useTokenIcon(assetIcon); + const primaryIcon = logoURI ?? fallbackIcon.iconSrc; return ( diff --git a/apps/frontend/src/components/menus/HistoryMenu/TransactionItem/index.tsx b/apps/frontend/src/components/menus/HistoryMenu/TransactionItem/index.tsx index 606c90db7..dac497265 100644 --- a/apps/frontend/src/components/menus/HistoryMenu/TransactionItem/index.tsx +++ b/apps/frontend/src/components/menus/HistoryMenu/TransactionItem/index.tsx @@ -1,10 +1,17 @@ import { ChevronRightIcon } from "@heroicons/react/20/solid"; -import { getNetworkDisplayName, Networks, roundDownToSignificantDecimals } from "@vortexfi/shared"; +import { + EPaymentMethod, + getNetworkDisplayName, + Networks, + PaymentMethod, + roundDownToSignificantDecimals +} from "@vortexfi/shared"; import Big from "big.js"; import { FC, useState } from "react"; -import { useGetAssetIcon } from "../../../../hooks/useGetAssetIcon"; +import { useTokenIcon } from "../../../../hooks/useTokenIcon"; import { StatusBadge } from "../../../StatusBadge"; -import { Transaction } from "../types"; +import { TokenIconWithNetwork } from "../../../TokenIconWithNetwork"; +import { Transaction, TransactionDestination } from "../types"; interface TransactionItemProps { transaction: Transaction; @@ -27,17 +34,28 @@ const formatTooltipDate = (date: Date) => year: "numeric" }); -const getNetworkName = (network: Transaction["from"] | Transaction["to"]) => { - if (typeof network === "string" && ["pix", "sepa", "cbu"].includes(network)) { +const PAYMENT_METHODS: PaymentMethod[] = [EPaymentMethod.PIX, EPaymentMethod.SEPA, EPaymentMethod.CBU]; + +function isNetwork(destination: TransactionDestination): destination is Networks { + return !PAYMENT_METHODS.includes(destination as PaymentMethod); +} + +const getNetworkName = (network: TransactionDestination) => { + if (!isNetwork(network)) { return network.toUpperCase(); } - return getNetworkDisplayName(network as Networks); + return getNetworkDisplayName(network); }; export const TransactionItem: FC = ({ transaction }) => { const [isHovered, setIsHovered] = useState(false); - const fromIcon = useGetAssetIcon(transaction.fromCurrency.toLowerCase()); - const toIcon = useGetAssetIcon(transaction.toCurrency.toLowerCase()); + + // Determine network for each currency (only on-chain tokens have networks) + const fromNetwork = isNetwork(transaction.from) ? transaction.from : undefined; + const toNetwork = isNetwork(transaction.to) ? transaction.to : undefined; + + const fromIcon = useTokenIcon(transaction.fromCurrency, fromNetwork); + const toIcon = useTokenIcon(transaction.toCurrency, toNetwork); return (
= ({ transaction }) => {
- {transaction.fromCurrency} - {transaction.toCurrency} + +
diff --git a/apps/frontend/src/components/widget-steps/SummaryStep/AssetDisplay.tsx b/apps/frontend/src/components/widget-steps/SummaryStep/AssetDisplay.tsx index 8c85b7636..8c80ce5af 100644 --- a/apps/frontend/src/components/widget-steps/SummaryStep/AssetDisplay.tsx +++ b/apps/frontend/src/components/widget-steps/SummaryStep/AssetDisplay.tsx @@ -1,19 +1,26 @@ +import { Networks } from "@vortexfi/shared"; import { FC } from "react"; -import { TokenImage } from "../../TokenImage"; +import { TokenIconWithNetwork } from "../../TokenIconWithNetwork"; interface AssetDisplayProps { amount: string; symbol: string; iconSrc: string; - iconAlt: string; fallbackIconSrc?: string; + network?: Networks; } -export const AssetDisplay: FC = ({ amount, symbol, iconSrc, iconAlt, fallbackIconSrc }) => ( +export const AssetDisplay: FC = ({ amount, symbol, iconSrc, fallbackIconSrc, network }) => (
{amount} {symbol} - +
); diff --git a/apps/frontend/src/components/widget-steps/SummaryStep/TransactionTokensDisplay.tsx b/apps/frontend/src/components/widget-steps/SummaryStep/TransactionTokensDisplay.tsx index 35274a767..e1c3c2552 100644 --- a/apps/frontend/src/components/widget-steps/SummaryStep/TransactionTokensDisplay.tsx +++ b/apps/frontend/src/components/widget-steps/SummaryStep/TransactionTokensDisplay.tsx @@ -1,15 +1,11 @@ import { ArrowDownIcon } from "@heroicons/react/20/solid"; import { - AssetHubTokenDetails, BaseFiatTokenDetails, - EvmTokenDetails, FiatToken, FiatTokenDetails, getAddressForFormat, getAnyFiatTokenDetails, getOnChainTokenDetailsOrDefault, - isAssetHubTokenDetails, - isEvmTokenDetails, isStellarOutputTokenDetails, OnChainTokenDetails, RampDirection @@ -22,7 +18,7 @@ import { useNetwork } from "../../../contexts/network"; import { useAssetHubNode } from "../../../contexts/polkadotNode"; import { useRampActor } from "../../../contexts/rampState"; import { trimAddress } from "../../../helpers/addressFormatter"; -import { useGetAssetIcon } from "../../../hooks/useGetAssetIcon"; +import { useTokenIcon } from "../../../hooks/useTokenIcon"; import { useVortexAccount } from "../../../hooks/useVortexAccount"; import { RampExecutionInput } from "../../../types/phases"; import { AssetDisplay } from "./AssetDisplay"; @@ -38,16 +34,6 @@ interface TransactionTokensDisplayProps { rampDirection: RampDirection; } -function getOnChainTokenIcon(tokenDetails: OnChainTokenDetails): { primary?: string; fallback?: string } { - if (isEvmTokenDetails(tokenDetails)) { - return { fallback: tokenDetails.fallbackLogoURI, primary: tokenDetails.logoURI }; - } - if (isAssetHubTokenDetails(tokenDetails)) { - return { primary: tokenDetails.logoURI }; - } - return {}; -} - export const TransactionTokensDisplay: FC = ({ executionInput, isOnramp, rampDirection }) => { const { t } = useTranslation(); const rampActor = useRampActor(); @@ -113,16 +99,8 @@ export const TransactionTokensDisplay: FC = ({ ex ? getOnChainTokenDetailsOrDefault(selectedNetwork, executionInput.onChainToken) : getAnyFiatTokenDetails(executionInput.fiatToken); - const fromFiatIcon = useGetAssetIcon(isOnramp ? (fromToken as BaseFiatTokenDetails).fiat.assetIcon : ""); - const toFiatIcon = useGetAssetIcon(!isOnramp ? (toToken as BaseFiatTokenDetails).fiat.assetIcon : ""); - - const fromTokenIcons = isOnramp ? { primary: fromFiatIcon } : getOnChainTokenIcon(fromToken as OnChainTokenDetails); - const toTokenIcons = !isOnramp ? { primary: toFiatIcon } : getOnChainTokenIcon(toToken as OnChainTokenDetails); - - const fromIcon = fromTokenIcons.primary ?? fromFiatIcon; - const toIcon = toTokenIcons.primary ?? toFiatIcon; - const fromFallbackIcon = fromTokenIcons.fallback; - const toFallbackIcon = toTokenIcons.fallback; + const fromIconInfo = useTokenIcon(fromToken); + const toIconInfo = useTokenIcon(toToken); const getPartnerUrl = (): string => { const fiatToken = (isOnramp ? fromToken : toToken) as FiatTokenDetails; @@ -146,17 +124,17 @@ export const TransactionTokensDisplay: FC = ({ ex
{ + if (typeof currencyOrDetails === "string") { + return currencyOrDetails.toLowerCase(); + } + // For token details, use assetSymbol + return currencyOrDetails.assetSymbol.toLowerCase(); + }, [currencyOrDetails]); + + const fiatIcon = useGetAssetIcon(currencyForFiatLookup); + + return useMemo(() => { + // Handle token details objects + if (typeof currencyOrDetails !== "string") { + // FiatTokenDetails (Stellar or Moonbeam) + if (isFiatTokenDetails(currencyOrDetails)) { + return { + iconSrc: fiatIcon + }; + } + + // OnChainTokenDetails (EVM or AssetHub) + const { logoURI, fallbackLogoURI } = getTokenLogoURIs(currencyOrDetails as OnChainTokenDetails); + return { + fallbackIconSrc: fallbackLogoURI, + iconSrc: logoURI ?? fiatIcon, + network: currencyOrDetails.network + }; + } + + // Handle currency string input + const currency = currencyOrDetails; + + // Fiat tokens use local icons + if (isFiatToken(currency)) { + return { + iconSrc: fiatIcon + }; + } + + // On-chain tokens need to look up details for logoURI + if (network) { + const tokenDetails = getOnChainTokenDetails(network, currency as OnChainToken, getEvmTokenConfig()); + if (tokenDetails) { + const { logoURI, fallbackLogoURI } = getTokenLogoURIs(tokenDetails); + return { + fallbackIconSrc: fallbackLogoURI, + iconSrc: logoURI ?? fiatIcon, + network + }; + } + } + + // Fallback to fiat icon lookup (will return placeholder if not found) + return { + iconSrc: fiatIcon, + network + }; + }, [currencyOrDetails, network, fiatIcon]); +} diff --git a/apps/frontend/src/sections/individuals/PopularTokens/index.tsx b/apps/frontend/src/sections/individuals/PopularTokens/index.tsx index 2e145a688..b8ffc88d3 100644 --- a/apps/frontend/src/sections/individuals/PopularTokens/index.tsx +++ b/apps/frontend/src/sections/individuals/PopularTokens/index.tsx @@ -3,8 +3,9 @@ import { motion } from "motion/react"; import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { cn } from "../../../helpers/cn"; -import { isValidFiatIcon, useGetAssetIcon } from "../../../hooks/useGetAssetIcon"; +import { isValidFiatIcon } from "../../../hooks/useGetAssetIcon"; import { useGetNetworkIcon } from "../../../hooks/useGetNetworkIcon"; +import { useTokenIcon } from "../../../hooks/useTokenIcon"; const fiatTokens: Array<{ name: string; assetIcon: string }> = Object.values(FiatToken) .map(name => ({ @@ -60,8 +61,8 @@ const NetworkBadge = ({ network, isAnimating }: { network: Networks; isAnimating }; const TokenBadge = ({ token, isAnimating }: { token: { name: string; assetIcon: string }; isAnimating: boolean }) => { - const icon = useGetAssetIcon(token.assetIcon); - return ; + const { iconSrc } = useTokenIcon(token.assetIcon); + return ; }; export function PopularTokens() { diff --git a/apps/frontend/src/services/tokens/index.ts b/apps/frontend/src/services/tokens/index.ts index c47983c77..537dce7d3 100644 --- a/apps/frontend/src/services/tokens/index.ts +++ b/apps/frontend/src/services/tokens/index.ts @@ -6,8 +6,5 @@ export { getAllEvmTokens, getEvmTokenConfig, getEvmTokensForNetwork, - getLoadingError, - initializeEvmTokens, - isTokensLoaded, - usedFallbackConfig + initializeEvmTokens } from "@vortexfi/shared"; diff --git a/apps/frontend/src/stores/quote/useQuoteStore.ts b/apps/frontend/src/stores/quote/useQuoteStore.ts index 4ef47cf17..9cf217466 100644 --- a/apps/frontend/src/stores/quote/useQuoteStore.ts +++ b/apps/frontend/src/stores/quote/useQuoteStore.ts @@ -77,6 +77,7 @@ const friendlyErrorMessages: Record = { [QuoteError.InputAmountTooLowToCoverCalculatedFees]: "pages.swap.error.tryLargerAmount", [QuoteError.BelowLowerLimitSell]: QuoteError.BelowLowerLimitSell, // We leave this as-is, as the replacement string depends on the context [QuoteError.BelowLowerLimitBuy]: QuoteError.BelowLowerLimitBuy, // We leave this as-is, as the replacement string depends on the context + [QuoteError.LowLiquidity]: "pages.swap.error.lowLiquidity", // Calculation failures - suggest different amount [QuoteError.UnableToGetPendulumTokenDetails]: "pages.swap.error.tryDifferentAmount", [QuoteError.FailedToCalculateQuote]: "pages.swap.error.tryDifferentAmount", diff --git a/apps/frontend/src/translations/en.json b/apps/frontend/src/translations/en.json index 297266b69..e27cba2d7 100644 --- a/apps/frontend/src/translations/en.json +++ b/apps/frontend/src/translations/en.json @@ -943,6 +943,7 @@ "buy": "Minimum buy amount is {{minAmountUnits}} {{assetSymbol}}.", "sell": "Minimum sell amount is {{minAmountUnits}} {{assetSymbol}}." }, + "lowLiquidity": "Low liquidity for this route. Please try a smaller amount.", "missingFields": "Missing required fields", "moreThanMaximumWithdrawal": { "buy": "Maximum buy amount is {{maxAmountUnits}} {{assetSymbol}}.", diff --git a/apps/frontend/src/translations/pt.json b/apps/frontend/src/translations/pt.json index 0734d69cd..7d7458f9b 100644 --- a/apps/frontend/src/translations/pt.json +++ b/apps/frontend/src/translations/pt.json @@ -937,6 +937,7 @@ "buy": "O valor mínimo de compra é {{minAmountUnits}} {{assetSymbol}}.", "sell": "O valor mínimo de venda é {{minAmountUnits}} {{assetSymbol}}." }, + "lowLiquidity": "Baixa liquidez para esta rota. Por favor, tente um valor menor.", "missingFields": "Campos obrigatórios ausentes", "moreThanMaximumWithdrawal": { "buy": "O valor máximo de compra é {{maxAmountUnits}} {{assetSymbol}}.", diff --git a/packages/shared/src/endpoints/quote.endpoints.ts b/packages/shared/src/endpoints/quote.endpoints.ts index 265fe0550..df98166da 100644 --- a/packages/shared/src/endpoints/quote.endpoints.ts +++ b/packages/shared/src/endpoints/quote.endpoints.ts @@ -95,6 +95,7 @@ export enum QuoteError { InputAmountForSwapMustBeGreaterThanZero = "Input amount for swap must be greater than 0", InputAmountTooLow = "Input amount too low. Please try a larger amount.", InputAmountTooLowToCoverCalculatedFees = "Input amount too low to cover calculated fees.", + LowLiquidity = "Low liquidity for this route. Please try a smaller amount.", BelowLowerLimitSell = "Output amount below minimum SELL limit of", BelowLowerLimitBuy = "Input amount below minimum BUY limit of", diff --git a/packages/shared/src/tokens/evm/dynamicEvmTokens.ts b/packages/shared/src/tokens/evm/dynamicEvmTokens.ts index bc0ad365a..5334f93fd 100644 --- a/packages/shared/src/tokens/evm/dynamicEvmTokens.ts +++ b/packages/shared/src/tokens/evm/dynamicEvmTokens.ts @@ -1,11 +1,14 @@ import axios from "axios"; import { EvmNetworks, getNetworkId, isNetworkEVM, Networks } from "../../helpers/networks"; +import logger from "../../logger"; import { squidRouterConfigBase } from "../../services/squidrouter/config"; import { PENDULUM_USDC_AXL } from "../pendulum/config"; import { TokenType } from "../types/base"; import { EvmTokenDetails } from "../types/evm"; import { evmTokenConfig } from "./config"; +const SQUID_ROUTER_API_URL = "https://v2.api.squidrouter.com/v2/tokens"; + // Token filtering configuration to exclude irrelevant tokens from EVM chains const TOKEN_FILTER_CONFIG = { // Bridged token patterns to exclude (Cosmos tokens bridged via Axelar) @@ -15,24 +18,6 @@ const TOKEN_FILTER_CONFIG = { symbolBlocklist: new Set(["HUAHUA", "OSMO", "ATOM", "LUNA", "UST", "SCRT", "JUNO", "STARS", "AKT", "REGEN", "KUJI", "INJ"]) }; -function shouldIncludeToken(token: SquidRouterToken): boolean { - const symbol = token.symbol.toUpperCase(); - - // Exclude blocklisted tokens (Cosmos native tokens) - if (TOKEN_FILTER_CONFIG.symbolBlocklist.has(symbol)) { - return false; - } - - // Exclude most bridged Axelar tokens except major stablecoins - for (const pattern of TOKEN_FILTER_CONFIG.excludedBridgedPatterns) { - if (pattern.test(token.symbol)) { - return false; - } - } - - return true; -} - interface SquidRouterToken { symbol: string; address: string; @@ -49,21 +34,42 @@ interface SquidRouterToken { } interface DynamicEvmTokensState { - tokens: EvmTokenDetails[]; tokensByNetwork: Record>>; + priceBySymbol: Map; isLoaded: boolean; - error: Error | null; - usedFallback: boolean; } const state: DynamicEvmTokensState = { - error: null, isLoaded: false, - tokens: [], - tokensByNetwork: {} as Record>>, - usedFallback: false + priceBySymbol: new Map(), + tokensByNetwork: {} as Record>> }; +/** + * Iterates over all EVM networks and calls the callback for each. + */ +function forEachEvmNetwork(callback: (network: EvmNetworks) => void): void { + for (const network of Object.values(Networks)) { + if (isNetworkEVM(network)) { + callback(network as EvmNetworks); + } + } +} + +function createEmptyNetworkBuckets(): Record>> { + const buckets = {} as Record>>; + forEachEvmNetwork(network => { + buckets[network] = {}; + }); + return buckets; +} + +const NATIVE_TOKEN_ADDRESS = "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" as const; + +function isNativeToken(address: string): boolean { + return address.toLowerCase() === NATIVE_TOKEN_ADDRESS; +} + function getNetworkFromChainId(chainId: string): Networks | null { const chainIdNum = parseInt(chainId, 10); const networkEntries = Object.entries(Networks).filter( @@ -72,16 +78,34 @@ function getNetworkFromChainId(chainId: string): Networks | null { return networkEntries.length > 0 ? (networkEntries[0][1] as Networks) : null; } -function isNativeToken(address: string): boolean { - return address === "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"; -} - function getNetworkAssetIcon(network: Networks, symbol: string): string { const networkName = network.toLowerCase(); const cleanSymbol = symbol.replace(/[^a-zA-Z0-9]/g, "").toLowerCase(); return `${networkName}${cleanSymbol}`; } +function generateFallbackLogoURI(chainId: number, address: string): string { + return `https://raw.githubusercontent.com/0xsquid/assets/main/images/migration/webp/${chainId}_${address.toLowerCase()}.webp`; +} + +function shouldIncludeToken(token: SquidRouterToken): boolean { + const symbol = token.symbol.toUpperCase(); + + // Exclude blocklisted tokens (Cosmos native tokens) + if (TOKEN_FILTER_CONFIG.symbolBlocklist.has(symbol)) { + return false; + } + + // Exclude most bridged Axelar tokens except major stablecoins + for (const pattern of TOKEN_FILTER_CONFIG.excludedBridgedPatterns) { + if (pattern.test(token.symbol)) { + return false; + } + } + + return true; +} + function mapSquidTokenToEvmTokenDetails(token: SquidRouterToken): EvmTokenDetails | null { const network = getNetworkFromChainId(token.chainId); if (!network || !isNetworkEVM(network)) { @@ -94,15 +118,13 @@ function mapSquidTokenToEvmTokenDetails(token: SquidRouterToken): EvmTokenDetail const isNative = isNativeToken(token.address); - const erc20Address: `0x${string}` = isNative - ? ("0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" as `0x${string}`) - : (token.address as `0x${string}`); + const erc20Address = token.address as `0x${string}`; return { assetSymbol: token.symbol, decimals: token.decimals, erc20AddressSourceChain: erc20Address, - fallbackLogoURI: `https://raw.githubusercontent.com/0xsquid/assets/main/images/migration/webp/${token.chainId}_${token.address.toLowerCase()}.webp`, + fallbackLogoURI: generateFallbackLogoURI(parseInt(token.chainId, 10), erc20Address), isNative, logoURI: token.logoURI, network, @@ -113,72 +135,113 @@ function mapSquidTokenToEvmTokenDetails(token: SquidRouterToken): EvmTokenDetail }; } -async function fetchSquidRouterTokens(): Promise { - const result = await axios.get("https://v2.api.squidrouter.com/v2/tokens", { - headers: { - "x-integrator-id": squidRouterConfigBase.integratorId - } - }); - return result.data.tokens; -} - +/** + * Groups tokens by their network into a record keyed by EvmNetworks. + * This function only groups - it does not merge with static config. + */ function groupTokensByNetwork(tokens: EvmTokenDetails[]): Record>> { - const grouped = {} as Record>>; - - for (const network of Object.values(Networks)) { - if (isNetworkEVM(network)) { - grouped[network as EvmNetworks] = {}; - } - } + const grouped = createEmptyNetworkBuckets(); for (const token of tokens) { - if (isNetworkEVM(token.network)) { - const network = token.network as EvmNetworks; - if (!grouped[network]) { - grouped[network] = {}; - } - grouped[network][token.assetSymbol.toUpperCase()] = token; - } + const network = token.network as EvmNetworks; + grouped[network][token.assetSymbol.toUpperCase()] = token; } - for (const network of Object.values(Networks)) { - if (isNetworkEVM(network)) { - const evmNetwork = network as EvmNetworks; - const networkTokenConfig = evmTokenConfig[evmNetwork]; - if (networkTokenConfig) { - grouped[evmNetwork] = { - ...networkTokenConfig, - ...grouped[evmNetwork] + return grouped; +} + +/** + * Merges dynamic tokens with static config. + * Static config takes priority for contract addresses, but preserves useful metadata + * (logoURI, usdPrice) from dynamic tokens. + */ +function mergeWithStaticConfig( + dynamicTokens: Record>> +): Record>> { + const merged = createEmptyNetworkBuckets(); + + forEachEvmNetwork(network => { + merged[network] = { ...dynamicTokens[network] }; + + const networkTokenConfig = evmTokenConfig[network]; + if (!networkTokenConfig) return; + + for (const [symbol, staticToken] of Object.entries(networkTokenConfig)) { + if (!staticToken) continue; + + const normalizedSymbol = symbol.toUpperCase(); + const dynamicToken = dynamicTokens[network][normalizedSymbol]; + + if (dynamicToken) { + // Warning if addresses point to different contracts (possible configuration drift or scam token) + if (staticToken.erc20AddressSourceChain.toLowerCase() !== dynamicToken.erc20AddressSourceChain.toLowerCase()) { + logger.current.warn( + `[DynamicEvmTokens] Address mismatch for ${symbol} on ${network}. Config: ${staticToken.erc20AddressSourceChain}, Dynamic: ${dynamicToken.erc20AddressSourceChain}. Using Config preference.` + ); + } + + // Static token exists and dynamic token exists - merge, static takes priority + merged[network][normalizedSymbol] = { + ...staticToken, + fallbackLogoURI: staticToken.fallbackLogoURI ?? dynamicToken.fallbackLogoURI, + logoURI: staticToken.logoURI ?? dynamicToken.logoURI, + usdPrice: dynamicToken.usdPrice ?? staticToken.usdPrice }; + } else { + // Static token exists but no dynamic token - use static as-is + merged[network][normalizedSymbol] = staticToken; } } - } + }); - return grouped; + return merged; } -function buildFallbackFromStaticConfig(): { - tokens: EvmTokenDetails[]; - tokensByNetwork: Record>>; -} { - const tokens: EvmTokenDetails[] = []; - const tokensByNetwork = {} as Record>>; +function buildPriceLookup(tokensByNetwork: Record>>): Map { + const priceMap = new Map(); - for (const network of Object.values(Networks)) { - if (isNetworkEVM(network)) { - const evmNetwork = network as EvmNetworks; - const networkTokenConfig = evmTokenConfig[evmNetwork]; - if (networkTokenConfig) { - tokensByNetwork[evmNetwork] = networkTokenConfig; - const networkTokens = Object.values(networkTokenConfig).filter( - (token): token is EvmTokenDetails => token !== undefined - ); - tokens.push(...networkTokens); + forEachEvmNetwork(network => { + const networkTokens = tokensByNetwork[network]; + for (const token of Object.values(networkTokens)) { + if (token?.usdPrice !== undefined) { + priceMap.set(token.assetSymbol.toUpperCase(), token.usdPrice); } } - } + }); + + return priceMap; +} - return { tokens, tokensByNetwork }; +async function fetchSquidRouterTokens(): Promise { + const result = await axios.get(SQUID_ROUTER_API_URL, { + headers: { + "x-integrator-id": squidRouterConfigBase.integratorId + } + }); + return result.data.tokens; +} + +function buildFallbackFromStaticConfig(): Record>> { + const tokensByNetwork = createEmptyNetworkBuckets(); + + forEachEvmNetwork(network => { + const networkTokenConfig = evmTokenConfig[network]; + if (networkTokenConfig) { + tokensByNetwork[network] = { ...networkTokenConfig }; + } + }); + + return tokensByNetwork; +} + +/** + * Derives a flat array of all tokens from the tokensByNetwork structure. + * Use this instead of storing a separate tokens array. + */ +function deriveAllTokens(tokensByNetwork: Record>>): EvmTokenDetails[] { + return Object.values(tokensByNetwork) + .flatMap(networkTokens => Object.values(networkTokens)) + .filter((token): token is EvmTokenDetails => token !== undefined); } /** @@ -196,21 +259,16 @@ export async function initializeEvmTokens(): Promise { const evmTokens = squidTokens .map(mapSquidTokenToEvmTokenDetails) .filter((token): token is EvmTokenDetails => token !== null); - //.slice(0, 500); // TODO TESTING Limit to first 500 tokens to avoid overload - state.tokens = evmTokens; - state.tokensByNetwork = groupTokensByNetwork(evmTokens); - state.error = null; - state.usedFallback = false; + const groupedTokens = groupTokensByNetwork(evmTokens); + state.tokensByNetwork = mergeWithStaticConfig(groupedTokens); + state.priceBySymbol = buildPriceLookup(state.tokensByNetwork); state.isLoaded = true; } catch (err) { console.error("[DynamicEvmTokens] Failed to fetch tokens from SquidRouter, using fallback:", err); - const fallback = buildFallbackFromStaticConfig(); - state.tokens = fallback.tokens; - state.tokensByNetwork = fallback.tokensByNetwork; - state.error = err instanceof Error ? err : new Error("Failed to fetch tokens"); - state.usedFallback = true; + state.tokensByNetwork = buildFallbackFromStaticConfig(); + state.priceBySymbol = buildPriceLookup(state.tokensByNetwork); state.isLoaded = true; } } @@ -241,31 +299,9 @@ export function getEvmTokensForNetwork(network: EvmNetworks): EvmTokenDetails[] */ export function getAllEvmTokens(): EvmTokenDetails[] { if (!state.isLoaded) { - const fallback = buildFallbackFromStaticConfig(); - return fallback.tokens; + return deriveAllTokens(buildFallbackFromStaticConfig()); } - return state.tokens; -} - -/** - * Check if tokens have been loaded. - */ -export function isTokensLoaded(): boolean { - return state.isLoaded; -} - -/** - * Check if the service used the fallback static config. - */ -export function usedFallbackConfig(): boolean { - return state.usedFallback; -} - -/** - * Get the error if token loading failed. - */ -export function getLoadingError(): Error | null { - return state.error; + return deriveAllTokens(state.tokensByNetwork); } /** @@ -280,10 +316,5 @@ export function getTokenUsdPrice(symbol: string): number | undefined { return undefined; } - const normalizedSymbol = symbol.toUpperCase(); - - // Search through all tokens to find matching symbol - const token = state.tokens.find(t => t.assetSymbol.toUpperCase() === normalizedSymbol); - - return token?.usdPrice; + return state.priceBySymbol.get(symbol.toUpperCase()); }