import styled from "@emotion/styled"; import { useQuery } from "@tanstack/react-query"; import { useState } from "react"; import type { prepare as BuyPrepare } from "../../../../../bridge/Buy.js"; import { Buy, Sell } from "../../../../../bridge/index.js"; import type { prepare as SellPrepare } from "../../../../../bridge/Sell.js"; import type { TokenWithPrices } from "../../../../../bridge/types/Token.js"; import type { ThirdwebClient } from "../../../../../client/client.js"; import { NATIVE_TOKEN_ADDRESS } from "../../../../../constants/addresses.js"; import type { SupportedFiatCurrency } from "../../../../../pay/convert/type.js"; import { getAddress } from "../../../../../utils/address.js"; import { toTokens, toUnits } from "../../../../../utils/units.js"; import { getDefaultWalletsForBridgeComponents } from "../../../../../wallets/defaultWallets.js"; import { useCustomTheme } from "../../../../core/design-system/CustomThemeProvider.js"; import { fontSize, iconSize, radius, spacing, type Theme, } from "../../../../core/design-system/index.js"; import type { BridgePrepareRequest } from "../../../../core/hooks/useBridgePrepare.js"; import { ConnectButton } from "../../ConnectWallet/ConnectButton.js"; import { onModalUnmount } from "../../ConnectWallet/constants.js"; import { DetailsModal } from "../../ConnectWallet/Details.js"; import { ArrowUpDownIcon } from "../../ConnectWallet/icons/ArrowUpDownIcon.js"; import connectLocaleEn from "../../ConnectWallet/locale/en.js"; import { PoweredByThirdweb } from "../../ConnectWallet/PoweredByTW.js"; import { formatCurrencyAmount, formatTokenAmount, } from "../../ConnectWallet/screens/formatTokenBalance.js"; import { Container } from "../../components/basic.js"; import { Button } from "../../components/buttons.js"; import { Modal } from "../../components/Modal.js"; import { Skeleton } from "../../components/Skeleton.js"; import { Spacer } from "../../components/Spacer.js"; import { Spinner } from "../../components/Spinner.js"; import { Text } from "../../components/text.js"; import { useIsMobile } from "../../hooks/useisMobile.js"; import { ActiveWalletDetails } from "../common/active-wallet-details.js"; import { DecimalInput } from "../common/decimal-input.js"; import { SelectedTokenButton } from "../common/selected-token-button.js"; import { useTokenBalance } from "../common/token-balance.js"; import { useTokenPrice } from "./hooks.js"; import { SelectToken } from "./select-token-ui.js"; import type { ActiveWalletInfo, SwapPreparedQuote, SwapWidgetConnectOptions, TokenSelection, } from "./types.js"; import { useBridgeChain } from "./use-bridge-chains.js"; type SwapUIProps = { activeWalletInfo: ActiveWalletInfo | undefined; client: ThirdwebClient; theme: Theme | "light" | "dark"; connectOptions: SwapWidgetConnectOptions | undefined; currency: SupportedFiatCurrency; showThirdwebBranding: boolean; onSwap: (data: { result: SwapPreparedQuote; request: BridgePrepareRequest; buyToken: TokenWithPrices; sellTokenBalance: bigint; sellToken: TokenWithPrices; mode: "buy" | "sell"; }) => void; buyToken: TokenSelection | undefined; sellToken: TokenSelection | undefined; setBuyToken: (token: TokenSelection | undefined) => void; setSellToken: (token: TokenSelection | undefined) => void; amountSelection: { type: "buy" | "sell"; amount: string; }; setAmountSelection: (amountSelection: { type: "buy" | "sell"; amount: string; }) => void; onDisconnect: (() => void) | undefined; }; /** * @internal */ export function SwapUI(props: SwapUIProps) { const [modalState, setModalState] = useState<{ screen: "select-buy-token" | "select-sell-token"; isOpen: boolean; }>({ screen: "select-buy-token", isOpen: false, }); const [detailsModalOpen, setDetailsModalOpen] = useState(false); const isMobile = useIsMobile(); // Token Prices ---------------------------------------------------------------------------- const buyTokenQuery = useTokenPrice({ token: props.buyToken, client: props.client, }); const sellTokenQuery = useTokenPrice({ token: props.sellToken, client: props.client, }); const buyTokenWithPrices = buyTokenQuery.data; const sellTokenWithPrices = sellTokenQuery.data; // Swap Quote ---------------------------------------------------------------------------- const preparedResultQuery = useSwapQuote({ amountSelection: props.amountSelection, buyTokenWithPrices: buyTokenWithPrices, sellTokenWithPrices: sellTokenWithPrices, activeWalletInfo: props.activeWalletInfo, client: props.client, }); // Amount and Amount.fetching ------------------------------------------------------------ const sellTokenAmount = props.amountSelection.type === "sell" ? props.amountSelection.amount : preparedResultQuery.data && props.amountSelection.type === "buy" && sellTokenWithPrices ? toTokens( preparedResultQuery.data.result.originAmount, sellTokenWithPrices.decimals, ) : ""; const buyTokenAmount = props.amountSelection.type === "buy" ? props.amountSelection.amount : preparedResultQuery.data && props.amountSelection.type === "sell" && buyTokenWithPrices ? toTokens( preparedResultQuery.data.result.destinationAmount, buyTokenWithPrices.decimals, ) : ""; // when buy amount is set, the sell amount is fetched const isBuyAmountFetching = props.amountSelection.type === "sell" && preparedResultQuery.isFetching; const isSellAmountFetching = props.amountSelection.type === "buy" && preparedResultQuery.isFetching; // token balances ------------------------------------------------------------ const sellTokenBalanceQuery = useTokenBalance({ chainId: sellTokenWithPrices?.chainId, tokenAddress: sellTokenWithPrices?.address, client: props.client, walletAddress: props.activeWalletInfo?.activeAccount.address, }); const buyTokenBalanceQuery = useTokenBalance({ chainId: buyTokenWithPrices?.chainId, tokenAddress: buyTokenWithPrices?.address, client: props.client, walletAddress: props.activeWalletInfo?.activeAccount.address, }); const notEnoughBalance = !!( sellTokenBalanceQuery.data && sellTokenWithPrices && props.amountSelection.amount && !!sellTokenAmount && sellTokenBalanceQuery.data.value < Number(toUnits(sellTokenAmount, sellTokenWithPrices.decimals)) ); // ---------------------------------------------------------------------------- const disableContinue = !preparedResultQuery.data || preparedResultQuery.isFetching || notEnoughBalance; return ( { if (!v) { setModalState((v) => ({ ...v, isOpen: false, })); } }} > {/* buy token modal */} {modalState.screen === "select-buy-token" && ( { setModalState((v) => ({ ...v, isOpen: false, })); }} client={props.client} selectedToken={props.buyToken} currency={props.currency} setSelectedToken={(token) => { props.setBuyToken(token); // if buy token is same as sell token, unset sell token if ( props.sellToken && token.tokenAddress.toLowerCase() === props.sellToken.tokenAddress.toLowerCase() && token.chainId === props.sellToken.chainId ) { props.setSellToken(undefined); } // if sell token is not selected, set it as native token of the buy token's chain if buy token is not a native token itself if ( !props.sellToken && token.tokenAddress.toLowerCase() !== NATIVE_TOKEN_ADDRESS.toLowerCase() ) { props.setSellToken({ tokenAddress: getAddress(NATIVE_TOKEN_ADDRESS), chainId: token.chainId, }); } }} /> )} {/* sell token modal */} {modalState.screen === "select-sell-token" && ( { setModalState((v) => ({ ...v, isOpen: false, })); }} client={props.client} selectedToken={props.sellToken} currency={props.currency} setSelectedToken={(token) => { props.setSellToken(token); // if sell token is same as buy token, unset buy token if ( props.buyToken && token.tokenAddress.toLowerCase() === props.buyToken.tokenAddress.toLowerCase() && token.chainId === props.buyToken.chainId ) { props.setBuyToken(undefined); } // if buy token is not selected, set it as native token of the sell token's chain if sell token is not a native token itself if ( !props.buyToken && token.tokenAddress.toLowerCase() !== NATIVE_TOKEN_ADDRESS.toLowerCase() ) { // set the buy token after a delay to avoid updating the "selections" prop passed to the component and trigger unnecessay fetch of chains query that will never be used // we have to do this because the modal does not close immediately onClose - it has a fade out animation onModalUnmount(() => { props.setBuyToken({ tokenAddress: getAddress(NATIVE_TOKEN_ADDRESS), chainId: token.chainId, }); }); } }} activeWalletInfo={props.activeWalletInfo} type="sell" selections={{ buyChainId: props.buyToken?.chainId, sellChainId: props.sellToken?.chainId, }} /> )} {detailsModalOpen && ( { setDetailsModalOpen(false); }} onDisconnect={() => { props.onDisconnect?.(); }} chains={[]} connectOptions={props.connectOptions} /> )} {/* Sell token */} { if (sellTokenBalanceQuery.data) { props.setAmountSelection({ type: "sell", amount: sellTokenBalanceQuery.data.displayValue, }); } }} activeWalletInfo={props.activeWalletInfo} isConnected={!!props.activeWalletInfo} balance={{ data: sellTokenBalanceQuery.data?.value, isFetching: sellTokenBalanceQuery.isFetching, }} amount={{ data: sellTokenAmount, isFetching: isSellAmountFetching, }} type="sell" setAmount={(value) => { props.setAmountSelection({ type: "sell", amount: value }); }} selectedToken={ props.sellToken ? { data: sellTokenQuery.data, isFetching: sellTokenQuery.isFetching, isError: sellTokenQuery.isError, } : undefined } client={props.client} currency={props.currency} onSelectToken={() => setModalState({ screen: "select-sell-token", isOpen: true, }) } onWalletClick={() => { setDetailsModalOpen(true); }} /> {/* Switch */} { // switch tokens const temp = props.sellToken; props.setSellToken(props.buyToken); props.setBuyToken(temp); props.setAmountSelection({ type: props.amountSelection.type === "buy" ? "sell" : "buy", amount: props.amountSelection.amount, }); }} /> {/* Buy */} { setDetailsModalOpen(true); }} activeWalletInfo={props.activeWalletInfo} isConnected={!!props.activeWalletInfo} balance={{ data: buyTokenBalanceQuery.data?.value, isFetching: buyTokenBalanceQuery.isFetching, }} amount={{ data: buyTokenAmount, isFetching: isBuyAmountFetching, }} type="buy" selectedToken={ props.buyToken ? { data: buyTokenQuery.data, isFetching: buyTokenQuery.isFetching, isError: buyTokenQuery.isError, } : undefined } setAmount={(value) => { props.setAmountSelection({ type: "buy", amount: value }); }} client={props.client} currency={props.currency} onSelectToken={() => setModalState({ screen: "select-buy-token", isOpen: true, }) } /> {/* error message */} {preparedResultQuery.error || buyTokenQuery.isError || sellTokenQuery.isError ? ( {preparedResultQuery.error ? preparedResultQuery.error.message || "Failed to get a quote" : buyTokenQuery.isError ? "Failed to fetch buy token details" : sellTokenQuery.isError ? "Failed to fetch sell token details" : "Failed to get a quote"} ) : ( )} {/* Button */} {!props.activeWalletInfo ? ( ) : ( )} {props.showThirdwebBranding ? (
) : null}
); } function useSwapQuote(params: { amountSelection: { type: "buy" | "sell"; amount: string; }; buyTokenWithPrices: TokenWithPrices | undefined; sellTokenWithPrices: TokenWithPrices | undefined; activeWalletInfo: ActiveWalletInfo | undefined; client: ThirdwebClient; }) { const { amountSelection, buyTokenWithPrices, sellTokenWithPrices, activeWalletInfo, client, } = params; return useQuery({ queryKey: [ "swap-quote", amountSelection, buyTokenWithPrices, sellTokenWithPrices, activeWalletInfo?.activeAccount.address, ], retry: false, enabled: !!buyTokenWithPrices && !!sellTokenWithPrices && !!amountSelection.amount, queryFn: async (): Promise< | { type: "preparedResult"; result: SwapPreparedQuote; request: Extract; } | { type: "quote"; result: Buy.quote.Result | Sell.quote.Result; } > => { if ( !buyTokenWithPrices || !sellTokenWithPrices || !amountSelection.amount ) { throw new Error("Invalid state"); } if (!activeWalletInfo) { if (amountSelection.type === "buy") { const res = await Buy.quote({ amount: toUnits( amountSelection.amount, buyTokenWithPrices.decimals, ), // origin = sell originChainId: sellTokenWithPrices.chainId, originTokenAddress: sellTokenWithPrices.address, // destination = buy destinationChainId: buyTokenWithPrices.chainId, destinationTokenAddress: buyTokenWithPrices.address, client: client, }); return { type: "quote", result: res, }; } const res = await Sell.quote({ amount: toUnits(amountSelection.amount, sellTokenWithPrices.decimals), // origin = sell originChainId: sellTokenWithPrices.chainId, originTokenAddress: sellTokenWithPrices.address, // destination = buy destinationChainId: buyTokenWithPrices.chainId, destinationTokenAddress: buyTokenWithPrices.address, client: client, }); return { type: "quote", result: res, }; } if (amountSelection.type === "buy") { const buyRequestOptions: BuyPrepare.Options = { amount: toUnits(amountSelection.amount, buyTokenWithPrices.decimals), // origin = sell originChainId: sellTokenWithPrices.chainId, originTokenAddress: sellTokenWithPrices.address, // destination = buy destinationChainId: buyTokenWithPrices.chainId, destinationTokenAddress: buyTokenWithPrices.address, client: client, receiver: activeWalletInfo.activeAccount.address, sender: activeWalletInfo.activeAccount.address, }; const buyRequest: BridgePrepareRequest = { type: "buy", ...buyRequestOptions, }; const res = await Buy.prepare(buyRequest); return { type: "preparedResult", result: { type: "buy", ...res }, request: buyRequest, }; } else if (amountSelection.type === "sell") { const sellRequestOptions: SellPrepare.Options = { amount: toUnits(amountSelection.amount, sellTokenWithPrices.decimals), // origin = sell originChainId: sellTokenWithPrices.chainId, originTokenAddress: sellTokenWithPrices.address, // destination = buy destinationChainId: buyTokenWithPrices.chainId, destinationTokenAddress: buyTokenWithPrices.address, client: client, receiver: activeWalletInfo.activeAccount.address, sender: activeWalletInfo.activeAccount.address, }; const res = await Sell.prepare(sellRequestOptions); const sellRequest: BridgePrepareRequest = { type: "sell", ...sellRequestOptions, }; return { type: "preparedResult", result: { type: "sell", ...res }, request: sellRequest, }; } throw new Error("Invalid amount selection type"); }, refetchInterval: 20000, }); } function TokenSection(props: { type: "buy" | "sell"; selection: { buyChainId: number | undefined; sellChainId: number | undefined; }; amount: { data: string; isFetching: boolean; }; setAmount: (amount: string) => void; activeWalletInfo: ActiveWalletInfo | undefined; selectedToken: | { data: TokenWithPrices | undefined; isFetching: boolean; isError: boolean; } | undefined; currency: SupportedFiatCurrency; onSelectToken: () => void; client: ThirdwebClient; isConnected: boolean; balance: { data: bigint | undefined; isFetching: boolean; }; onWalletClick: () => void; onMaxClick: (() => void) | undefined; }) { const theme = useCustomTheme(); const chainQuery = useBridgeChain({ chainId: props.selectedToken?.data?.chainId, client: props.client, }); const chain = chainQuery.data; const fiatPricePerToken = props.selectedToken?.data?.prices[props.currency]; const totalFiatValue = !props.amount.data ? undefined : fiatPricePerToken ? fiatPricePerToken * Number(props.amount.data) : undefined; return ( {/* make the background semi-transparent */} {/* row1 : label */} {props.type === "buy" ? "BUY" : "SELL"} {props.activeWalletInfo && ( )} {props.amount.isFetching ? (
) : ( )} {props.activeWalletInfo && props.onMaxClick && props.selectedToken && ( )}
{/* row3 : fiat value and balance */}
{props.amount.isFetching ? ( ) : ( {formatCurrencyAmount(props.currency, totalFiatValue || 0)} )}
{/* Balance */} {props.isConnected && props.selectedToken && (
{props.balance.data === undefined || props.selectedToken.data === undefined ? ( ) : (
Balance: {formatTokenAmount( props.balance.data, props.selectedToken.data.decimals, 5, )}
)}
)}
); } function SwitchButton(props: { onClick: () => void }) { return (
{ props.onClick(); const node = e.currentTarget.querySelector("svg"); if (node) { node.style.transform = "rotate(180deg)"; node.style.transition = "transform 300ms ease"; setTimeout(() => { node.style.transition = ""; node.style.transform = "rotate(0deg)"; }, 300); } }} >
); } const SwitchButtonInner = /* @__PURE__ */ styled(Button)(() => { const theme = useCustomTheme(); return { "&:hover": { background: theme.colors.secondaryButtonBg, }, borderRadius: radius.full, padding: spacing.xs, color: theme.colors.primaryText, background: theme.colors.modalBg, border: `1px solid ${theme.colors.borderColor}`, }; });