diff --git a/.changeset/dry-wasps-love.md b/.changeset/dry-wasps-love.md new file mode 100644 index 00000000000..a93181523df --- /dev/null +++ b/.changeset/dry-wasps-love.md @@ -0,0 +1,5 @@ +--- +"thirdweb": patch +--- + +More reliable list of chains shown in token selection UI in SwapWidget based on origin and destination chain selections diff --git a/packages/thirdweb/src/bridge/Chains.ts b/packages/thirdweb/src/bridge/Chains.ts index ead60c0f6d4..693fe29b5d8 100644 --- a/packages/thirdweb/src/bridge/Chains.ts +++ b/packages/thirdweb/src/bridge/Chains.ts @@ -54,12 +54,30 @@ import { ApiError } from "./types/Errors.js"; */ export async function chains(options: chains.Options): Promise { const { client } = options; - return withCache( async () => { const clientFetch = getClientFetch(client); const url = new URL(`${getThirdwebBaseUrl("bridge")}/v1/chains`); + if (options.testnet) { + url.searchParams.set("testnet", options.testnet.toString()); + } + + // set type or originChainId or destinationChainId + if ("type" in options && options.type) { + url.searchParams.set("type", options.type); + } else if ("originChainId" in options && options.originChainId) { + url.searchParams.set("originChainId", options.originChainId.toString()); + } else if ( + "destinationChainId" in options && + options.destinationChainId + ) { + url.searchParams.set( + "destinationChainId", + options.destinationChainId.toString(), + ); + } + const response = await clientFetch(url.toString()); if (!response.ok) { const errorJson = await response.json(); @@ -75,7 +93,7 @@ export async function chains(options: chains.Options): Promise { return data; }, { - cacheKey: "bridge-chains", + cacheKey: `bridge-chains-${JSON.stringify(options)}`, cacheTime: 1000 * 60 * 60 * 1, // 1 hours }, ); @@ -95,7 +113,32 @@ export declare namespace chains { type Options = { /** Your thirdweb client */ client: ThirdwebClient; - }; + + /** + * If true, returns only testnet chains. Defaults to false. + */ + testnet?: boolean; + } & ( + | { + /** + * setting type=origin: Returns all chains that can be used as origin, + * setting type=destination: Returns all chains that can be used as destination + */ + type?: "origin" | "destination"; + } + | { + /** + * setting originChainId=X: Returns destination chains reachable from chain X + */ + originChainId?: number; + } + | { + /** + * setting destinationChainId=X: Returns origin chains reachable from chain X + */ + destinationChainId?: number; + } + ); /** * Result returned from fetching supported bridge chains. diff --git a/packages/thirdweb/src/react/web/ui/Bridge/FundWallet.tsx b/packages/thirdweb/src/react/web/ui/Bridge/FundWallet.tsx index b0c133a0619..91fe38d892c 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/FundWallet.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/FundWallet.tsx @@ -50,7 +50,7 @@ import { WithHeader } from "./common/WithHeader.js"; import { useActiveWalletInfo } from "./swap-widget/hooks.js"; import { SelectToken } from "./swap-widget/select-token-ui.js"; import type { ActiveWalletInfo } from "./swap-widget/types.js"; -import { useBridgeChains } from "./swap-widget/use-bridge-chains.js"; +import { useBridgeChain } from "./swap-widget/use-bridge-chains.js"; type FundWalletProps = { /** @@ -212,6 +212,11 @@ export function FundWallet(props: FundWalletProps) { autoFocusCrossIcon={false} > setIsTokenSelectionOpen(false)} client={props.client} @@ -411,11 +416,11 @@ function TokenSection(props: { presetOptions: [number, number, number]; }) { const theme = useCustomTheme(); - const chainQuery = useBridgeChains(props.client); - const chain = chainQuery.data?.find( - (chain) => chain.chainId === props.selectedToken?.data?.chainId, - ); - + const chainQuery = useBridgeChain({ + chainId: props.selectedToken?.data?.chainId, + client: props.client, + }); + const chain = chainQuery.data; const fiatPricePerToken = props.selectedToken?.data?.prices[props.currency]; const { fiatValue, tokenValue } = getAmounts( diff --git a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/SwapWidget.tsx b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/SwapWidget.tsx index 0aef7b9f934..87e57d1824a 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/SwapWidget.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/SwapWidget.tsx @@ -366,7 +366,7 @@ function SwapWidgetContent( }, [buyToken, sellToken, isPersistEnabled]); // preload requests - useBridgeChains(props.client); + useBridgeChains({ client: props.client }); // if wallet suddenly disconnects, show screen 1 if (screen.id === "1:swap-ui" || !activeWalletInfo) { diff --git a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/select-chain.tsx b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/select-chain.tsx index 9f894de85f7..4a5e2db5ff0 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/select-chain.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/select-chain.tsx @@ -15,7 +15,7 @@ import { Skeleton } from "../../components/Skeleton.js"; import { Spacer } from "../../components/Spacer.js"; import { Text } from "../../components/text.js"; import { SearchInput } from "./SearchInput.js"; -import { useBridgeChains } from "./use-bridge-chains.js"; +import { useBridgeChainsWithFilters } from "./use-bridge-chains.js"; import { cleanedChainName } from "./utils.js"; type SelectBuyTokenProps = { @@ -24,13 +24,23 @@ type SelectBuyTokenProps = { onSelectChain: (chain: BridgeChain) => void; selectedChain: BridgeChain | undefined; isMobile: boolean; + type: "buy" | "sell"; + selections: { + buyChainId: number | undefined; + sellChainId: number | undefined; + }; }; /** * @internal */ export function SelectBridgeChain(props: SelectBuyTokenProps) { - const chainQuery = useBridgeChains(props.client); + const chainQuery = useBridgeChainsWithFilters({ + client: props.client, + type: props.type, + buyChainId: props.selections.buyChainId, + sellChainId: props.selections.sellChainId, + }); return ( ( // biome-ignore lint/correctness/useJsxKeyInIterable: ok - + ))} {filteredChains.length === 0 && !props.isPending && ( @@ -148,18 +158,24 @@ export function SelectBridgeChainUI( ); } -function ChainButtonSkeleton() { +function ChainButtonSkeleton(props: { isMobile: boolean }) { + const iconSizeValue = props.isMobile ? iconSize.lg : iconSize.md; return (
- - + +
); } diff --git a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/select-token-ui.tsx b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/select-token-ui.tsx index 6c6f734b1e8..ef4673d256f 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/select-token-ui.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/select-token-ui.tsx @@ -26,7 +26,7 @@ import { SearchInput } from "./SearchInput.js"; import { SelectChainButton } from "./SelectChainButton.js"; import { SelectBridgeChain } from "./select-chain.js"; import type { ActiveWalletInfo, TokenSelection } from "./types.js"; -import { useBridgeChains } from "./use-bridge-chains.js"; +import { useBridgeChainsWithFilters } from "./use-bridge-chains.js"; import { type TokenBalance, useTokenBalances, @@ -43,6 +43,11 @@ type SelectTokenUIProps = { selectedToken: TokenSelection | undefined; setSelectedToken: (token: TokenSelection) => void; activeWalletInfo: ActiveWalletInfo | undefined; + type: "buy" | "sell"; + selections: { + buyChainId: number | undefined; + sellChainId: number | undefined; + }; }; function findChain(chains: BridgeChain[], activeChainId: number | undefined) { @@ -58,7 +63,13 @@ const INITIAL_LIMIT = 100; * @internal */ export function SelectToken(props: SelectTokenUIProps) { - const chainQuery = useBridgeChains(props.client); + const chainQuery = useBridgeChainsWithFilters({ + client: props.client, + type: props.type, + buyChainId: props.selections.buyChainId, + sellChainId: props.selections.sellChainId, + }); + const [search, _setSearch] = useState(""); const debouncedSearch = useDebouncedValue(search, 500); const [limit, setLimit] = useState(INITIAL_LIMIT); @@ -146,6 +157,11 @@ function SelectTokenUI( selectedToken: TokenSelection | undefined; setSelectedToken: (token: TokenSelection) => void; showMore: (() => void) | undefined; + type: "buy" | "sell"; + selections: { + buyChainId: number | undefined; + sellChainId: number | undefined; + }; }, ) { const isMobile = useIsMobile(); @@ -203,6 +219,8 @@ function SelectTokenUI( > setScreen("select-token")} client={props.client} isMobile={false} @@ -269,6 +287,8 @@ function SelectTokenUI( setScreen("select-token"); }} selectedChain={props.selectedChain} + type={props.type} + selections={props.selections} /> ); } @@ -467,16 +487,16 @@ function TokenSelectionScreen(props: { {!props.selectedChain && ( -
-
+ )} {props.selectedChain && ( diff --git a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/swap-ui.tsx b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/swap-ui.tsx index 81f48aee741..42abe822b8b 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/swap-ui.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/swap-ui.tsx @@ -22,6 +22,7 @@ import { } 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"; @@ -49,7 +50,7 @@ import type { SwapWidgetConnectOptions, TokenSelection, } from "./types.js"; -import { useBridgeChains } from "./use-bridge-chains.js"; +import { useBridgeChain } from "./use-bridge-chains.js"; type SwapUIProps = { activeWalletInfo: ActiveWalletInfo | undefined; @@ -226,8 +227,14 @@ export function SwapUI(props: SwapUIProps) { } }} > + {/* buy token modal */} {modalState.screen === "select-buy-token" && ( { setModalState((v) => ({ @@ -264,6 +271,7 @@ export function SwapUI(props: SwapUIProps) { /> )} + {/* sell token modal */} {modalState.screen === "select-sell-token" && ( { @@ -292,13 +300,22 @@ export function SwapUI(props: SwapUIProps) { token.tokenAddress.toLowerCase() !== NATIVE_TOKEN_ADDRESS.toLowerCase() ) { - props.setBuyToken({ - tokenAddress: getAddress(NATIVE_TOKEN_ADDRESS), - chainId: token.chainId, + // 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, + }} /> )} @@ -320,8 +337,12 @@ export function SwapUI(props: SwapUIProps) { /> )} - {/* Sell */} + {/* Sell token */} { if (sellTokenBalanceQuery.data) { props.setAmountSelection({ @@ -382,6 +403,10 @@ export function SwapUI(props: SwapUIProps) { {/* Buy */} { setDetailsModalOpen(true); @@ -663,6 +688,10 @@ function useSwapQuote(params: { function TokenSection(props: { type: "buy" | "sell"; + selection: { + buyChainId: number | undefined; + sellChainId: number | undefined; + }; amount: { data: string; isFetching: boolean; @@ -688,10 +717,12 @@ function TokenSection(props: { onMaxClick: (() => void) | undefined; }) { const theme = useCustomTheme(); - const chainQuery = useBridgeChains(props.client); - const chain = chainQuery.data?.find( - (chain) => chain.chainId === props.selectedToken?.data?.chainId, - ); + + 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 diff --git a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/use-bridge-chains.ts b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/use-bridge-chains.ts index 25e92f9414e..dedebb6cec0 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/use-bridge-chains.ts +++ b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/use-bridge-chains.ts @@ -2,11 +2,11 @@ import { useQuery } from "@tanstack/react-query"; import { chains } from "../../../../../bridge/index.js"; import type { ThirdwebClient } from "../../../../../client/client.js"; -export function useBridgeChains(client: ThirdwebClient) { +export function useBridgeChains(options: chains.Options) { return useQuery({ - queryKey: ["bridge-chains"], + queryKey: ["bridge-chains", options], queryFn: async () => { - const data = await chains({ client }); + const data = await chains(options); const dataCopy = [...data]; // sort by name, but if name starts with number, put it at the end @@ -29,3 +29,52 @@ export function useBridgeChains(client: ThirdwebClient) { refetchOnWindowFocus: false, }); } + +export function useBridgeChain({ + chainId, + client, +}: { + chainId: number | undefined; + client: ThirdwebClient; +}) { + const chainQuery = useBridgeChains({ client }); + return { + data: chainQuery.data?.find((chain) => chain.chainId === chainId), + isPending: chainQuery.isPending, + }; +} + +/** + * type=origin: Returns all chains that can be used as origin + * type=destination: Returns all chains that can be used as destination + * originChainId=X: Returns destination chains reachable from chain X + * destinationChainId=X: Returns origin chains that can reach chain X + */ + +// for fetching "buy" (destination) chains: +// if a "sell" (origin) chain is selected, set originChainId to fetch all "buy" (destination) chains that support given originChainId +// else - set type="destination" + +// for fetching "sell" (origin) chains: +// if a "buy" (destination) chain is selected, set destinationChainId to fetch all "sell" (origin) chains that support given destinationChainId +// else - set type="origin" + +export function useBridgeChainsWithFilters(options: { + client: ThirdwebClient; + buyChainId: number | undefined; + sellChainId: number | undefined; + type: "buy" | "sell"; +}) { + return useBridgeChains({ + client: options.client, + ...(options.type === "buy" + ? // type = buy + options.sellChainId + ? { originChainId: options.sellChainId } + : { type: "destination" } + : // type = sell + options.buyChainId + ? { destinationChainId: options.buyChainId } + : { type: "origin" }), + }); +} diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/constants.ts b/packages/thirdweb/src/react/web/ui/ConnectWallet/constants.ts index cbcf5cfd285..7b746c6e374 100644 --- a/packages/thirdweb/src/react/web/ui/ConnectWallet/constants.ts +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/constants.ts @@ -20,5 +20,5 @@ export const modalCloseFadeOutDuration = 250; * @internal */ export function onModalUnmount(cb: () => void) { - setTimeout(cb, modalCloseFadeOutDuration + 100); + setTimeout(cb, modalCloseFadeOutDuration + 200); } diff --git a/packages/thirdweb/src/stories/Bridge/Swap/SelectChain.stories.tsx b/packages/thirdweb/src/stories/Bridge/Swap/SelectChain.stories.tsx index a3877a2e19d..d64e7ea3ac5 100644 --- a/packages/thirdweb/src/stories/Bridge/Swap/SelectChain.stories.tsx +++ b/packages/thirdweb/src/stories/Bridge/Swap/SelectChain.stories.tsx @@ -23,6 +23,11 @@ export function WithDataDesktop() { return (