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 (