Skip to content
14 changes: 12 additions & 2 deletions apps/api/src/api/services/quote/core/squidrouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion apps/frontend/src/components/AssetNumericInput/index.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -24,6 +24,7 @@ interface AssetNumericInputProps {
fallbackLogoURI?: string;
registerInput: UseFormRegisterReturn<keyof QuoteFormValues>;
id: string;
network?: Networks;
}

export const AssetNumericInput: FC<AssetNumericInputProps> = ({
Expand All @@ -45,6 +46,7 @@ export const AssetNumericInput: FC<AssetNumericInputProps> = ({
assetIcon={assetIcon}
fallbackLogoURI={rest.fallbackLogoURI}
logoURI={rest.logoURI}
network={rest.network}
onClick={onClick}
tokenSymbol={tokenSymbol}
/>
Expand Down
48 changes: 42 additions & 6 deletions apps/frontend/src/components/CurrencyExchange/index.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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 = ({
Expand All @@ -20,25 +28,53 @@ 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 (
<div className={`flex flex-col gap-4 ${className}`}>
<div className="flex flex-col">
<div className="text-gray-500">You send</div>
<div className="flex grow items-center font-bold">
{showIcons && <img alt={inputCurrency} className="mr-2 h-6 w-6" src={inputIcon} />}
{showIcons && (
<TokenIconWithNetwork
className="mr-2 h-6 w-6"
fallbackIconSrc={inputFallbackIcon}
iconSrc={inputIcon}
network={inputNetwork}
showNetworkOverlay={!!inputNetwork}
tokenSymbol={inputCurrency}
/>
)}
{inputAmount} {inputCurrency.toUpperCase()}
</div>
</div>
<div className="flex flex-col">
<div className="text-gray-500">You get</div>
<div className="flex grow items-center justify-end font-bold">
{showIcons && <img alt={outputCurrency} className="mr-2 h-6 w-6" src={outputIcon} />}
{showIcons && (
<TokenIconWithNetwork
className="mr-2 h-6 w-6"
fallbackIconSrc={outputFallbackIcon}
iconSrc={outputIcon}
network={outputNetwork}
showNetworkOverlay={!!outputNetwork}
tokenSymbol={outputCurrency}
/>
)}
{showApproximation && "~ "}
{outputAmount} {outputCurrency.toUpperCase()}
</div>
Expand Down
4 changes: 2 additions & 2 deletions apps/frontend/src/components/FiatIcon/index.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLImageElement> {
fiat: FiatTokenDetails;
}

export const FiatIcon: FC<Props> = ({ fiat, ...props }) => {
const iconSrc = useGetAssetIcon(fiat.assetSymbol.toLowerCase());
const { iconSrc } = useTokenIcon(fiat);

if (iconSrc) return <img alt={fiat.fiat.name} src={iconSrc} {...props} />;

Expand Down
7 changes: 4 additions & 3 deletions apps/frontend/src/components/ListItem/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand Down
50 changes: 43 additions & 7 deletions apps/frontend/src/components/QuoteSummary/index.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
Expand All @@ -24,8 +41,14 @@ const QuoteSummaryCore = ({ quote }: { quote: QuoteResponse }) => {
<CurrencyExchange
inputAmount={quote.inputAmount}
inputCurrency={quote.inputCurrency}
inputFallbackIcon={inputIcon.fallbackIconSrc}
inputIcon={inputIcon.iconSrc}
inputNetwork={inputIcon.network}
outputAmount={quote.outputAmount}
outputCurrency={quote.outputCurrency}
outputFallbackIcon={outputIcon.fallbackIconSrc}
outputIcon={outputIcon.iconSrc}
outputNetwork={outputIcon.network}
/>
<ToggleButton
ariaControls={detailsId}
Expand All @@ -42,8 +65,7 @@ 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 (
<section className="overflow-hidden">
Expand All @@ -53,15 +75,29 @@ const QuoteSummaryDetails = ({ quote }: { quote: QuoteResponse }) => {
<div className="flex flex-col">
<div className="text-gray-500 text-sm">{t("components.quoteSummary.youSend")}</div>
<div className="flex items-center font-bold">
<img alt={quote.inputCurrency} className="mr-2 h-5 w-5" src={inputIcon} />
<TokenIconWithNetwork
className="mr-2 h-5 w-5"
fallbackIconSrc={inputIcon.fallbackIconSrc}
iconSrc={inputIcon.iconSrc}
network={inputIcon.network}
showNetworkOverlay={!!inputIcon.network}
tokenSymbol={quote.inputCurrency}
/>
{quote.inputAmount} {quote.inputCurrency.toUpperCase()}
</div>
</div>
<div className="flex flex-col">
<div className="text-gray-500 text-sm">{t("components.quoteSummary.youReceive")}</div>
<div className="flex items-center font-bold">
<img alt={quote.outputCurrency} className="mr-2 h-5 w-5" src={outputIcon} />~ {quote.outputAmount}{" "}
{quote.outputCurrency.toUpperCase()}
<TokenIconWithNetwork
className="mr-2 h-5 w-5"
fallbackIconSrc={outputIcon.fallbackIconSrc}
iconSrc={outputIcon.iconSrc}
network={outputIcon.network}
showNetworkOverlay={!!outputIcon.network}
tokenSymbol={quote.outputCurrency}
/>
~ {quote.outputAmount} {quote.outputCurrency.toUpperCase()}
</div>
</div>
</div>
Expand Down
11 changes: 6 additions & 5 deletions apps/frontend/src/components/Ramp/Offramp/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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(
() => (
<>
<AssetNumericInput
assetIcon={fromToken.networkAssetIcon}
fallbackLogoURI={fallbackLogoURI}
fallbackLogoURI={fromIconInfo.fallbackIconSrc}
id="inputAmount"
logoURI={logoURI}
logoURI={fromIconInfo.iconSrc}
network={fromIconInfo.network}
onChange={handleInputChange}
onClick={() => openTokenSelectModal("from")}
registerInput={form.register("inputAmount")}
Expand All @@ -96,7 +97,7 @@ export const Offramp = () => {
</div>
</>
),
[form, fromToken, openTokenSelectModal, handleInputChange, handleBalanceClick, isDisconnected, logoURI, fallbackLogoURI]
[form, fromToken, openTokenSelectModal, handleInputChange, handleBalanceClick, isDisconnected, fromIconInfo]
);

const ReceiveNumericInput = useMemo(
Expand Down
20 changes: 6 additions & 14 deletions apps/frontend/src/components/Ramp/Onramp/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -84,33 +84,25 @@ export const Onramp = () => {
[form, fromToken, openTokenSelectModal, handleInputChange]
);

const { logoURI, fallbackLogoURI } = getTokenLogoURIs(toToken);
const toIconInfo = useTokenIcon(toToken);

const ReceiveNumericInput = useMemo(
() => (
<AssetNumericInput
assetIcon={toToken.networkAssetIcon}
disabled={!toAmount}
fallbackLogoURI={fallbackLogoURI}
fallbackLogoURI={toIconInfo.fallbackIconSrc}
id="outputAmount"
loading={quoteLoading}
logoURI={logoURI}
logoURI={toIconInfo.iconSrc}
network={toIconInfo.network}
onClick={() => 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(() => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<TokenImageProps> = memo(function TokenImage({ src, fallbackSrc, alt, className }) {
export const TokenIcon: FC<TokenIconProps> = memo(function TokenIcon({ src, fallbackSrc, alt, className }) {
const [isLoading, setIsLoading] = useState(true);
const [imgError, setImgError] = useState(false);
const [fallbackError, setFallbackError] = useState(false);
Expand Down
6 changes: 3 additions & 3 deletions apps/frontend/src/components/TokenIconWithNetwork/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -26,11 +26,11 @@ export const TokenIconWithNetwork: FC<TokenIconWithNetworkProps> = memo(function

return (
<div className={cn("relative", className)}>
<TokenImage alt={tokenSymbol} className="h-full w-full" fallbackSrc={fallbackIconSrc} src={iconSrc} />
<TokenIcon alt={tokenSymbol} className="h-full w-full" fallbackSrc={fallbackIconSrc} src={iconSrc} />
{shouldShowOverlay && (
<img
alt={`${network} network`}
className="-bottom-0.5 -right-0.5 absolute h-[40%] w-[40%] rounded-full object-contain"
className="-bottom-0.5 -right-0.5 absolute h-[50%] w-[50%] rounded-full object-contain"
decoding="async"
loading="lazy"
src={networkIcon}
Expand Down
Loading