diff --git a/src/hooks/useBridgeFees.ts b/src/hooks/useBridgeFees.ts index 9a180b221..ce1e161bf 100644 --- a/src/hooks/useBridgeFees.ts +++ b/src/hooks/useBridgeFees.ts @@ -36,7 +36,8 @@ export function useBridgeFees( externalProjectId?: string, _recipientAddress?: string, isUniversalSwap?: boolean, - universalSwapQuote?: UniversalSwapQuote + universalSwapQuote?: UniversalSwapQuote, + enabled: boolean = true ) { const didUniversalSwapLoad = isUniversalSwap && !!universalSwapQuote; const bridgeInputTokenSymbol = didUniversalSwapLoad @@ -87,7 +88,7 @@ export function useBridgeFees( ? getBridgeFeesWithExternalProjectId(externalProjectIdToQuery, feeArgs) : getBridgeFees(feeArgs); }, - enabled: Boolean(amount.gt(0)), + enabled: enabled && Boolean(amount.gt(0)), refetchInterval: 5000, retry: (_, error) => { if ( diff --git a/src/hooks/useBridgeLimits.ts b/src/hooks/useBridgeLimits.ts index 595be7f53..5bba15033 100644 --- a/src/hooks/useBridgeLimits.ts +++ b/src/hooks/useBridgeLimits.ts @@ -27,15 +27,18 @@ export function useBridgeLimits( fromChainId?: ChainId, toChainId?: ChainId, isUniversalSwap?: boolean, - universalSwapQuote?: UniversalSwapQuote + universalSwapQuote?: UniversalSwapQuote, + enabled: boolean = true ) { - const enabled = !!( - inputTokenSymbol && - outputTokenSymbol && - fromChainId && - toChainId && - (isUniversalSwap ? !!universalSwapQuote : true) - ); + const queryEnabled = + enabled && + !!( + inputTokenSymbol && + outputTokenSymbol && + fromChainId && + toChainId && + (isUniversalSwap ? !!universalSwapQuote : true) + ); const didUniversalSwapLoad = isUniversalSwap && !!universalSwapQuote; const bridgeInputTokenSymbol = didUniversalSwapLoad ? universalSwapQuote.steps.bridge.tokenIn.symbol @@ -78,7 +81,7 @@ export function useBridgeLimits( toChainIdToQuery ); }, - enabled, + enabled: queryEnabled, refetchInterval: 300_000, // 5 minutes }); return { diff --git a/src/hooks/useEnrichedCrosschainBalances.ts b/src/hooks/useEnrichedCrosschainBalances.ts deleted file mode 100644 index 6d4a9924b..000000000 --- a/src/hooks/useEnrichedCrosschainBalances.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { useMemo } from "react"; -import useAvailableCrosschainRoutes, { - LifiToken, -} from "./useAvailableCrosschainRoutes"; -import { useUserTokenBalances } from "./useUserTokenBalances"; -import { compareAddressesSimple } from "utils"; -import { BigNumber, utils } from "ethers"; - -export function useEnrichedCrosschainBalances() { - const tokenBalances = useUserTokenBalances(); - const availableCrosschainRoutes = useAvailableCrosschainRoutes(); - - return useMemo(() => { - if (availableCrosschainRoutes.isLoading || tokenBalances.isLoading) { - return {}; - } - const chains = Object.keys(availableCrosschainRoutes.data || {}); - - return chains.reduce( - (acc, chainId) => { - const balancesForChain = tokenBalances.data?.balances.find( - (t) => t.chainId === String(chainId) - ); - - const tokens = availableCrosschainRoutes.data![Number(chainId)]; - const enrichedTokens = tokens.map((t) => { - const balance = balancesForChain?.balances.find((b) => - compareAddressesSimple(b.address, t.address) - ); - return { - ...t, - balance: balance?.balance - ? BigNumber.from(balance.balance) - : BigNumber.from(0), - balanceUsd: - balance?.balance && t - ? Number( - utils.formatUnits( - BigNumber.from(balance.balance), - t.decimals - ) - ) * Number(t.priceUSD) - : 0, - }; - }); - - // Sort high to low balanceUsd - const sortedByBalance = enrichedTokens.sort( - (a, b) => b.balanceUsd - a.balanceUsd - ); - - return { - ...acc, - [Number(chainId)]: sortedByBalance, - }; - }, - {} as Record< - number, - Array - > - ); - }, [ - availableCrosschainRoutes.data, - availableCrosschainRoutes.isLoading, - tokenBalances.data, - tokenBalances.isLoading, - ]); -} diff --git a/src/hooks/useTokenInput.ts b/src/hooks/useTokenInput.ts index 5e884417c..d8e6bc046 100644 --- a/src/hooks/useTokenInput.ts +++ b/src/hooks/useTokenInput.ts @@ -1,13 +1,14 @@ import { useCallback, useEffect, useState } from "react"; import { BigNumber, utils } from "ethers"; import { convertTokenToUSD, convertUSDToToken } from "utils"; -import { EnrichedToken } from "views/SwapAndBridge/components/ChainTokenSelector/ChainTokenSelectorModal"; + import { formatUnitsWithMaxFractions } from "utils"; +import { TokenWithBalance } from "views/SwapAndBridge/hooks/useSwapAndBridgeTokens"; export type UnitType = "usd" | "token"; type UseTokenInputProps = { - token: EnrichedToken | null; + token: TokenWithBalance | null; setAmount: (amount: BigNumber | null) => void; expectedAmount: string | undefined; shouldUpdate: boolean; diff --git a/src/utils/token.ts b/src/utils/token.ts index 5dcf6fa0e..5f5b3549d 100644 --- a/src/utils/token.ts +++ b/src/utils/token.ts @@ -1,5 +1,5 @@ import { BigNumber, ethers } from "ethers"; -import { LifiToken } from "hooks/useAvailableCrosschainRoutes"; +import { LifiToken } from "views/SwapAndBridge/hooks/useAvailableCrosschainRoutes"; import { getProvider, diff --git a/src/views/Bridge/hooks/useTransferQuote.ts b/src/views/Bridge/hooks/useTransferQuote.ts index da53881e8..5fda59de9 100644 --- a/src/views/Bridge/hooks/useTransferQuote.ts +++ b/src/views/Bridge/hooks/useTransferQuote.ts @@ -26,13 +26,14 @@ export function useTransferQuote( amount: BigNumber, swapSlippage: number, fromAddress?: string, - toAddress?: string + toAddress?: string, + isEnabled: boolean = true ) { const [initialQuoteTime, setInitialQuoteTime] = useState< number | undefined >(); - const isSwapRoute = selectedRoute.type === "swap"; - const isUniversalSwapRoute = selectedRoute.type === "universal-swap"; + const isSwapRoute = selectedRoute?.type === "swap"; + const isUniversalSwapRoute = selectedRoute?.type === "universal-swap"; const swapQuoteQuery = useSwapQuoteQuery({ // Setting `swapTokenSymbol` to undefined will disable the query swapTokenSymbol: isSwapRoute ? selectedRoute.swapTokenSymbol : undefined, @@ -72,7 +73,8 @@ export function useTransferQuote( selectedRoute.externalProjectId, toAddress, isUniversalSwapRoute, - universalSwapQuoteQuery.data + universalSwapQuoteQuery.data, + isEnabled ); const limitsQuery = useBridgeLimits( selectedRoute.fromTokenSymbol, @@ -80,9 +82,15 @@ export function useTransferQuote( selectedRoute.fromChain, selectedRoute.toChain, isUniversalSwapRoute, - universalSwapQuoteQuery.data + universalSwapQuoteQuery.data, + isEnabled + ); + const usdPriceQuery = useCoingeckoPrice( + selectedRoute.l1TokenAddress, + "usd", + undefined, + isEnabled ); - const usdPriceQuery = useCoingeckoPrice(selectedRoute.l1TokenAddress, "usd"); const transferQuoteQuery = useQuery({ queryKey: [ @@ -100,7 +108,8 @@ export function useTransferQuote( selectedRoute.type, ], enabled: Boolean( - feesQuery.fees && + isEnabled && + feesQuery.fees && limitsQuery.limits && usdPriceQuery.data?.price && // If it's a swap route, we also need to wait for the swap quote to be fetched diff --git a/src/views/SwapAndBridge/components/ChainTokenSelector/ChainTokenSelectorModal.tsx b/src/views/SwapAndBridge/components/ChainTokenSelector/ChainTokenSelectorModal.tsx index f433a3014..8ffbc9939 100644 --- a/src/views/SwapAndBridge/components/ChainTokenSelector/ChainTokenSelectorModal.tsx +++ b/src/views/SwapAndBridge/components/ChainTokenSelector/ChainTokenSelectorModal.tsx @@ -2,7 +2,7 @@ import Modal from "components/Modal"; import styled from "@emotion/styled"; import { Searchbar } from "./Searchbar"; import TokenMask from "assets/mask/token-mask-corner.svg"; -import { LifiToken } from "hooks/useAvailableCrosschainRoutes"; +import { LifiToken } from "views/SwapAndBridge/hooks/useAvailableCrosschainRoutes"; import { CHAIN_IDs, ChainInfo, @@ -22,11 +22,13 @@ import { ReactComponent as SearchResults } from "assets/icons/search_results.svg import { ReactComponent as WarningIcon } from "assets/icons/warning_triangle.svg"; import { ReactComponent as LinkExternalIcon } from "assets/icons/arrow-up-right-boxed.svg"; import AllChainsIcon from "assets/chain-logos/all-swap-chain.png"; -import { useEnrichedCrosschainBalances } from "hooks/useEnrichedCrosschainBalances"; import useCurrentBreakpoint from "hooks/useCurrentBreakpoint"; -import { BigNumber } from "ethers"; import { Text, TokenImage } from "components"; import { useHotkeys } from "react-hotkeys-hook"; +import { + useSwapAndBridgeTokens, + TokenWithBalance, +} from "views/SwapAndBridge/hooks/useSwapAndBridgeTokens"; const popularChains = [ CHAIN_IDs.MAINNET, @@ -53,27 +55,21 @@ type DisplayedChains = { all: ChainData[]; }; -export type EnrichedToken = LifiToken & { - balance: BigNumber; - balanceUsd: number; - routeSource: "bridge" | "swap"; -}; - -type EnrichedTokenWithReachability = EnrichedToken & { +type TokenWithBalanceWithReachability = TokenWithBalance & { isUnreachable: boolean; }; type DisplayedTokens = { - popular: EnrichedTokenWithReachability[]; - all: EnrichedTokenWithReachability[]; + popular: TokenWithBalanceWithReachability[]; + all: TokenWithBalanceWithReachability[]; }; type Props = { - onSelect: (token: EnrichedToken) => void; - onSelectOtherToken?: (token: EnrichedToken | null) => void; // Callback to reset the other selector + onSelect: (token: TokenWithBalance) => void; + onSelectOtherToken?: (token: TokenWithBalance | null) => void; // Callback to reset the other selector isOriginToken: boolean; - currentToken?: EnrichedToken | null; // The currently selected token we're changing from - otherToken?: EnrichedToken | null; // The currently selected token on the other side + currentToken?: TokenWithBalance | null; // The currently selected token we're changing from + otherToken?: TokenWithBalance | null; // The currently selected token on the other side displayModal: boolean; setDisplayModal: (displayModal: boolean) => void; }; @@ -87,7 +83,17 @@ export function ChainTokenSelectorModal({ currentToken, otherToken, }: Props) { - const crossChainRoutes = useEnrichedCrosschainBalances(); + const { data: crossChainRoutes } = useSwapAndBridgeTokens( + isOriginToken + ? { + outputToken: otherToken, + isInput: true, + } + : { + inputToken: otherToken, + isOutput: true, + } + ); const { isMobile } = useCurrentBreakpoint(); const [selectedChain, setSelectedChain] = useState( @@ -107,33 +113,32 @@ export function ChainTokenSelectorModal({ }, [displayModal, currentToken]); const displayedTokens = useMemo(() => { - let tokens = selectedChain ? (crossChainRoutes[selectedChain] ?? []) : []; + let tokens = selectedChain ? (crossChainRoutes?.[selectedChain] ?? []) : []; if (tokens.length === 0 && selectedChain === null) { - tokens = Object.values(crossChainRoutes).flatMap((t) => t); + tokens = Object.values(crossChainRoutes ?? {}).flatMap((t) => t); } // Enrich tokens with route source information and unreachable flag - const enrichedTokens = tokens.map((token) => { + // The hook (useSwapAndBridgeTokens) already handles all unreachability logic: + // - Same chain check (input tokens on same chain as output, or output tokens on same chain as input) + // - Bridge-only check (swap-only tokens when output is bridge-only) + const TokenWithBalances = tokens.map((token) => { // Find the corresponding token in crossChainRoutes to get route source const routeToken = crossChainRoutes?.[token.chainId]?.find( (rt) => rt.address.toLowerCase() === token.address.toLowerCase() ); - // Token is unreachable if otherToken exists and is from the same chain - const isUnreachable = otherToken - ? token.chainId === otherToken.chainId - : false; - return { ...token, - routeSource: routeToken?.routeSource || "bridge", // Default to bridge if not found - isUnreachable, + routeSource: routeToken?.routeSource || token.routeSource || ["bridge"], // Use token's routeSource if available, otherwise default to bridge + // Use the hook's isUnreachable value directly - it handles all cases + isUnreachable: token.isUnreachable ?? false, }; }); // Filter by search first - const filteredTokens = enrichedTokens.filter((t) => { + const filteredTokens = TokenWithBalances.filter((t) => { if (tokenSearch === "") { return true; } @@ -152,7 +157,7 @@ export function ChainTokenSelectorModal({ }); // Sort function that prioritizes tokens with balance, then by balance amount, then alphabetically - const sortTokens = (tokens: EnrichedTokenWithReachability[]) => { + const sortTokens = (tokens: TokenWithBalanceWithReachability[]) => { return tokens.sort((a, b) => { // Sort by token balance - tokens with balance go to top const aHasTokenBalance = a.balance.gt(0); @@ -334,8 +339,8 @@ const MobileModal = ({ displayedChains: DisplayedChains; displayedTokens: DisplayedTokens; onChainSelect: (chainId: number | null) => void; - onTokenSelect: (token: EnrichedToken) => void; - onSelectOtherToken?: (token: EnrichedToken | null) => void; + onTokenSelect: (token: TokenWithBalance) => void; + onSelectOtherToken?: (token: TokenWithBalance | null) => void; }) => { return ( void; - onTokenSelect: (token: EnrichedToken) => void; - onSelectOtherToken?: (token: EnrichedToken | null) => void; + onTokenSelect: (token: TokenWithBalance) => void; + onSelectOtherToken?: (token: TokenWithBalance | null) => void; }) => { return ( void; - onTokenSelect: (token: EnrichedToken) => void; - onSelectOtherToken?: (token: EnrichedToken | null) => void; + onTokenSelect: (token: TokenWithBalance) => void; + onSelectOtherToken?: (token: TokenWithBalance | null) => void; onModalClose: () => void; }) => { const chainSearchInputRef = useRef(null); @@ -638,8 +643,8 @@ const DesktopLayout = ({ displayedChains: DisplayedChains; displayedTokens: DisplayedTokens; onChainSelect: (chainId: number | null) => void; - onTokenSelect: (token: EnrichedToken) => void; - onSelectOtherToken?: (token: EnrichedToken | null) => void; + onTokenSelect: (token: TokenWithBalance) => void; + onSelectOtherToken?: (token: TokenWithBalance | null) => void; onModalClose: () => void; }) => { const chainSearchInputRef = useRef(null); @@ -851,7 +856,7 @@ const TokenEntry = ({ tabIndex, warningMessage, }: { - token: EnrichedTokenWithReachability; + token: TokenWithBalanceWithReachability; isSelected: boolean; onClick: () => void; warningMessage: string; diff --git a/src/views/SwapAndBridge/components/ChainTokenSelector/SelectorButton.tsx b/src/views/SwapAndBridge/components/ChainTokenSelector/SelectorButton.tsx index c590fd174..e75f5549b 100644 --- a/src/views/SwapAndBridge/components/ChainTokenSelector/SelectorButton.tsx +++ b/src/views/SwapAndBridge/components/ChainTokenSelector/SelectorButton.tsx @@ -3,17 +3,15 @@ import { useCallback, useEffect, useState } from "react"; import { COLORS, getChainInfo } from "utils"; import { ReactComponent as ChevronDownIcon } from "assets/icons/chevron-down.svg"; import { TokenImage } from "components/TokenImage"; -import { - ChainTokenSelectorModal, - EnrichedToken, -} from "./ChainTokenSelectorModal"; +import { ChainTokenSelectorModal } from "./ChainTokenSelectorModal"; +import { TokenWithBalance } from "views/SwapAndBridge/hooks/useSwapAndBridgeTokens"; type Props = { - selectedToken: EnrichedToken | null; - onSelect?: (token: EnrichedToken) => void; - onSelectOtherToken?: (token: EnrichedToken | null) => void; // Callback to reset the other selector + selectedToken: TokenWithBalance | null; + onSelect?: (token: TokenWithBalance) => void; + onSelectOtherToken?: (token: TokenWithBalance | null) => void; // Callback to reset the other selector isOriginToken: boolean; - otherToken?: EnrichedToken | null; // The currently selected token on the other side + otherToken?: TokenWithBalance | null; // The currently selected token on the other side marginBottom?: string; className?: string; }; @@ -36,7 +34,7 @@ export default function SelectorButton({ }, [selectedToken]); const setSelectedToken = useCallback( - (token: EnrichedToken) => { + (token: TokenWithBalance) => { onSelect?.(token); setDisplayModal(false); }, diff --git a/src/views/SwapAndBridge/components/ConfirmationButton.tsx b/src/views/SwapAndBridge/components/ConfirmationButton.tsx index f33e8815f..deb96d57c 100644 --- a/src/views/SwapAndBridge/components/ConfirmationButton.tsx +++ b/src/views/SwapAndBridge/components/ConfirmationButton.tsx @@ -15,10 +15,10 @@ import React from "react"; import { motion, AnimatePresence } from "framer-motion"; import { BigNumber } from "ethers"; import { COLORS, formatUSDString, isDefined } from "utils"; -import { EnrichedToken } from "./ChainTokenSelector/ChainTokenSelectorModal"; import styled from "@emotion/styled"; import { Tooltip } from "components/Tooltip"; -import { SwapApprovalApiCallReturnType } from "utils/serverless-api/prod/swap-approval"; +import { NormalizedQuote } from "../hooks/useSwapAndBridgeQuote"; +import { TokenWithBalance } from "../hooks/useSwapAndBridgeTokens"; export type BridgeButtonState = | "notConnected" @@ -31,10 +31,10 @@ export type BridgeButtonState = interface ConfirmationButtonProps extends ButtonHTMLAttributes { - inputToken: EnrichedToken | null; - outputToken: EnrichedToken | null; + inputToken: TokenWithBalance | null; + outputToken: TokenWithBalance | null; amount: BigNumber | null; - swapQuote: SwapApprovalApiCallReturnType | null; + swapQuote: NormalizedQuote | null; isQuoteLoading: boolean; onConfirm?: () => Promise; // External state props @@ -54,8 +54,18 @@ const ExpandableLabelSection: React.FC< visible: boolean; state: BridgeButtonState; hasQuote: boolean; + isLoading: boolean; }> -> = ({ fee, time, expanded, onToggle, state, children, hasQuote }) => { +> = ({ + fee, + time, + expanded, + onToggle, + state, + children, + hasQuote, + isLoading, +}) => { // Render state-specific content let content: React.ReactNode = null; @@ -71,8 +81,10 @@ const ExpandableLabelSection: React.FC< ); - // Show quote breakdown when quote is available, otherwise show default state - if (hasQuote) { + // Show loading state when fetching quote + if (isLoading) { + content = defaultState; + } else if (hasQuote) { // Show quote details when available content = ( <> @@ -109,7 +121,7 @@ const ExpandableLabelSection: React.FC< type="button" onClick={onToggle} aria-expanded={expanded} - disabled={!hasQuote} + disabled={!hasQuote || isLoading} > {content} @@ -180,6 +192,7 @@ export const ConfirmationButton: React.FC = ({ outputToken, amount, swapQuote, + isQuoteLoading, onConfirm, buttonState, buttonDisabled, @@ -194,42 +207,42 @@ export const ConfirmationButton: React.FC = ({ // Resolve conversion helpers outside memo to respect hooks rules const displayValues = React.useMemo(() => { - if (!swapQuote || !inputToken || !outputToken || !swapQuote.fees) { + if (!swapQuote || !inputToken || !outputToken) { return { fee: "-", time: "-", - bridgeFee: "-", - appFee: undefined, - swapImpact: undefined, route: "Across V4", estimatedTime: "-", totalFee: "-", + fees: [], }; } - const totalFeeUsd = swapQuote.fees.total.amountUsd; - const bridgeFeesUsd = swapQuote.fees.total.details.bridge.amountUsd; - const appFeesUsd = swapQuote.fees.total.details.app.amountUsd; - const swapImpactUsd = swapQuote.fees.total.details.swapImpact.amountUsd; - - // Only show fee items if they're at least 1 cent - const hasAppFee = Number(appFeesUsd) >= 0.01; - const hasSwapImpact = Number(swapImpactUsd) >= 0.01; + // Get total fee from fees array (first item is usually Total Fee) + const totalFeeItem = swapQuote.fees.find((f) => f.label === "Total Fee"); + const totalFeeUsd = totalFeeItem?.value || 0; - const totalSeconds = Math.max(0, Number(swapQuote.expectedFillTime || 0)); + // Get estimated time from normalized quote + const totalSeconds = swapQuote.estimatedFillTimeSeconds || 0; const underOneMinute = totalSeconds < 60; - const time = underOneMinute - ? `~${Math.max(1, Math.round(totalSeconds))} secs` - : `~${Math.ceil(totalSeconds / 60)} min`; + const time = + totalSeconds > 0 + ? underOneMinute + ? `~${Math.max(1, Math.round(totalSeconds))} secs` + : `~${Math.ceil(totalSeconds / 60)} min` + : "-"; + + // Filter fees to only show those >= 1 cent, excluding Total Fee (shown separately) + const feeBreakdown = swapQuote.fees.filter( + (fee) => fee.value >= 0.01 && fee.label !== "Total Fee" + ); return { - totalFee: formatUSDString(totalFeeUsd), + totalFee: totalFeeUsd > 0 ? formatUSDString(totalFeeUsd.toString()) : "-", time, - bridgeFee: formatUSDString(bridgeFeesUsd), - appFee: hasAppFee ? formatUSDString(appFeesUsd) : undefined, - swapImpact: hasSwapImpact ? formatUSDString(swapImpactUsd) : undefined, route: "Across V4", estimatedTime: time, + fees: feeBreakdown, }; }, [swapQuote, inputToken, outputToken, amount]); @@ -253,6 +266,7 @@ export const ConfirmationButton: React.FC = ({ visible={true} state={state} hasQuote={!!swapQuote} + isLoading={isQuoteLoading} > @@ -276,45 +290,44 @@ export const ConfirmationButton: React.FC = ({ Total Fee - - - - - {displayValues.totalFee} - - - - - Bridge Fee - - - - - {displayValues.bridgeFee} - - {isDefined(displayValues.swapImpact) && ( - - - Swap Impact + {(() => { + const totalFeeItem = swapQuote?.fees.find( + (f) => f.label === "Total Fee" + ); + return totalFeeItem?.description ? ( - - - {displayValues.swapImpact} - - - )} - + ) : null; + })()} + + {displayValues.totalFee} + + {displayValues.fees.length > 0 && ( + + {displayValues.fees.map((fee) => ( + + + {fee.label} + {fee.description && ( + + + + )} + + + {formatUSDString(fee.value.toString())} + + + ))} + + )} void; + inputToken: TokenWithBalance | null; + setInputToken: (token: TokenWithBalance | null) => void; - outputToken: EnrichedToken | null; - setOutputToken: (token: EnrichedToken | null) => void; + outputToken: TokenWithBalance | null; + setOutputToken: (token: TokenWithBalance | null) => void; isQuoteLoading: boolean; expectedOutputAmount: string | undefined; @@ -127,9 +127,9 @@ const TokenInput = ({ toAccountManagement, destinationChainEcosystem, }: { - setToken: (token: EnrichedToken) => void; - setOtherToken: (token: EnrichedToken | null) => void; - token: EnrichedToken | null; + setToken: (token: TokenWithBalance) => void; + setOtherToken: (token: TokenWithBalance | null) => void; + token: TokenWithBalance | null; setAmount: (amount: BigNumber | null) => void; isOrigin: boolean; expectedAmount: string | undefined; @@ -137,7 +137,7 @@ const TokenInput = ({ isUpdateLoading: boolean; insufficientInputBalance?: boolean; disabled?: boolean; - otherToken?: EnrichedToken | null; + otherToken?: TokenWithBalance | null; unit: UnitType; setUnit: (unit: UnitType) => void; toAccountManagement: ToAccountManagement; diff --git a/src/hooks/useAvailableCrosschainRoutes.ts b/src/views/SwapAndBridge/hooks/useAvailableCrosschainRoutes.ts similarity index 86% rename from src/hooks/useAvailableCrosschainRoutes.ts rename to src/views/SwapAndBridge/hooks/useAvailableCrosschainRoutes.ts index 70dbcc6ce..1b2431077 100644 --- a/src/hooks/useAvailableCrosschainRoutes.ts +++ b/src/views/SwapAndBridge/hooks/useAvailableCrosschainRoutes.ts @@ -1,5 +1,5 @@ import { useQuery } from "@tanstack/react-query"; -import { useSwapTokens } from "./useSwapTokens"; +import { useSwapTokens } from "../../../hooks/useSwapTokens"; export type LifiToken = { chainId: number; @@ -10,18 +10,13 @@ export type LifiToken = { priceUSD: string; coinKey: string; logoURI: string; - routeSource: "bridge" | "swap"; -}; - -export type TokenInfo = { - chainId: number; - address: string; - symbol: string; + routeSource: ("bridge" | "swap")[]; + externalProjectId?: string; }; export type RouteFilterParams = { - inputToken?: TokenInfo | null; - outputToken?: TokenInfo | null; + inputToken?: LifiToken | null; + outputToken?: LifiToken | null; }; export default function useAvailableCrosschainRoutes( @@ -53,7 +48,7 @@ export default function useAvailableCrosschainRoutes( logoURI: token.logoURI || "", priceUSD: token.priceUsd || "0", // Use price from SwapToken, fallback to "0" if not available coinKey: token.symbol, - routeSource: "swap", + routeSource: ["swap"], }; if (!acc[chainId]) { diff --git a/src/views/SwapAndBridge/hooks/useBridgeRoutes.ts b/src/views/SwapAndBridge/hooks/useBridgeRoutes.ts new file mode 100644 index 000000000..c7a58eaa6 --- /dev/null +++ b/src/views/SwapAndBridge/hooks/useBridgeRoutes.ts @@ -0,0 +1,195 @@ +import { useQuery } from "@tanstack/react-query"; +import { TOKEN_SYMBOLS_MAP, CHAIN_IDs } from "@across-protocol/constants"; +import { LifiToken, RouteFilterParams } from "./useAvailableCrosschainRoutes"; +import { orderedTokenLogos } from "constants/tokens"; +import unknownLogo from "assets/icons/question-circle.svg"; +import { compareAddressesSimple, getConfig, getToken } from "utils"; +import { constants } from "ethers"; + +export type Route = { + fromChain: number; + toChain: number; + fromTokenAddress: string; + toTokenAddress: string; + fromTokenSymbol: string; + toTokenSymbol: string; + isNative: boolean; + l1TokenAddress: string; + externalProjectId?: string; +}; + +export type BridgeRoutesResult = { + routes: Route[]; + inputTokens: LifiToken[]; + outputTokens: LifiToken[]; + allTokens: LifiToken[]; + inputTokensByChain: Record; + outputTokensByChain: Record; +}; + +const config = getConfig(); + +export default function useBridgeRoutes(filterParams?: RouteFilterParams) { + const query = useQuery({ + queryKey: ["bridgeRoutes", filterParams], + queryFn: async () => { + const routesData = config.getEnabledRoutes(); + + // Filter routes that have externalProjectId + let filteredRoutes = routesData.filter( + (route) => route.externalProjectId + ); + + // Helper function to get the effective chainId for a route token + // This accounts for HyperCore mapping where hyperliquid routes map to HYPERCORE + const getEffectiveChainId = ( + route: Route, + isInputToken: boolean + ): number => { + if (isInputToken) { + return route.fromChain; + } + // For output tokens, if it's a hyperliquid route, use HYPERCORE chainId + return route.externalProjectId === "hyperliquid" + ? CHAIN_IDs.HYPERCORE + : route.toChain; + }; + + // Helper function to create a token from route data + const createTokenFromRoute = ( + route: Route, + isInputToken: boolean + ): LifiToken | null => { + const tokenSymbol = isInputToken + ? route.fromTokenSymbol + : route.toTokenSymbol; + const tokenInfo = getToken(tokenSymbol); + if (!tokenInfo) return null; + + const chainId = getEffectiveChainId(route, isInputToken); + + const address = isInputToken + ? route.isNative + ? constants.AddressZero + : route.fromTokenAddress + : route.toTokenAddress; + + return { + chainId, + address, + symbol: tokenSymbol, + name: tokenInfo.name, + decimals: tokenInfo.decimals, + logoURI: tokenInfo.logoURI, + priceUSD: "0", // TODO + coinKey: tokenSymbol, + routeSource: ["bridge"], + externalProjectId: route.externalProjectId, + }; + }; + + // Build input tokens: if outputToken is set, return tokens that can bridge to it + // Otherwise, return all unique input tokens + const inputTokenMap = new Map(); + + const routesForInputTokens = filterParams?.outputToken + ? filteredRoutes.filter((route) => { + // Use effective chainId for output token comparison + const effectiveOutputChainId = getEffectiveChainId(route, false); + return ( + effectiveOutputChainId === filterParams.outputToken?.chainId && + compareAddressesSimple( + route.toTokenAddress, + filterParams.outputToken?.address + ) && + route.toTokenSymbol === filterParams.outputToken?.symbol + ); + }) + : filteredRoutes; + + routesForInputTokens.forEach((route) => { + const token = createTokenFromRoute(route, true); + if (token) { + const key = `${token.chainId}-${token.address.toLowerCase()}-${token.symbol}`; + if (!inputTokenMap.has(key)) { + inputTokenMap.set(key, token); + } + } + }); + + // Build output tokens: if inputToken is set, return tokens that can be reached from it + // Otherwise, return all unique output tokens + const outputTokenMap = new Map(); + + const routesForOutputTokens = filterParams?.inputToken + ? filteredRoutes.filter( + (route) => + route.fromChain === filterParams.inputToken?.chainId && + compareAddressesSimple( + route.fromTokenAddress, + filterParams.inputToken?.address + ) && + route.fromTokenSymbol === filterParams.inputToken?.symbol + ) + : filteredRoutes; + + routesForOutputTokens.forEach((route) => { + const token = createTokenFromRoute(route, false); + if (token) { + // For HyperCore, deduplicate by symbol only (since all hyperliquid routes have the same destination token) + // For other chains, deduplicate by chainId + address + symbol + const key = + token.chainId === CHAIN_IDs.HYPERCORE + ? `${token.chainId}-${token.symbol}` + : `${token.chainId}-${token.address.toLowerCase()}-${token.symbol}`; + if (!outputTokenMap.has(key)) { + outputTokenMap.set(key, token); + } + } + }); + + const inputTokens = Array.from(inputTokenMap.values()); + const outputTokens = Array.from(outputTokenMap.values()); + + // Group tokens by chainId for inputTokensByChain + const inputTokensByChain = inputTokens.reduce( + (acc, token) => { + const chainId = String(token.chainId); + if (!acc[chainId]) { + acc[chainId] = []; + } + acc[chainId].push(token); + return acc; + }, + {} as Record + ); + + // Group tokens by chainId for outputTokensByChain + const outputTokensByChain = outputTokens.reduce( + (acc, token) => { + const chainId = String(token.chainId); + if (!acc[chainId]) { + acc[chainId] = []; + } + acc[chainId].push(token); + return acc; + }, + {} as Record + ); + + return { + routes: filteredRoutes, + inputTokens, + outputTokens, + inputTokensByChain, + outputTokensByChain, + allTokens: [...inputTokens, ...outputTokens], + }; + }, + }); + + return { + query, + filterParams, + }; +} diff --git a/src/views/SwapAndBridge/hooks/useDefaultRoute.ts b/src/views/SwapAndBridge/hooks/useDefaultRoute.ts index 902bfe79e..023410e9b 100644 --- a/src/views/SwapAndBridge/hooks/useDefaultRoute.ts +++ b/src/views/SwapAndBridge/hooks/useDefaultRoute.ts @@ -1,21 +1,23 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useConnectionEVM } from "hooks/useConnectionEVM"; -import { useEnrichedCrosschainBalances } from "hooks/useEnrichedCrosschainBalances"; import { CHAIN_IDs } from "utils"; -import { EnrichedToken } from "../components/ChainTokenSelector/ChainTokenSelectorModal"; import { useConnectionSVM } from "hooks/useConnectionSVM"; import { usePrevious } from "@uidotdev/usehooks"; +import { + useSwapAndBridgeTokens, + TokenWithBalance, +} from "./useSwapAndBridgeTokens"; type DefaultRoute = { - inputToken: EnrichedToken | null; - outputToken: EnrichedToken | null; + inputToken: TokenWithBalance | null; + outputToken: TokenWithBalance | null; }; export function useDefaultRoute(): DefaultRoute { const [defaultInputToken, setDefaultInputToken] = - useState(null); + useState(null); const [defaultOutputToken, setDefaultOutputToken] = - useState(null); + useState(null); const [hasSetInitial, setHasSetInitial] = useState(false); const [hasSetConnected, setHasSetConnected] = useState(false); @@ -23,16 +25,16 @@ export function useDefaultRoute(): DefaultRoute { useConnectionEVM(); const { isConnected: isConnectedSVM, chainId: chainIdSVM } = useConnectionSVM(); - const routeData = useEnrichedCrosschainBalances(); + const { data: routeData } = useSwapAndBridgeTokens(); const anyConnected = isConnectedEVM || isConnectedSVM; const previouslyConnected = usePrevious(anyConnected); const chainId = chainIdEVM || chainIdSVM; - const hasRouteData = Object.keys(routeData).length ? true : false; + const hasRouteData = Object.keys(routeData ?? {}).length ? true : false; const findUsdcToken = useCallback( (targetChainId: number) => { - const tokensOnChain = routeData[targetChainId] || []; + const tokensOnChain = (routeData ?? {})[targetChainId] || []; return tokensOnChain.find( (token) => token.symbol.toUpperCase() === "USDC" ); @@ -63,8 +65,8 @@ export function useDefaultRoute(): DefaultRoute { // only first connection - also check hasSetConnected to prevent infinite loop if (!previouslyConnected && anyConnected && chainId && !hasSetConnected) { - let inputToken: EnrichedToken | undefined; - let outputToken: EnrichedToken | undefined; + let inputToken: TokenWithBalance | undefined; + let outputToken: TokenWithBalance | undefined; if (chainId === CHAIN_IDs.ARBITRUM) { // Special case: If on Arbitrum, use Arbitrum -> Base diff --git a/src/views/SwapAndBridge/hooks/useSwapAndBridge.ts b/src/views/SwapAndBridge/hooks/useSwapAndBridge.ts index f314d2ea3..aba92b212 100644 --- a/src/views/SwapAndBridge/hooks/useSwapAndBridge.ts +++ b/src/views/SwapAndBridge/hooks/useSwapAndBridge.ts @@ -2,8 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { BigNumber } from "ethers"; import { AmountInputError } from "../../Bridge/utils"; -import useSwapQuote from "./useSwapQuote"; -import { EnrichedToken } from "../components/ChainTokenSelector/ChainTokenSelectorModal"; +import { useSwapAndBridgeQuote } from "./useSwapAndBridgeQuote"; import { useSwapApprovalAction, SwapApprovalData, @@ -13,16 +12,22 @@ import { BridgeButtonState } from "../components/ConfirmationButton"; import { useDebounce } from "@uidotdev/usehooks"; import { useDefaultRoute } from "./useDefaultRoute"; import { useHistory } from "react-router-dom"; -import { getEcosystem, getQuoteWarningMessage } from "utils"; +import { buildSearchParams, getEcosystem, getQuoteWarningMessage } from "utils"; import { useConnectionEVM } from "hooks/useConnectionEVM"; import { useConnectionSVM } from "hooks/useConnectionSVM"; import { useToAccount } from "views/Bridge/hooks/useToAccount"; +import { TokenWithBalance } from "./useSwapAndBridgeTokens"; +import { findEnabledRoute } from "views/Bridge/utils"; +import { DepositActionParams } from "views/Bridge/hooks/useBridgeAction/strategies/types"; +import useReferrer from "hooks/useReferrer"; +import { useAmplitude } from "hooks/useAmplitude"; +import { ampli, DepositNetworkMismatchProperties } from "ampli"; export type UseSwapAndBridgeReturn = { - inputToken: EnrichedToken | null; - outputToken: EnrichedToken | null; - setInputToken: (t: EnrichedToken | null) => void; - setOutputToken: (t: EnrichedToken | null) => void; + inputToken: TokenWithBalance | null; + outputToken: TokenWithBalance | null; + setInputToken: (t: TokenWithBalance | null) => void; + setOutputToken: (t: TokenWithBalance | null) => void; quickSwap: () => void; amount: BigNumber | null; @@ -30,7 +35,7 @@ export type UseSwapAndBridgeReturn = { isAmountOrigin: boolean; setIsAmountOrigin: (v: boolean) => void; // route - swapQuote: ReturnType["data"]; + swapQuote: ReturnType["data"]; isQuoteLoading: boolean; expectedInputAmount?: string; expectedOutputAmount?: string; @@ -60,8 +65,8 @@ export type UseSwapAndBridgeReturn = { }; export function useSwapAndBridge(): UseSwapAndBridgeReturn { - const [inputToken, setInputToken] = useState(null); - const [outputToken, setOutputToken] = useState(null); + const [inputToken, setInputToken] = useState(null); + const [outputToken, setOutputToken] = useState(null); const [amount, setAmount] = useState(null); const [isAmountOrigin, setIsAmountOrigin] = useState(true); @@ -82,6 +87,8 @@ export function useSwapAndBridge(): UseSwapAndBridgeReturn { } = useConnectionSVM(); const toAccountManagement = useToAccount(outputToken?.chainId); + const { referrer, integratorId } = useReferrer(); + const { addToAmpliQueue } = useAmplitude(); const originChainEcosystem = inputToken?.chainId ? getEcosystem(inputToken?.chainId) @@ -151,29 +158,123 @@ export function useSwapAndBridge(): UseSwapAndBridgeReturn { }, [outputToken]); const { - data: swapQuote, + data: swapQuote, // normalized quote isLoading: isQuoteLoading, error: quoteError, - } = useSwapQuote({ - origin: inputToken ? inputToken : null, - destination: outputToken ? outputToken : null, + bridgeQuoteResult, + } = useSwapAndBridgeQuote({ + inputToken: inputToken, + outputToken: outputToken, amount: debouncedAmount, isInputAmount: isAmountOrigin, depositor, recipient: toAccountManagement.currentRecipientAccount, }); - const approvalData: SwapApprovalData | undefined = useMemo(() => { - if (!swapQuote) return undefined; + const swapTxData: SwapApprovalData | undefined = useMemo(() => { + if (!swapQuote || swapQuote.quoteType !== "swap") return undefined; return { approvalTxns: swapQuote.approvalTxns, - swapTx: swapQuote.swapTx as any, - }; + swapTx: swapQuote.swapTx, + } as SwapApprovalData; }, [swapQuote]); + // Compute selectedRoute for bridge quotes (similar to useSwapAndBridgeQuote) + const selectedRouteForBridge = useMemo(() => { + if ( + !swapQuote || + swapQuote.quoteType !== "bridge" || + !inputToken || + !outputToken + ) { + return undefined; + } + + const toChain = outputToken.externalProjectId + ? undefined + : outputToken.chainId; + + return findEnabledRoute({ + inputTokenSymbol: inputToken.symbol, + outputTokenSymbol: outputToken.symbol, + fromChain: inputToken.chainId, + toChain, + externalProjectId: outputToken.externalProjectId, + type: "bridge", + }); + }, [swapQuote, inputToken, outputToken]); + + // Create bridgeTxData from bridgeQuoteResult when we have a bridge quote + const bridgeTxData: DepositActionParams | undefined = useMemo(() => { + if ( + !swapQuote || + swapQuote.quoteType !== "bridge" || + !bridgeQuoteResult || + !selectedRouteForBridge + ) { + return undefined; + } + + const transferQuote = bridgeQuoteResult.transferQuoteQuery.data; + if (!transferQuote || !transferQuote.quotedFees) { + return undefined; + } + + const { amountToBridgeAfterSwap, initialAmount, quotedFees, recipient } = + transferQuote; + + if (!amountToBridgeAfterSwap || !initialAmount || !recipient) { + return undefined; + } + + const depositArgs = { + initialAmount, + amount: amountToBridgeAfterSwap, + fromChain: selectedRouteForBridge.fromChain, + toChain: selectedRouteForBridge.toChain, + timestamp: quotedFees.quoteTimestamp, + referrer: referrer || "", + relayerFeePct: quotedFees.totalRelayFee.pct, + inputTokenAddress: selectedRouteForBridge.fromTokenAddress, + outputTokenAddress: selectedRouteForBridge.toTokenAddress, + inputTokenSymbol: selectedRouteForBridge.fromTokenSymbol, + outputTokenSymbol: selectedRouteForBridge.toTokenSymbol, + fillDeadline: quotedFees.fillDeadline, + isNative: selectedRouteForBridge.isNative, + toAddress: recipient, + exclusiveRelayer: quotedFees.exclusiveRelayer, + exclusivityDeadline: quotedFees.exclusivityDeadline, + integratorId, + externalProjectId: selectedRouteForBridge.externalProjectId, + }; + + const onNetworkMismatch = ( + networkMismatchProperties: DepositNetworkMismatchProperties + ) => { + addToAmpliQueue(() => { + ampli.depositNetworkMismatch(networkMismatchProperties); + }); + }; + + return { + depositArgs, + transferQuote, + selectedRoute: selectedRouteForBridge, + onNetworkMismatch, + }; + }, [ + swapQuote, + bridgeQuoteResult, + selectedRouteForBridge, + referrer, + integratorId, + addToAmpliQueue, + ]); + const approvalAction = useSwapApprovalAction( inputToken?.chainId || 0, - approvalData + swapTxData, + bridgeTxData ); const validation = useValidateSwapAndBridge( @@ -220,23 +321,36 @@ export function useSwapAndBridge(): UseSwapAndBridgeReturn { const txHash = await approvalAction.buttonActionHandler(); // Only navigate if we got a transaction hash (not empty string from wallet connection) if (txHash) { - history.push( - `/bridge-and-swap/${txHash}?originChainId=${inputToken?.chainId}&destinationChainId=${outputToken?.chainId}&inputTokenSymbol=${inputToken?.symbol}&outputTokenSymbol=${outputToken?.symbol}&referrer=` - ); + const url = + `/bridge-and-swap/${txHash}?` + + buildSearchParams({ + originChainId: swapQuote?.inputToken?.chainId || "", + destinationChainId: + (selectedRouteForBridge + ? selectedRouteForBridge.toChain + : swapQuote?.outputToken.chainId) ?? "", + inputTokenSymbol: swapQuote?.inputToken?.symbol || "", + outputTokenSymbol: swapQuote?.outputToken?.symbol || "", + externalProjectId: selectedRouteForBridge?.externalProjectId ?? "", + referrer: "", + }); + + history.push(url); } }, [ isOriginConnected, isRecipientSet, - originChainEcosystem, - destinationChainEcosystem, approvalAction, + originChainEcosystem, connectEVM, connectSVM, + destinationChainEcosystem, + swapQuote?.inputToken?.chainId, + swapQuote?.inputToken?.symbol, + swapQuote?.outputToken.chainId, + swapQuote?.outputToken?.symbol, + selectedRouteForBridge, history, - inputToken?.chainId, - inputToken?.symbol, - outputToken?.chainId, - outputToken?.symbol, ]); // Button state logic @@ -261,7 +375,7 @@ export function useSwapAndBridge(): UseSwapAndBridgeReturn { }, [buttonState]); const quoteWarningMessage = useMemo(() => { - return getQuoteWarningMessage(quoteError); + return getQuoteWarningMessage(quoteError || null); }, [quoteError]); const buttonLabel = useMemo(() => { @@ -272,6 +386,7 @@ export function useSwapAndBridge(): UseSwapAndBridgeReturn { // Show API error in button label if present if (quoteWarningMessage && buttonState === "apiError") { + // todo: parse suggested fees errors return quoteWarningMessage; } @@ -297,18 +412,26 @@ export function useSwapAndBridge(): UseSwapAndBridgeReturn { const buttonDisabled = useMemo( () => - approvalAction.buttonDisabled || + // Only check approvalAction.buttonDisabled for swap quotes + // For bridge quotes, approvalAction.buttonDisabled will be true because approvalData is undefined, + // but we should still allow the button to be enabled if we have a valid bridge quote + (swapQuote?.quoteType === "swap" + ? approvalAction.buttonDisabled + : false) || !!validation.error || !inputToken || !outputToken || !amount || - amount.lte(0), + amount.lte(0) || + // Ensure we have a valid quote (for both swap and bridge quotes) + !swapQuote, [ approvalAction.buttonDisabled, validation.error, inputToken, outputToken, amount, + swapQuote, ] ); @@ -347,7 +470,7 @@ export function useSwapAndBridge(): UseSwapAndBridgeReturn { isWrongNetwork: approvalAction.isWrongNetwork, isSubmitting: approvalAction.isButtonActionLoading, onConfirm, - quoteError, + quoteError: quoteError || null, quoteWarningMessage, }; } diff --git a/src/views/SwapAndBridge/hooks/useSwapAndBridgeQuote.ts b/src/views/SwapAndBridge/hooks/useSwapAndBridgeQuote.ts new file mode 100644 index 000000000..6dae1b087 --- /dev/null +++ b/src/views/SwapAndBridge/hooks/useSwapAndBridgeQuote.ts @@ -0,0 +1,444 @@ +import { useMemo } from "react"; +import { BigNumber, utils } from "ethers"; +import { useTransferQuote } from "views/Bridge/hooks/useTransferQuote"; +import { findEnabledRoute } from "views/Bridge/utils"; +import { TokenWithBalance } from "./useSwapAndBridgeTokens"; +import useSwapQuote from "./useSwapQuote"; +import { defaultSwapSlippage, fixedPointAdjustment } from "utils/constants"; +import { ConvertDecimals } from "utils/convertdecimals"; +import { SwapApprovalApiCallReturnType } from "utils/serverless-api/prod/swap-approval"; +import { TransferQuote } from "views/Bridge/hooks/useTransferQuote"; + +type Params = { + inputToken: TokenWithBalance | null; + outputToken: TokenWithBalance | null; + amount: BigNumber | null; + isInputAmount: boolean; + depositor: string | undefined; + recipient: string | undefined; +}; + +/** + * Fee item structure for display in UI + */ +export type FeeItem = { + label: string; + value: number; // Always USD value + description?: string; // Optional description for tooltip +}; + +/** + * Normalized quote type for swap and bridge + */ +export type NormalizedQuote = { + // Common fields + inputAmount: BigNumber; + expectedOutputAmount: BigNumber; + inputToken: { + address: string; + chainId: number; + symbol: string; + decimals: number; + }; + outputToken: { + address: string; + chainId: number; + symbol: string; + decimals: number; + }; + // Fees breakdown as array of fee items + fees: FeeItem[]; + // Estimated fill time in seconds + estimatedFillTimeSeconds?: number; + // Quote type + quoteType: "swap" | "bridge"; + // Original quote data for type-specific access + swapQuote?: SwapApprovalApiCallReturnType; + bridgeQuote?: TransferQuote; + // Approval data (only for swap quotes) + approvalTxns?: Array<{ + chainId: number; + to: string; + data: string; + }>; + swapTx?: SwapApprovalApiCallReturnType["swapTx"]; +}; + +/** + * - If either token is bridge-only, uses useTransferQuote (suggested-fees endpoint) + * - If both tokens are swap tokens, uses useSwapQuote + */ +export function useSwapAndBridgeQuote(params: Params) { + const { + inputToken, + outputToken, + amount, + isInputAmount, + depositor, + recipient, + } = params; + + // Check if either token is bridge-only + const isInputBridgeOnly = useMemo(() => { + if (!inputToken) return false; + return ( + inputToken.routeSource?.includes("bridge") && + !inputToken.routeSource?.includes("swap") + ); + }, [inputToken]); + + const isOutputBridgeOnly = useMemo(() => { + if (!outputToken) return false; + return ( + outputToken.routeSource?.includes("bridge") && + !outputToken.routeSource?.includes("swap") + ); + }, [outputToken]); + + const isBridgeOnlyRoute = isInputBridgeOnly || isOutputBridgeOnly; + + // Check if both tokens are swap tokens (neither is bridge-only) + const isSwapOnlyRoute = useMemo(() => { + if (!inputToken || !outputToken) return false; + return ( + !isInputBridgeOnly && + !isOutputBridgeOnly && + (inputToken.routeSource?.includes("swap") || + outputToken.routeSource?.includes("swap")) + ); + }, [inputToken, outputToken, isInputBridgeOnly, isOutputBridgeOnly]); + + // Construct SelectedRoute for bridge quotes + const selectedRoute = useMemo(() => { + if (!isBridgeOnlyRoute || !inputToken || !outputToken) return undefined; + + // Don't filter by toChain, use externalProjectId to match the route + const toChain = outputToken.externalProjectId + ? undefined + : outputToken.chainId; + + return findEnabledRoute({ + inputTokenSymbol: inputToken.symbol, + outputTokenSymbol: outputToken.symbol, + fromChain: inputToken.chainId, + toChain, + externalProjectId: outputToken.externalProjectId, + type: "bridge", + }); + }, [isBridgeOnlyRoute, inputToken, outputToken]); + + // Create a minimal route for useTransferQuote when selectedRoute is undefined + // The query will be disabled internally if the route is invalid + const fallbackRoute = useMemo(() => { + if (!inputToken || !outputToken) { + // Return a minimal valid route structure to prevent undefined errors + // This route won't be used since the query will be disabled + return { + fromChain: 1, + toChain: 1, + fromTokenSymbol: "", + toTokenSymbol: "", + fromTokenAddress: "", + toTokenAddress: "", + isNative: false, + l1TokenAddress: "", + fromSpokeAddress: "", + externalProjectId: undefined, + type: "bridge" as const, + }; + } + return { + fromChain: inputToken.chainId, + toChain: outputToken.chainId, + fromTokenSymbol: inputToken.symbol, + toTokenSymbol: outputToken.symbol, + fromTokenAddress: inputToken.address, + toTokenAddress: outputToken.address, + isNative: false, + l1TokenAddress: inputToken.address, + fromSpokeAddress: "", + externalProjectId: + inputToken.externalProjectId || outputToken.externalProjectId, + type: "bridge" as const, + }; + }, [inputToken, outputToken]); + + // Bridge quote (enabled if either token is bridge-only and route is found) + // Always pass a valid route to prevent undefined errors + const routeForBridgeQuote = selectedRoute || fallbackRoute; + // Only enable bridge quote if we have a bridge-only route + const shouldFetchBridgeQuote = + isBridgeOnlyRoute && !!selectedRoute && amount?.gt(0); + const bridgeQuoteResult = useTransferQuote( + routeForBridgeQuote, + amount || BigNumber.from(0), + defaultSwapSlippage, + depositor, + recipient, + shouldFetchBridgeQuote + ); + + // Swap quote (enabled only if both tokens are swap tokens, not bridge-only) + const swapQuoteResult = useSwapQuote({ + origin: inputToken, + destination: outputToken, + amount, + isInputAmount, + depositor, + recipient, + enabled: isSwapOnlyRoute && amount?.gt(0), + }); + + // Determine which quote to use and normalize the data + const normalizedQuote: NormalizedQuote | undefined = useMemo(() => { + // Reset quote if amount is empty/null/zero to prevent stale data + if (!amount || amount.isZero() || amount.lte(0)) { + return undefined; + } + + // Reset quote if tokens are missing to prevent stale data when tokens change + if (!inputToken || !outputToken) { + return undefined; + } + + if (isBridgeOnlyRoute && bridgeQuoteResult) { + const bridgeQuote = bridgeQuoteResult.transferQuoteQuery.data; + if (!bridgeQuote || !inputToken || !outputToken || !selectedRoute) + return undefined; + + // Extract fees from bridge quote and convert to USD + // Following the same logic as calcFeesForEstimatedTable + const fees: FeeItem[] = []; + let totalFeeUsd = 0; + let bridgeFeeUsd = 0; + + if ( + bridgeQuote.quotedFees && + bridgeQuote.quotePriceUSD && + bridgeQuote.quotedFees.lpFee?.total && + bridgeQuote.quotedFees.relayerCapitalFee?.total && + bridgeQuote.quotedFees.relayerGasFee?.total + ) { + const price = BigNumber.from(bridgeQuote.quotePriceUSD); + const bridgeTokenDecimals = inputToken.decimals || 18; + + // Convert BigNumber fees to token amounts + const lpFee = BigNumber.from(bridgeQuote.quotedFees.lpFee.total); + const capitalFee = BigNumber.from( + bridgeQuote.quotedFees.relayerCapitalFee.total + ); + const gasFee = BigNumber.from( + bridgeQuote.quotedFees.relayerGasFee.total + ); + + // Add these together for consistency with swap quotes + const bridgeFee = capitalFee.add(lpFee).add(gasFee); + + // Convert to USD using the same method as calcFeesForEstimatedTable and convertBridgeTokenToUsd: + // 1. Convert from token decimals to 18 decimals + // 2. Multiply by price (already in 18 decimals) + // 3. Divide by fixedPointAdjustment (10^18) + const convertToUsd = (amount: BigNumber) => { + const convertedAmount = ConvertDecimals( + bridgeTokenDecimals, + 18 + )(amount); + return price.mul(convertedAmount).div(fixedPointAdjustment); + }; + + const bridgeFeeUsdBn = convertToUsd(bridgeFee); + + // Convert BigNumber USD values to numbers for display + bridgeFeeUsd = Number(utils.formatEther(bridgeFeeUsdBn)); + + // For bridge-only routes, swapFeeUsd = 0, so totalFeeUsd = bridgeFeeUsd + totalFeeUsd = bridgeFeeUsd; + + // Build fees array + if (totalFeeUsd > 0) { + fees.push({ + label: "Total Fee", + value: totalFeeUsd, + description: "Sum of bridge and swap fees", + }); + } + if (bridgeFeeUsd > 0) { + fees.push({ + label: "Bridge Fee", + value: bridgeFeeUsd, + description: + "Includes relayer capital fees, LP fees, and destination gas fees", + }); + } + } + + // Get output amount from quoteForAnalytics (it has toAmount, not outputAmount) + // toAmount is already formatted as a string in token units (not wei) + const outputAmountStr = bridgeQuote.quoteForAnalytics?.toAmount || "0"; + const outputAmountWei = utils.parseUnits( + outputAmountStr, + outputToken.decimals || 18 + ); + + // Extract estimated fill time from bridge quote + // estimatedTime is a ConfirmationDepositTimeType object, extract seconds from estimatedFillTimeSec + const estimatedFillTimeSeconds = bridgeQuote.quotedFees + ?.estimatedFillTimeSec + ? bridgeQuote.quotedFees.estimatedFillTimeSec + : undefined; + + return { + inputAmount: bridgeQuote.initialAmount || amount, + expectedOutputAmount: outputAmountWei, + inputToken: { + address: inputToken.address, + chainId: inputToken.chainId, + symbol: inputToken.symbol, + decimals: inputToken.decimals, + }, + outputToken: { + address: outputToken.address, + chainId: outputToken.chainId, + symbol: outputToken.symbol, + decimals: outputToken.decimals, + }, + fees, + estimatedFillTimeSeconds, + quoteType: "bridge", + bridgeQuote, + }; + } else if (swapQuoteResult.data) { + const swapQuote = swapQuoteResult.data; + if (!swapQuote || !inputToken || !outputToken) return undefined; + + // Verify quote tokens match current tokens to prevent stale data when tokens change + if ( + swapQuote.inputToken.address.toLowerCase() !== + inputToken.address.toLowerCase() || + swapQuote.inputToken.chainId !== inputToken.chainId || + swapQuote.outputToken.address.toLowerCase() !== + outputToken.address.toLowerCase() || + swapQuote.outputToken.chainId !== outputToken.chainId + ) { + return undefined; + } + + // Extract fees from swap quote structure (as it was done in ConfirmationButton) + const fees: FeeItem[] = []; + let totalFeeUsd = 0; + let bridgeFeeUsd = 0; + let swapImpactUsd = 0; + let appFeeUsd = 0; + + if (swapQuote.fees?.total) { + // Total fee + totalFeeUsd = Number(swapQuote.fees.total.amountUsd || 0); + + // Bridge fee + if (swapQuote.fees.total.details?.bridge?.amountUsd) { + bridgeFeeUsd = Number(swapQuote.fees.total.details.bridge.amountUsd); + } + + // Swap impact + if (swapQuote.fees.total.details?.swapImpact?.amountUsd) { + swapImpactUsd = Number( + swapQuote.fees.total.details.swapImpact.amountUsd + ); + } + + // App fee (if present) + if (swapQuote.fees.total.details?.app?.amountUsd) { + appFeeUsd = Number(swapQuote.fees.total.details.app.amountUsd); + } + + // Build fees array + if (totalFeeUsd > 0) { + fees.push({ + label: "Total Fee", + value: totalFeeUsd, + description: "Sum of bridge and swap fees", + }); + } + if (bridgeFeeUsd > 0) { + fees.push({ + label: "Bridge Fee", + value: bridgeFeeUsd, + description: "Includes destination gas, relayer fees, and LP fees", + }); + } + if (swapImpactUsd > 0) { + fees.push({ + label: "Swap Impact", + value: swapImpactUsd, + description: + "Estimated price difference from pool depth and trade size", + }); + } + if (appFeeUsd > 0) { + fees.push({ + label: "App Fee", + value: appFeeUsd, + }); + } + } + + return { + inputAmount: BigNumber.from(swapQuote.inputAmount), + expectedOutputAmount: BigNumber.from(swapQuote.expectedOutputAmount), + inputToken: swapQuote.inputToken, + outputToken: swapQuote.outputToken, + fees, + estimatedFillTimeSeconds: swapQuote.expectedFillTime, + quoteType: "swap", + swapQuote, + approvalTxns: swapQuote.approvalTxns, + swapTx: swapQuote.swapTx, + }; + } + }, [ + isBridgeOnlyRoute, + bridgeQuoteResult, + inputToken, + outputToken, + amount, + selectedRoute, + swapQuoteResult.data, + ]); + + // Determine loading and error states + // Mimic the logic from useBridge.ts - check transferQuoteQuery, feesQuery, and conditionally + // check swapQuoteQuery/universalSwapQuoteQuery based on route type + // Also check if we don't have a quote yet (!transferQuote) when we should be fetching one + const transferQuote = bridgeQuoteResult.transferQuoteQuery.data; + const bridgeQuoteIsLoading = + shouldFetchBridgeQuote && + (bridgeQuoteResult.transferQuoteQuery.isLoading || + bridgeQuoteResult.feesQuery.isLoading || + (routeForBridgeQuote.type === "swap" + ? bridgeQuoteResult.swapQuoteQuery.isLoading + : false) || + ((routeForBridgeQuote.type === "universal-swap" + ? bridgeQuoteResult.universalSwapQuoteQuery.isLoading + : false) && + !transferQuote)); + + const isLoading = bridgeQuoteIsLoading || swapQuoteResult.isLoading; + + const error = + isBridgeOnlyRoute && selectedRoute + ? bridgeQuoteResult.transferQuoteQuery.error + : !isBridgeOnlyRoute + ? swapQuoteResult.error + : selectedRoute + ? undefined + : new Error("No route found for bridge-only tokens"); + + return { + data: normalizedQuote, + isLoading, + error, + // Expose underlying queries for advanced usage + bridgeQuoteResult: isBridgeOnlyRoute ? bridgeQuoteResult : undefined, + swapQuoteResult: !isBridgeOnlyRoute ? swapQuoteResult : undefined, + }; +} diff --git a/src/views/SwapAndBridge/hooks/useSwapAndBridgeTokens.ts b/src/views/SwapAndBridge/hooks/useSwapAndBridgeTokens.ts new file mode 100644 index 000000000..75c57e11a --- /dev/null +++ b/src/views/SwapAndBridge/hooks/useSwapAndBridgeTokens.ts @@ -0,0 +1,388 @@ +import { useQuery } from "@tanstack/react-query"; +import { useSwapTokens } from "hooks/useSwapTokens"; +import { LifiToken, RouteFilterParams } from "./useAvailableCrosschainRoutes"; +import useBridgeRoutes from "./useBridgeRoutes"; +import { useUserTokenBalances } from "../../../hooks/useUserTokenBalances"; +import { compareAddressesSimple } from "utils"; +import { BigNumber, utils } from "ethers"; + +/** + * Helper function to check if two tokens match by address, symbol, and chainId. + * Used for deduplication when merging swap and bridge tokens. + */ +type BasicToken = Pick; + +function tokensMatch(token1: BasicToken, token2: BasicToken): boolean { + return ( + compareAddressesSimple(token1.address, token2.address) && + token1.symbol === token2.symbol && + token1.chainId === token2.chainId + ); +} + +/** + * Token type that includes user balances. + * Used in components that need tokens with balance information but don't need unreachability status. + */ +export type TokenWithBalance = LifiToken & { + /** User's token balance as a BigNumber */ + balance: BigNumber; + /** User's token balance in USD */ + balanceUsd: number; + /** Optional external project ID (e.g., for bridge routes) */ + externalProjectId?: string; +}; + +/** + * Extended token type that includes unreachability status and user balances. + * This is the return type for tokens from useSwapAndBridgeTokens. + */ +export type InputToken = TokenWithBalance & { + /** Whether this token is unreachable given the current filter parameters */ + isUnreachable: boolean; +}; + +/** + * Parameters for filtering tokens in useSwapAndBridgeTokens. + * Extends RouteFilterParams with flags to indicate whether we're fetching input or output tokens. + */ +type Params = RouteFilterParams & { + /** + * If true, fetches input tokens (tokens that can be used as input for a route). + * When set, useBridgeRoutes will filter to only input tokens that can reach the specified outputToken. + */ + isInput?: boolean; + /** + * If true, fetches output tokens (tokens that can be received from a route). + * When set, useBridgeRoutes will filter to only output tokens that can be reached from the specified inputToken. + */ + isOutput?: boolean; +}; + +/** + * Hook that merges swap and bridge tokens, enriches them with balances, and marks unreachable tokens. + * + * This hook performs the following operations: + * 1. Fetches tokens from both swap pools (via useSwapTokens) and bridge routes (via useBridgeRoutes) + * 2. Merges and deduplicates tokens that exist in both pools + * 3. Marks tokens as unreachable based on: + * - Same chain check: tokens on the same chain as the other selected token + * - Bridge-only check: swap-only tokens when the output token is bridge-only + * 4. Enriches tokens with user balances from useUserTokenBalances + * + * @param filterParams - Optional parameters to filter tokens: + * - `inputToken`: When selecting output tokens, filters to tokens reachable from this input token + * - `outputToken`: When selecting input tokens, filters to tokens that can reach this output token + * - `isInput`: Set to true when fetching input tokens (e.g., in origin token selector) + * - `isOutput`: Set to true when fetching output tokens (e.g., in destination token selector) + * + * @returns A React Query result containing: + * - `data`: Record - Tokens grouped by chainId + * - Standard React Query properties: `isLoading`, `isError`, `error`, etc. + * + * @example + * // Fetch input tokens when USDC on HyperCore is selected as output + * const { data: inputTokens } = useSwapAndBridgeTokens({ + * outputToken: { chainId: 1337, address: "0x...", symbol: "USDC" }, + * isInput: true, + * }); + * // inputTokens[1] would contain all input tokens on chain 1 + * // Swap-only tokens will be marked as isUnreachable: true + * + * @example + * // Fetch output tokens when USDC on Ethereum is selected as input + * const { data: outputTokens } = useSwapAndBridgeTokens({ + * inputToken: { chainId: 1, address: "0x...", symbol: "USDC" }, + * isOutput: true, + * }); + * // outputTokens[1337] would contain all output tokens on HyperCore + * // Tokens on the same chain as input (chain 1) will be marked as isUnreachable: true + */ +export function useSwapAndBridgeTokens(filterParams?: Params) { + const swapTokensQuery = useSwapTokens(); + const { query: bridgeTokensQuery } = useBridgeRoutes(filterParams); + const tokenBalances = useUserTokenBalances(); + + return useQuery({ + queryKey: ["inputTokens", filterParams, tokenBalances.data], + queryFn: () => { + const swapTokens = swapTokensQuery.data || []; + + // Get bridge tokens based on whether we're fetching input or output tokens + const bridgeTokensByChain = filterParams?.isInput + ? bridgeTokensQuery.data?.inputTokensByChain + : bridgeTokensQuery.data?.outputTokensByChain; + + /** + * Step 1: Build swapTokensByChain + * Transform swap tokens from the API format (with addresses object) into a structure + * organized by chainId. Each swap token can exist on multiple chains. + */ + const swapTokensByChain = swapTokens.reduce( + (acc, token) => { + // Get the chainId from the addresses record (TokenInfo has addresses object) + const chainIds = token.addresses + ? Object.keys(token.addresses).map(Number) + : []; + + chainIds.forEach((chainId) => { + const address = token.addresses?.[chainId]; + if (!address) return; + + const mapped: LifiToken = { + chainId: chainId, + address: address, + name: token.name, + symbol: token.symbol, + decimals: token.decimals, + logoURI: token.logoURI || "", + priceUSD: token.priceUsd || "0", // Use price from SwapToken, fallback to "0" if not available + coinKey: token.symbol, + routeSource: ["swap"], + }; + + if (!acc[chainId]) { + acc[chainId] = []; + } + acc[chainId].push(mapped); + }); + + return acc; + }, + {} as Record> + ); + + /** + * Convert bridgeTokensByChain from string keys to number keys + * useBridgeRoutes returns chainIds as strings, but we need them as numbers for consistency + */ + const bridgeTokensByChainNumbered: Record> = {}; + if (bridgeTokensByChain) { + Object.keys(bridgeTokensByChain).forEach((chainIdStr) => { + const chainId = Number(chainIdStr); + bridgeTokensByChainNumbered[chainId] = + bridgeTokensByChain[chainIdStr]; + }); + } + + /** + * Step 2: Merge and deduplicate tokens + * Some tokens exist in both swap and bridge pools. We merge them by: + * - Preferring swap token data (has price information) + * - Combining routeSource arrays (e.g., ["bridge", "swap"]) + * - Using tokensMatch to identify duplicates + */ + const mergedTokensByChain: Record> = {}; + const allChainIds = new Set([ + ...Object.keys(swapTokensByChain).map(Number), + ...Object.keys(bridgeTokensByChainNumbered).map(Number), + ]); + + allChainIds.forEach((chainId) => { + const swapTokensForChain = swapTokensByChain[chainId] || []; + const bridgeTokensForChain = bridgeTokensByChainNumbered[chainId] || []; + const merged: InputToken[] = []; + + // Process swap tokens first (they have price data) + swapTokensForChain.forEach((swapToken) => { + // Check if there's a matching bridge token + const matchingBridgeToken = bridgeTokensForChain.find((bt) => + tokensMatch(swapToken, bt) + ); + + if (matchingBridgeToken) { + // Token exists in both pools - merge them + merged.push({ + ...swapToken, // Prefer swap token data (has price) + routeSource: ["bridge", "swap"], // Combine route sources + externalProjectId: matchingBridgeToken.externalProjectId, // Preserve externalProjectId from bridge token + isUnreachable: false, // Will be set in step 3 + balance: BigNumber.from(0), // Will be set in step 4 + balanceUsd: 0, // Will be set in step 4 + }); + } else { + // Token only in swap pool + merged.push({ + ...swapToken, + isUnreachable: false, // Will be set in step 3 + balance: BigNumber.from(0), // Will be set in step 4 + balanceUsd: 0, // Will be set in step 4 + }); + } + }); + + // Process bridge tokens that weren't already merged + bridgeTokensForChain.forEach((bridgeToken) => { + const alreadyMerged = merged.some((mt) => + tokensMatch(mt, bridgeToken) + ); + if (!alreadyMerged) { + merged.push({ + ...bridgeToken, + isUnreachable: false, // Will be set in step 3 + balance: BigNumber.from(0), // Will be set in step 4 + balanceUsd: 0, // Will be set in step 4 + }); + } + }); + + mergedTokensByChain[chainId] = merged; + }); + + /** + * Step 3: Mark tokens as unreachable + * Tokens are marked unreachable in two scenarios: + * 1. Same chain: tokens on the same chain as the other selected token (can't bridge to/from same chain) + * 2. Bridge-only output: when output token is bridge-only, swap-only input tokens are unreachable + */ + const outputTokenChainId = filterParams?.outputToken?.chainId; + const inputTokenChainId = filterParams?.inputToken?.chainId; + const outputToken = filterParams?.outputToken; + + /** + * Check if output token is bridge-only (not available in swap token pool). + * This is used to determine if swap-only input tokens should be marked as unreachable. + */ + let isOutputTokenBridgeOnly = false; + if (outputToken) { + // Check if output token exists in swap tokens + const outputTokenInSwap = swapTokens.some((st) => { + const chainIds = st.addresses + ? Object.keys(st.addresses).map(Number) + : []; + return ( + chainIds.includes(outputToken.chainId) && + st.addresses?.[outputToken.chainId] && + compareAddressesSimple( + st.addresses[outputToken.chainId], + outputToken.address + ) && + st.symbol === outputToken.symbol + ); + }); + + // If output token is not in swap tokens, check if it's reachable via bridge routes + if (!outputTokenInSwap && bridgeTokensQuery.data) { + // When outputToken is set in filterParams, useBridgeRoutes filters inputTokens + // to only those that can bridge to that outputToken. So if there are any + // inputTokens, it means the outputToken is reachable via bridge routes. + const bridgeInputTokens = bridgeTokensQuery.data.inputTokens || []; + const hasBridgeRoutesToOutput = bridgeInputTokens.length > 0; + + // Also check if the outputToken exists in the outputTokens list + // (this handles the case where outputToken might be in the list even if no inputToken is set) + const bridgeOutputTokens = bridgeTokensQuery.data.outputTokens || []; + const outputTokenInBridgeList = bridgeOutputTokens.find( + (bt) => + bt.chainId === outputToken.chainId && + compareAddressesSimple(bt.address, outputToken.address) && + bt.symbol === outputToken.symbol + ); + + // If there are bridge routes to this output token (indicated by inputTokens existing), + // or if the outputToken is in the bridge outputTokens list, it's bridge-only + isOutputTokenBridgeOnly = + hasBridgeRoutesToOutput || !!outputTokenInBridgeList; + } + } + + Object.keys(mergedTokensByChain).forEach((chainIdStr) => { + const chainId = Number(chainIdStr); + mergedTokensByChain[chainId] = mergedTokensByChain[chainId].map( + (token) => { + let isUnreachable = false; + + // (same chain check) + // When selecting input tokens: mark tokens on the same chain as outputToken as unreachable + // When selecting output tokens: mark tokens on the same chain as inputToken as unreachable + if ( + filterParams?.isInput && + outputTokenChainId && + token.chainId === outputTokenChainId + ) { + isUnreachable = true; + } else if ( + filterParams?.isOutput && + inputTokenChainId && + token.chainId === inputTokenChainId + ) { + isUnreachable = true; + } + + // (bridge only check) + // If outputToken is bridge only, mark all swap-only input tokens as unreachable + // This only applies when selecting input tokens (isInput: true) + if ( + filterParams?.isInput && + isOutputTokenBridgeOnly && + token.routeSource.includes("swap") && + !token.routeSource.includes("bridge") + ) { + // Token is swap-only and output is bridge-only + isUnreachable = true; + } + + return { + ...token, + isUnreachable, + }; + } + ); + }); + + /** + * Step 4: Enrich tokens with user balances + * For each token, find the corresponding balance from useUserTokenBalances and calculate: + * - balance: BigNumber representation of the token balance + * - balanceUsd: USD value of the balance (balance * priceUSD) + */ + const TokenWithBalancesByChain: Record> = {}; + Object.keys(mergedTokensByChain).forEach((chainIdStr) => { + const chainId = Number(chainIdStr); + const balancesForChain = tokenBalances.data?.balances.find( + (t) => t.chainId === String(chainId) + ); + + const tokens = mergedTokensByChain[chainId]; + const TokenWithBalances = tokens.map((t) => { + const balance = balancesForChain?.balances.find((b) => + compareAddressesSimple(b.address, t.address) + ); + return { + ...t, + balance: balance?.balance + ? BigNumber.from(balance.balance) + : BigNumber.from(0), + balanceUsd: + balance?.balance && t + ? Number( + utils.formatUnits( + BigNumber.from(balance.balance), + t.decimals + ) + ) * Number(t.priceUSD) + : 0, + }; + }); + + TokenWithBalancesByChain[chainId] = TokenWithBalances; + }); + + /** + * Step 5: Return enriched tokens grouped by chainId + * The result is a Record where keys are chainIds (numbers) and values are arrays of InputToken + */ + return TokenWithBalancesByChain; + }, + /** + * Query is enabled when: + * - Swap tokens are successfully loaded (required for merging) + * - Bridge routes query has completed (success or error - error means no bridge routes available) + * - Token balances query has completed (success or error - error means no balances available) + */ + enabled: + swapTokensQuery.isSuccess && + (bridgeTokensQuery.isSuccess || bridgeTokensQuery.isError) && + (tokenBalances.isSuccess || tokenBalances.isError), + }); +} diff --git a/src/views/SwapAndBridge/hooks/useSwapApprovalAction/factory.ts b/src/views/SwapAndBridge/hooks/useSwapApprovalAction/factory.ts index 314f1328b..16822b6ff 100644 --- a/src/views/SwapAndBridge/hooks/useSwapApprovalAction/factory.ts +++ b/src/views/SwapAndBridge/hooks/useSwapApprovalAction/factory.ts @@ -3,25 +3,34 @@ import { SwapApprovalActionStrategy, SwapApprovalData, } from "./strategies/types"; +import { DepositActionParams } from "views/Bridge/hooks/useBridgeAction/strategies/types"; export function createSwapApprovalActionHook( strategy: SwapApprovalActionStrategy ) { - return function useSwapApprovalAction(approvalData?: SwapApprovalData) { + return function useSwapApprovalAction( + approvalData?: SwapApprovalData, + bridgeTxData?: DepositActionParams + ) { const isConnected = strategy.isConnected(); const isWrongNetwork = approvalData ? strategy.isWrongNetwork(approvalData.swapTx.chainId) - : false; + : bridgeTxData + ? strategy.isWrongNetwork(bridgeTxData.selectedRoute.fromChain) + : false; const action = useMutation({ mutationFn: async () => { - if (!approvalData) throw new Error("Missing approval data"); - const txHash = await strategy.execute(approvalData); + if (!approvalData && !bridgeTxData) { + throw new Error("Missing approval data or bridge tx data"); + } + const txHash = await strategy.execute(approvalData, bridgeTxData); return txHash; }, }); - const buttonDisabled = !approvalData || (isConnected && action.isPending); + const buttonDisabled = + (!approvalData && !bridgeTxData) || (isConnected && action.isPending); return { isConnected, diff --git a/src/views/SwapAndBridge/hooks/useSwapApprovalAction/index.ts b/src/views/SwapAndBridge/hooks/useSwapApprovalAction/index.ts index f861b783e..4e782a513 100644 --- a/src/views/SwapAndBridge/hooks/useSwapApprovalAction/index.ts +++ b/src/views/SwapAndBridge/hooks/useSwapApprovalAction/index.ts @@ -5,10 +5,12 @@ import { EVMSwapApprovalActionStrategy } from "./strategies/evm"; import { SVMSwapApprovalActionStrategy } from "./strategies/svm"; import { getEcosystem } from "utils"; import { SwapApprovalData } from "./strategies/types"; +import { DepositActionParams } from "views/Bridge/hooks/useBridgeAction/strategies/types"; export function useSwapApprovalAction( originChainId: number, - approvalData?: SwapApprovalData + swapTxData?: SwapApprovalData, + bridgeTxData?: DepositActionParams ) { const connectionEVM = useConnectionEVM(); const connectionSVM = useConnectionSVM(); @@ -21,8 +23,8 @@ export function useSwapApprovalAction( ); return getEcosystem(originChainId) === "evm" - ? evmHook(approvalData) - : svmHook(approvalData); + ? evmHook(swapTxData, bridgeTxData) + : svmHook(swapTxData, bridgeTxData); } export type { SwapApprovalData } from "./strategies/types"; diff --git a/src/views/SwapAndBridge/hooks/useSwapApprovalAction/strategies/abstract.ts b/src/views/SwapAndBridge/hooks/useSwapApprovalAction/strategies/abstract.ts index ba311c19c..fbb1f99d3 100644 --- a/src/views/SwapAndBridge/hooks/useSwapApprovalAction/strategies/abstract.ts +++ b/src/views/SwapAndBridge/hooks/useSwapApprovalAction/strategies/abstract.ts @@ -1,5 +1,6 @@ import { useConnectionEVM } from "hooks/useConnectionEVM"; -import { SwapApprovalActionStrategy } from "./types"; +import { SwapApprovalActionStrategy, SwapApprovalData } from "./types"; +import { DepositActionParams } from "views/Bridge/hooks/useBridgeAction/strategies/types"; export abstract class AbstractSwapApprovalActionStrategy implements SwapApprovalActionStrategy @@ -9,7 +10,10 @@ export abstract class AbstractSwapApprovalActionStrategy abstract isConnected(): boolean; abstract isWrongNetwork(requiredChainId: number): boolean; abstract switchNetwork(requiredChainId: number): Promise; - abstract execute(approvalData: any): Promise; + abstract execute( + approvalData?: SwapApprovalData, + bridgeTxData?: DepositActionParams + ): Promise; async assertCorrectNetwork(requiredChainId: number) { const currentChainId = this.evmConnection.chainId; diff --git a/src/views/SwapAndBridge/hooks/useSwapApprovalAction/strategies/evm.ts b/src/views/SwapAndBridge/hooks/useSwapApprovalAction/strategies/evm.ts index 53ecaa6e7..0170d0637 100644 --- a/src/views/SwapAndBridge/hooks/useSwapApprovalAction/strategies/evm.ts +++ b/src/views/SwapAndBridge/hooks/useSwapApprovalAction/strategies/evm.ts @@ -1,6 +1,18 @@ import { AbstractSwapApprovalActionStrategy } from "./abstract"; import { useConnectionEVM } from "hooks/useConnectionEVM"; import { ApprovalTxn, SwapApprovalData, SwapTx } from "./types"; +import { utils } from "ethers"; +import { + acrossPlusMulticallHandler, + ChainId, + fixedPointAdjustment, + generateHyperLiquidPayload, + getSpokePoolAndVerifier, + getToken, + hyperLiquidBridge2Address, + sendDepositTx, +} from "utils"; +import { DepositActionParams } from "views/Bridge/hooks/useBridgeAction/strategies/types"; export class EVMSwapApprovalActionStrategy extends AbstractSwapApprovalActionStrategy { constructor(evmConnection: ReturnType) { @@ -63,13 +75,174 @@ export class EVMSwapApprovalActionStrategy extends AbstractSwapApprovalActionStr return tx.hash; } - async execute(approvalData: SwapApprovalData): Promise { + async execute( + swapTxData?: SwapApprovalData, + bridgeTxData?: DepositActionParams + ): Promise { try { - await this.approve(approvalData); - return await this.swap(approvalData); + if (swapTxData) { + return await this.executeSwapTx(swapTxData); + } else if (bridgeTxData) { + return await this.executeBridgeTx(bridgeTxData); + } + + throw new Error("No tx data to execute."); + } catch (e) { + console.error(e); + throw e; + } + } + + async executeBridgeTx(bridgeTxData: DepositActionParams): Promise { + try { + // Handle deposits to Hyperliquid + if (bridgeTxData.depositArgs.externalProjectId === "hyperliquid") { + return await this._sendHyperliquidDepositTx(bridgeTxData); + } + throw new Error( + "Only external project routes supported via direct bridge" + ); + } catch (e) { + console.error(e); + throw e; + } + } + + async executeSwapTx(swapTxData: SwapApprovalData): Promise { + try { + await this.approve(swapTxData); + return await this.swap(swapTxData); } catch (e) { console.error(e); throw e; } } + + /** + * We need to set up our crosschain message to the hyperliquid bridge with the following considerations: + * 1. Our recipient address is the default multicall handler + * 2. The recipient and the signer must be the same address + * 3. We will first transfer funds to the true recipient EoA + * 4. We must construct a payload to send to HL's Bridge2 contract + * 5. The user must sign this signature + */ + private async _sendHyperliquidDepositTx(params: DepositActionParams) { + const { depositArgs, transferQuote, selectedRoute } = params; + + if (!transferQuote?.quotedFees) { + throw new Error("'transferQuote.quotedFees' is required"); + } + + // For Hyperliquid, we need to switch to Arbitrum for permit signing + // then switch back to the source chain for the deposit + const message = await this._generateHyperliquidMessage(params); + + // Ensure we are on the correct network for the deposit + await this.assertCorrectNetwork(selectedRoute.fromChain); + + const { spokePool } = await getSpokePoolAndVerifier(selectedRoute); + const tx = await sendDepositTx( + this.getSigner(), + { + ...depositArgs, + inputTokenAddress: selectedRoute.fromTokenAddress, + outputTokenAddress: selectedRoute.toTokenAddress, + inputTokenSymbol: selectedRoute.fromTokenSymbol, + outputTokenSymbol: selectedRoute.toTokenSymbol, + fillDeadline: transferQuote.quotedFees.fillDeadline, + message, + toAddress: acrossPlusMulticallHandler[selectedRoute.toChain], + }, + spokePool, + params.onNetworkMismatch + ); + + return tx.hash; + } + + /** + * Generate Hyperliquid message with permit signature + * This method handles the network switching for permit signing + */ + private async _generateHyperliquidMessage(params: DepositActionParams) { + const { depositArgs, transferQuote, selectedRoute } = params; + + if (!this.evmConnection.signer) { + throw new Error("'signer' is required"); + } + + if (selectedRoute.externalProjectId !== "hyperliquid") { + throw new Error( + "'selectedRoute.externalProjectId' must be 'hyperliquid'" + ); + } + + if (!transferQuote || !transferQuote.quotedFees) { + throw new Error( + "'transferQuote' and 'transferQuote.quotedFees' are required" + ); + } + + // Store current chain to restore later + const currentChainId = this.evmConnection.chainId; + + // Switch to Arbitrum for permit signing (required for EIP-712 signature) + if (currentChainId !== ChainId.ARBITRUM) { + await this.evmConnection.setChain(ChainId.ARBITRUM); + } + + try { + // Subtract the relayer fee pct just like we do for our output token amount + const amount = depositArgs.amount.sub( + depositArgs.amount + .mul(depositArgs.relayerFeePct) + .div(fixedPointAdjustment) + ); + + // Build the payload + const hyperLiquidPayload = await generateHyperLiquidPayload( + this.evmConnection.signer, + depositArgs.toAddress, + amount + ); + + // Create a txn calldata for transfering amount to recipient + const erc20Interface = new utils.Interface([ + "function transfer(address to, uint256 amount) returns (bool)", + ]); + + const transferCalldata = erc20Interface.encodeFunctionData("transfer", [ + depositArgs.toAddress, + amount, + ]); + + // Encode Instructions struct directly + const message = utils.defaultAbiCoder.encode( + [ + "tuple(tuple(address target, bytes callData, uint256 value)[] calls, address fallbackRecipient)", + ], + [ + { + calls: [ + { + target: getToken("USDC").addresses![ChainId.ARBITRUM], + callData: transferCalldata, + value: 0, + }, + { + target: hyperLiquidBridge2Address, + callData: hyperLiquidPayload, + value: 0, + }, + ], + fallbackRecipient: depositArgs.toAddress, + }, + ] + ); + + return message; + } finally { + await this.evmConnection.setChain(selectedRoute.fromChain); + } + } } diff --git a/src/views/SwapAndBridge/hooks/useSwapApprovalAction/strategies/svm.ts b/src/views/SwapAndBridge/hooks/useSwapApprovalAction/strategies/svm.ts index 163be4e6a..5dc560467 100644 --- a/src/views/SwapAndBridge/hooks/useSwapApprovalAction/strategies/svm.ts +++ b/src/views/SwapAndBridge/hooks/useSwapApprovalAction/strategies/svm.ts @@ -3,6 +3,7 @@ import { AbstractSwapApprovalActionStrategy } from "./abstract"; import { useConnectionSVM } from "hooks/useConnectionSVM"; import { useConnectionEVM } from "hooks/useConnectionEVM"; import { SwapApprovalData, SwapTx } from "./types"; +import { DepositActionParams } from "views/Bridge/hooks/useBridgeAction/strategies/types"; export class SVMSwapApprovalActionStrategy extends AbstractSwapApprovalActionStrategy { constructor( @@ -44,9 +45,19 @@ export class SVMSwapApprovalActionStrategy extends AbstractSwapApprovalActionStr return signature; } - async execute(approvalData: SwapApprovalData): Promise { + async execute( + swapTxData?: SwapApprovalData, + bridgeTxData?: DepositActionParams + ): Promise { try { - return await this.swap(approvalData); + if (!swapTxData) { + throw new Error("No swap Tx data found"); + } + // SVM strategy doesn't support bridge transactions for now + if (bridgeTxData) { + throw new Error("Bridge transactions are not supported for SVM"); + } + return await this.swap(swapTxData); } catch (e) { console.error(e); throw e; diff --git a/src/views/SwapAndBridge/hooks/useSwapApprovalAction/strategies/types.ts b/src/views/SwapAndBridge/hooks/useSwapApprovalAction/strategies/types.ts index d2b77ec07..e13c8c686 100644 --- a/src/views/SwapAndBridge/hooks/useSwapApprovalAction/strategies/types.ts +++ b/src/views/SwapAndBridge/hooks/useSwapApprovalAction/strategies/types.ts @@ -1,3 +1,5 @@ +import { DepositActionParams } from "views/Bridge/hooks/useBridgeAction/strategies/types"; + export type ApprovalTxn = { chainId: number; to: string; @@ -28,5 +30,8 @@ export type SwapApprovalActionStrategy = { isConnected(): boolean; isWrongNetwork(requiredChainId: number): boolean; switchNetwork(requiredChainId: number): Promise; - execute(approvalData: SwapApprovalData): Promise; + execute( + approvalData?: SwapApprovalData, + bridgeTxData?: DepositActionParams + ): Promise; }; diff --git a/src/views/SwapAndBridge/hooks/useSwapQuote.ts b/src/views/SwapAndBridge/hooks/useSwapQuote.ts index 8ff561892..c0dd57ba5 100644 --- a/src/views/SwapAndBridge/hooks/useSwapQuote.ts +++ b/src/views/SwapAndBridge/hooks/useSwapQuote.ts @@ -23,6 +23,7 @@ type SwapQuoteParams = { refundAddress?: string; refundOnOrigin?: boolean; slippageTolerance?: number; + enabled?: boolean; }; const useSwapQuote = ({ @@ -35,6 +36,7 @@ const useSwapQuote = ({ refundAddress, depositor, refundOnOrigin = true, + enabled = true, }: SwapQuoteParams) => { const { data, isLoading, error } = useQuery({ queryKey: [ @@ -86,7 +88,7 @@ const useSwapQuote = ({ const data = await swapApprovalApiCall(params); return data; }, - enabled: !!origin?.address && !!destination?.address && !!amount, + enabled: enabled && !!origin?.address && !!destination?.address && !!amount, retry: 2, refetchInterval(query) { diff --git a/src/views/SwapAndBridge/hooks/useValidateSwapAndBridge.ts b/src/views/SwapAndBridge/hooks/useValidateSwapAndBridge.ts index 681615e21..2a0e76563 100644 --- a/src/views/SwapAndBridge/hooks/useValidateSwapAndBridge.ts +++ b/src/views/SwapAndBridge/hooks/useValidateSwapAndBridge.ts @@ -2,8 +2,8 @@ import { useMemo } from "react"; import { BigNumber } from "ethers"; import { AmountInputError } from "../../Bridge/utils"; -import { EnrichedToken } from "../components/ChainTokenSelector/ChainTokenSelectorModal"; import { validationErrorTextMap } from "views/Bridge/components/AmountInput"; +import { TokenWithBalance } from "./useSwapAndBridgeTokens"; export type ValidationResult = { error?: AmountInputError; @@ -14,8 +14,8 @@ export type ValidationResult = { export function useValidateSwapAndBridge( amount: BigNumber | null, isAmountOrigin: boolean, - inputToken: EnrichedToken | null, - outputToken: EnrichedToken | null, + inputToken: TokenWithBalance | null, + outputToken: TokenWithBalance | null, isConnected: boolean, swapQuoteInputAmount: BigNumber | undefined ): ValidationResult { @@ -74,8 +74,8 @@ export function useValidateSwapAndBridge( function getValidationErrorText(props: { validationError?: AmountInputError; - inputToken: EnrichedToken | null; - outputToken: EnrichedToken | null; + inputToken: TokenWithBalance | null; + outputToken: TokenWithBalance | null; }): string | undefined { if (!props.validationError) { return;