diff --git a/.changeset/sharp-symbols-unite.md b/.changeset/sharp-symbols-unite.md new file mode 100644 index 00000000000..5aa79e66f9b --- /dev/null +++ b/.changeset/sharp-symbols-unite.md @@ -0,0 +1,5 @@ +--- +"thirdweb": patch +--- + +Show deposit modal for tokens that don't have any UB routes diff --git a/apps/playground-web/src/components/pay/transaction-button.tsx b/apps/playground-web/src/components/pay/transaction-button.tsx index 152d9ee3bfd..f34df14ee83 100644 --- a/apps/playground-web/src/components/pay/transaction-button.tsx +++ b/apps/playground-web/src/components/pay/transaction-button.tsx @@ -2,7 +2,8 @@ import { useTheme } from "next-themes"; import { getContract } from "thirdweb"; -import { base, polygon } from "thirdweb/chains"; +import { prepareTransaction } from "thirdweb"; +import { base, baseSepolia, polygon } from "thirdweb/chains"; import { transfer } from "thirdweb/extensions/erc20"; import { claimTo, getNFT } from "thirdweb/extensions/erc1155"; import { @@ -12,6 +13,7 @@ import { useActiveAccount, useReadContract, } from "thirdweb/react"; +import { toWei } from "thirdweb/utils"; import { THIRDWEB_CLIENT } from "../../lib/client"; import { StyledConnectButton } from "../styled-connect-button"; @@ -99,6 +101,27 @@ export function PayTransactionButtonPreview() { > Buy VIP Pass +
+
Price: 0.1 ETH
+ { + if (!account) throw new Error("No active account"); + return prepareTransaction({ + chain: baseSepolia, + client: THIRDWEB_CLIENT, + to: account.address, + value: toWei("0.1"), + }); + }} + onError={(e) => { + console.error(e); + }} + payModal={{ + theme: theme === "light" ? "light" : "dark", + }} + > + Send 0.1 ETH +
)} diff --git a/apps/portal/src/app/pay/guides/build-a-custom-experience/page.mdx b/apps/portal/src/app/pay/guides/build-a-custom-experience/page.mdx index e3379f39141..1efe463a460 100644 --- a/apps/portal/src/app/pay/guides/build-a-custom-experience/page.mdx +++ b/apps/portal/src/app/pay/guides/build-a-custom-experience/page.mdx @@ -94,7 +94,7 @@ The `quote` object contains detailed transaction information including the estim -The `quote` object contains `quote.onRampToken` and `quote.toToken` objects containing intermediate and detination token information. +The `quote` object contains `quote.onRampToken` and `quote.toToken` objects containing intermediate and destination token information. If `quote.onRampToken` is not the same as `quote.toToken`, then your users will need to onramp to intermediary token before arriving at their destination token. diff --git a/packages/thirdweb/src/react/core/hooks/transaction/useSendTransaction.ts b/packages/thirdweb/src/react/core/hooks/transaction/useSendTransaction.ts index 387eb567514..3807597cf9b 100644 --- a/packages/thirdweb/src/react/core/hooks/transaction/useSendTransaction.ts +++ b/packages/thirdweb/src/react/core/hooks/transaction/useSendTransaction.ts @@ -104,6 +104,7 @@ export type SendTransactionConfig = { }; export type ShowModalData = { + mode: "buy" | "deposit"; tx: PreparedTransaction; sendTx: () => void; rejectTx: (reason: Error) => void; @@ -184,47 +185,6 @@ export function useSendTransactionCore(args: { resolvePromisedValue(tx.erc20Value), ]); - const supportedDestinations = await Bridge.routes({ - client: tx.client, - destinationChainId: tx.chain.id, - destinationTokenAddress: _erc20Value?.tokenAddress, - }).catch((err) => { - trackPayEvent({ - client: tx.client, - walletAddress: account.address, - walletType: wallet?.id, - toChainId: tx.chain.id, - event: "pay_transaction_modal_pay_api_error", - error: err?.message, - }); - return null; - }); - - if (!supportedDestinations) { - // could not fetch supported destinations, just send the tx - sendTx(); - return; - } - - if (supportedDestinations.length === 0) { - trackPayEvent({ - client: tx.client, - walletAddress: account.address, - walletType: wallet?.id, - toChainId: tx.chain.id, - toToken: _erc20Value?.tokenAddress || undefined, - event: "pay_transaction_modal_chain_token_not_supported", - error: JSON.stringify({ - chain: tx.chain.id, - token: _erc20Value?.tokenAddress, - message: "chain/token not supported", - }), - }); - // chain/token not supported, just send the tx - sendTx(); - return; - } - const nativeValue = _nativeValue || 0n; const erc20Value = _erc20Value?.amountWei || 0n; @@ -256,7 +216,54 @@ export function useSendTransactionCore(args: { (nativeCost > 0n && nativeBalance.value < nativeCost); if (shouldShowModal) { + const supportedDestinations = await Bridge.routes({ + client: tx.client, + destinationChainId: tx.chain.id, + destinationTokenAddress: _erc20Value?.tokenAddress, + }).catch((err) => { + trackPayEvent({ + client: tx.client, + walletAddress: account.address, + walletType: wallet?.id, + toChainId: tx.chain.id, + event: "pay_transaction_modal_pay_api_error", + error: err?.message, + }); + return null; + }); + + if ( + !supportedDestinations || + supportedDestinations.length === 0 + ) { + // not a supported destination -> show deposit screen + trackPayEvent({ + client: tx.client, + walletAddress: account.address, + walletType: wallet?.id, + toChainId: tx.chain.id, + toToken: _erc20Value?.tokenAddress || undefined, + event: "pay_transaction_modal_chain_token_not_supported", + error: JSON.stringify({ + chain: tx.chain.id, + token: _erc20Value?.tokenAddress, + message: "chain/token not supported", + }), + }); + + showPayModal({ + mode: "deposit", + tx, + sendTx, + rejectTx: reject, + resolveTx: resolve, + }); + return; + } + + // chain is supported, show buy mode showPayModal({ + mode: "buy", tx, sendTx, rejectTx: reject, diff --git a/packages/thirdweb/src/react/core/utils/wallet.ts b/packages/thirdweb/src/react/core/utils/wallet.ts index 5f96795c86d..6e0503bd753 100644 --- a/packages/thirdweb/src/react/core/utils/wallet.ts +++ b/packages/thirdweb/src/react/core/utils/wallet.ts @@ -244,6 +244,21 @@ export function hasSponsoredTransactionsEnabled(wallet: Wallet | undefined) { sponsoredTransactionsEnabled = smartOptions.gasless; } } + if (options?.executionMode) { + const execMode = options.executionMode; + if (execMode.mode === "EIP4337") { + const smartOptions = execMode.smartAccount; + if (smartOptions && "sponsorGas" in smartOptions) { + sponsoredTransactionsEnabled = smartOptions.sponsorGas; + } + if (smartOptions && "gasless" in smartOptions) { + sponsoredTransactionsEnabled = smartOptions.gasless; + } + } + if (execMode.mode === "EIP7702") { + sponsoredTransactionsEnabled = execMode.sponsorGas || false; + } + } } return sponsoredTransactionsEnabled; } diff --git a/packages/thirdweb/src/react/web/hooks/transaction/useSendTransaction.tsx b/packages/thirdweb/src/react/web/hooks/transaction/useSendTransaction.tsx index e20145079ba..6348602db0c 100644 --- a/packages/thirdweb/src/react/web/hooks/transaction/useSendTransaction.tsx +++ b/packages/thirdweb/src/react/web/hooks/transaction/useSendTransaction.tsx @@ -121,6 +121,7 @@ export function useSendTransaction(config: SendTransactionConfig = {}) { localeId={payModal?.locale || "en_US"} supportedTokens={payModal?.supportedTokens} theme={payModal?.theme || "dark"} + modalMode={data.mode} payOptions={{ buyWithCrypto: payModal?.buyWithCrypto, buyWithFiat: payModal?.buyWithFiat, diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/locale/en.ts b/packages/thirdweb/src/react/web/ui/ConnectWallet/locale/en.ts index 82f81a24cf1..fa5024fa817 100644 --- a/packages/thirdweb/src/react/web/ui/ConnectWallet/locale/en.ts +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/locale/en.ts @@ -68,7 +68,7 @@ const connectLocaleEn: ConnectLocale = { }, receiveFundsScreen: { title: "Receive Funds", - instruction: "Copy the wallet address to send funds to this wallet", + instruction: "Copy the address to send funds to this wallet", }, sendFundsScreen: { title: "Send Funds", diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/main/types.ts b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/main/types.ts index 3baf7a0e792..4677ef7f58f 100644 --- a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/main/types.ts +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/main/types.ts @@ -1,3 +1,4 @@ +import type { ChainMetadata } from "../../../../../../../chains/types.js"; import type { BuyWithCryptoQuote } from "../../../../../../../pay/buyWithCrypto/getQuote.js"; import type { BuyWithFiatQuote } from "../../../../../../../pay/buyWithFiat/getQuote.js"; import type { GetWalletBalanceResult } from "../../../../../../../wallets/utils/getWalletBalance.js"; @@ -9,6 +10,7 @@ export type TransactionCostAndData = { walletBalance: GetWalletBalanceResult; transactionValueWei: bigint; gasCostWei: bigint; + chainMetadata: ChainMetadata; }; export type SelectedScreen = diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/main/useBuyTxStates.ts b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/main/useBuyTxStates.ts index f913825b356..ed8a33dccd4 100644 --- a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/main/useBuyTxStates.ts +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/main/useBuyTxStates.ts @@ -18,6 +18,7 @@ export function useTransactionCostAndData(args: { transaction: PreparedTransaction; account: Account | undefined; supportedDestinations: SupportedChainAndTokens; + refetchIntervalMs?: number; }) { const { transaction, account, supportedDestinations } = args; // Compute query key of the transaction first @@ -62,22 +63,24 @@ export function useTransactionCostAndData(args: { const erc20Value = await resolvePromisedValue(transaction.erc20Value); if (erc20Value) { - const [tokenBalance, tokenMeta, gasCostWei] = await Promise.all([ - getWalletBalance({ - address: account.address, - chain: transaction.chain, - client: transaction.client, - tokenAddress: erc20Value.tokenAddress, - }), - getCurrencyMetadata({ - contract: getContract({ - address: erc20Value.tokenAddress, + const [tokenBalance, tokenMeta, gasCostWei, chainMetadata] = + await Promise.all([ + getWalletBalance({ + address: account.address, chain: transaction.chain, client: transaction.client, + tokenAddress: erc20Value.tokenAddress, }), - }), - getTransactionGasCost(transaction, account?.address), - ]); + getCurrencyMetadata({ + contract: getContract({ + address: erc20Value.tokenAddress, + chain: transaction.chain, + client: transaction.client, + }), + }), + getTransactionGasCost(transaction, account?.address), + getChainMetadata(transaction.chain), + ]); const transactionValueWei = erc20Value.amountWei; const walletBalance = tokenBalance; const currency = { @@ -95,6 +98,7 @@ export function useTransactionCostAndData(args: { return { token: currency, decimals: tokenMeta.decimals, + chainMetadata, walletBalance, gasCostWei, transactionValueWei, @@ -121,6 +125,7 @@ export function useTransactionCostAndData(args: { symbol: chainMetadata.nativeCurrency.symbol, icon: chainMetadata.icon?.url, }, + chainMetadata, decimals: 18, walletBalance, gasCostWei, @@ -128,12 +133,6 @@ export function useTransactionCostAndData(args: { } satisfies TransactionCostAndData; }, enabled: !!transaction && !!txQueryKey, - refetchInterval: () => { - if (transaction.erc20Value) { - // if erc20 value is set, we don't need to poll - return undefined; - } - return 30_000; - }, + refetchInterval: args.refetchIntervalMs || 30_000, }); } diff --git a/packages/thirdweb/src/react/web/ui/TransactionButton/DepositScreen.tsx b/packages/thirdweb/src/react/web/ui/TransactionButton/DepositScreen.tsx new file mode 100644 index 00000000000..899bfd21d80 --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/TransactionButton/DepositScreen.tsx @@ -0,0 +1,295 @@ +import { keyframes } from "@emotion/react"; +import type { ThirdwebClient } from "../../../../client/client.js"; +import type { PreparedTransaction } from "../../../../transaction/prepare-transaction.js"; +import { shortenAddress } from "../../../../utils/address.js"; +import { formatNumber } from "../../../../utils/formatNumber.js"; +import { toTokens } from "../../../../utils/units.js"; +import { useCustomTheme } from "../../../core/design-system/CustomThemeProvider.js"; +import { + fontSize, + iconSize, + radius, + spacing, +} from "../../../core/design-system/index.js"; +import { useActiveAccount } from "../../../core/hooks/wallets/useActiveAccount.js"; +import { useActiveWallet } from "../../../core/hooks/wallets/useActiveWallet.js"; +import { hasSponsoredTransactionsEnabled } from "../../../core/utils/wallet.js"; +import { ErrorState } from "../../wallets/shared/ErrorState.js"; +import { LoadingScreen } from "../../wallets/shared/LoadingScreen.js"; +import { CoinsIcon } from "../ConnectWallet/icons/CoinsIcon.js"; +import type { ConnectLocale } from "../ConnectWallet/locale/types.js"; +import { useTransactionCostAndData } from "../ConnectWallet/screens/Buy/main/useBuyTxStates.js"; +import { WalletRow } from "../ConnectWallet/screens/Buy/swap/WalletRow.js"; +import { formatTokenBalance } from "../ConnectWallet/screens/formatTokenBalance.js"; +import { isNativeToken } from "../ConnectWallet/screens/nativeToken.js"; +import { CopyIcon } from "../components/CopyIcon.js"; +import { QRCode } from "../components/QRCode.js"; +import { Skeleton } from "../components/Skeleton.js"; +import { Spacer } from "../components/Spacer.js"; +import { WalletImage } from "../components/WalletImage.js"; +import { Container, ModalHeader } from "../components/basic.js"; +import { Button } from "../components/buttons.js"; +import { Text } from "../components/text.js"; +import { TokenSymbol } from "../components/token/TokenSymbol.js"; +import { StyledButton, StyledDiv } from "../design-system/elements.js"; +import { useClipboard } from "../hooks/useCopyClipboard.js"; + +const pulseAnimation = keyframes` +0% { + opacity: 1; + transform: scale(0.5); +} +100% { + opacity: 0; + transform: scale(1.5); +} +`; + +const WaitingBadge = /* @__PURE__ */ StyledDiv(() => { + const theme = useCustomTheme(); + return { + display: "flex", + alignItems: "center", + gap: spacing.sm, + backgroundColor: theme.colors.tertiaryBg, + border: `1px solid ${theme.colors.borderColor}`, + padding: `${spacing.md} ${spacing.sm}`, + borderRadius: radius.lg, + color: theme.colors.secondaryText, + fontSize: fontSize.sm, + fontWeight: 500, + position: "relative" as const, + "&::before": { + content: '""', + width: "8px", + height: "8px", + borderRadius: "50%", + backgroundColor: theme.colors.accentText, + animation: `${pulseAnimation} 1s infinite`, + }, + }; +}); + +/** + * + * @internal + */ +export function DepositScreen(props: { + onBack: (() => void) | undefined; + connectLocale: ConnectLocale; + client: ThirdwebClient; + tx: PreparedTransaction; + onDone: () => void; +}) { + const activeWallet = useActiveWallet(); + const activeAccount = useActiveAccount(); + const address = activeAccount?.address; + const { hasCopied, onCopy } = useClipboard(address || ""); + const { connectLocale, client } = props; + const locale = connectLocale.receiveFundsScreen; + const isTestnet = props.tx.chain.testnet === true; + const { + data: transactionCostAndData, + error: transactionCostAndDataError, + isFetching: transactionCostAndDataFetching, + refetch: transactionCostAndDataRefetch, + } = useTransactionCostAndData({ + transaction: props.tx, + account: activeAccount, + supportedDestinations: [], + refetchIntervalMs: 10_000, + }); + const theme = useCustomTheme(); + const sponsoredTransactionsEnabled = + hasSponsoredTransactionsEnabled(activeWallet); + + if (transactionCostAndDataError) { + return ( + + + + ); + } + + if (!transactionCostAndData) { + return ; + } + + const totalCost = + isNativeToken(transactionCostAndData.token) && !sponsoredTransactionsEnabled + ? transactionCostAndData.transactionValueWei + + transactionCostAndData.gasCostWei + : transactionCostAndData.transactionValueWei; + const insufficientFunds = + transactionCostAndData.walletBalance.value < totalCost; + const requiredFunds = transactionCostAndData.walletBalance.value + ? totalCost - transactionCostAndData.walletBalance.value + : totalCost; + + const openFaucetLink = () => { + window.open( + `https://thirdweb.com/${props.tx.chain.id}?utm_source=ub_deposit`, + ); + }; + + return ( + + + + + + + {insufficientFunds && ( +
+ + You need{" "} + {formatNumber( + Number.parseFloat( + toTokens(requiredFunds, transactionCostAndData.decimals), + ), + 5, + )}{" "} + {transactionCostAndData.token.symbol} to continue + +
+ )} + + {activeAccount && ( + + )} + {transactionCostAndData.walletBalance.value !== undefined && + !transactionCostAndDataFetching ? ( + + + {formatTokenBalance( + transactionCostAndData.walletBalance, + false, + )} + + + + ) : ( + + + + )} + +
+ + + + + + ) + } + /> + + + + {address ? shortenAddress(address) : ""} + + + + + + + + + + {locale.instruction} + + + + + {insufficientFunds ? ( + + Waiting for funds on {transactionCostAndData.chainMetadata.name}... + + ) : ( + + )} + {insufficientFunds && isTestnet && ( + <> + + + + )} +
+ ); +} + +const WalletAddressContainer = /* @__PURE__ */ StyledButton((_) => { + const theme = useCustomTheme(); + return { + all: "unset", + width: "100%", + boxSizing: "border-box", + cursor: "pointer", + padding: spacing.md, + display: "flex", + justifyContent: "space-between", + border: `1px solid ${theme.colors.borderColor}`, + borderRadius: radius.lg, + transition: "border-color 200ms ease", + "&:hover": { + borderColor: theme.colors.accentText, + }, + }; +}); diff --git a/packages/thirdweb/src/react/web/ui/TransactionButton/ExecutingScreen.tsx b/packages/thirdweb/src/react/web/ui/TransactionButton/ExecutingScreen.tsx index c198dae127e..2d060e0692b 100644 --- a/packages/thirdweb/src/react/web/ui/TransactionButton/ExecutingScreen.tsx +++ b/packages/thirdweb/src/react/web/ui/TransactionButton/ExecutingScreen.tsx @@ -24,6 +24,7 @@ export function ExecutingTxScreen(props: { payModal: false, }); const [txHash, setTxHash] = useState(); + const [txError, setTxError] = useState(); const chainExplorers = useChainExplorers(props.tx.chain); const [status, setStatus] = useState<"loading" | "failed" | "sent">( "loading", @@ -31,6 +32,7 @@ export function ExecutingTxScreen(props: { const sendTx = useCallback(async () => { setStatus("loading"); + setTxError(undefined); try { const txData = await sendTxCore.mutateAsync(props.tx); setTxHash(txData.transactionHash); @@ -40,6 +42,7 @@ export function ExecutingTxScreen(props: { // Do not reject the transaction here, because the user may want to try again // we only reject on modal close console.error(e); + setTxError(e as Error); setStatus("failed"); } }, [sendTxCore, props.tx, props.onTxSent]); @@ -81,9 +84,7 @@ export function ExecutingTxScreen(props: { - {status === "failed" && sendTxCore.error - ? sendTxCore.error.message - : ""} + {status === "failed" && txError ? txError.message || "" : ""} diff --git a/packages/thirdweb/src/react/web/ui/TransactionButton/TransactionModal.tsx b/packages/thirdweb/src/react/web/ui/TransactionButton/TransactionModal.tsx index d2c2790f3ad..c76cb25644b 100644 --- a/packages/thirdweb/src/react/web/ui/TransactionButton/TransactionModal.tsx +++ b/packages/thirdweb/src/react/web/ui/TransactionButton/TransactionModal.tsx @@ -16,6 +16,7 @@ import { useConnectLocale } from "../ConnectWallet/locale/getConnectLocale.js"; import { LazyBuyScreen } from "../ConnectWallet/screens/Buy/LazyBuyScreen.js"; import { Modal } from "../components/Modal.js"; import type { LocaleId } from "../types.js"; +import { DepositScreen } from "./DepositScreen.js"; import { ExecutingTxScreen } from "./ExecutingScreen.js"; type ModalProps = { @@ -30,6 +31,7 @@ type ModalProps = { tx: PreparedTransaction; payOptions: PayUIOptions; onTxSent: (data: WaitForReceiptOptions) => void; + modalMode: "buy" | "deposit"; }; export function TransactionModal(props: ModalProps) { @@ -50,7 +52,10 @@ export function TransactionModal(props: ModalProps) { toToken: props.tx.erc20Value ? (await resolvePromisedValue(props.tx.erc20Value))?.tokenAddress : undefined, - event: "open_pay_transaction_modal", + event: + props.modalMode === "buy" + ? "open_pay_transaction_modal" + : "open_pay_deposit_modal", }); return null; @@ -93,6 +98,20 @@ function TransactionModalContent(props: ModalProps & { onBack?: () => void }) { ); } + if (props.modalMode === "deposit") { + return ( + { + setScreen("execute-tx"); + }} + /> + ); + } + return (