diff --git a/apps/playground-web/src/app/connect/pay/embed/page.tsx b/apps/playground-web/src/app/connect/pay/embed/page.tsx
index ccf7cef3e0d..4268cf37732 100644
--- a/apps/playground-web/src/app/connect/pay/embed/page.tsx
+++ b/apps/playground-web/src/app/connect/pay/embed/page.tsx
@@ -1,57 +1,37 @@
"use client";
import { use, useState } from "react";
import { NATIVE_TOKEN_ADDRESS } from "thirdweb";
-import { base } from "thirdweb/chains";
-import type { PayEmbedPlaygroundOptions } from "../components/types";
+import { arbitrum } from "thirdweb/chains";
+import { checksumAddress } from "thirdweb/utils";
+import type { BridgeComponentsPlaygroundOptions } from "../components/types";
import { LeftSection } from "./LeftSection";
import { RightSection } from "./RightSection";
// NOTE: Only set the values that are actually the default values used by PayEmbed component
-const defaultConnectOptions: PayEmbedPlaygroundOptions = {
+const defaultConnectOptions: BridgeComponentsPlaygroundOptions = {
theme: {
type: "dark",
darkColorOverrides: {},
lightColorOverrides: {},
},
payOptions: {
- mode: "fund_wallet",
+ widget: "buy",
title: "",
image: "",
- buyTokenAddress: NATIVE_TOKEN_ADDRESS,
- buyTokenAmount: "0.01",
- buyTokenChain: base,
- sellerAddress: "",
+ description: "",
+ buyTokenAddress: checksumAddress(NATIVE_TOKEN_ADDRESS),
+ buyTokenAmount: "0.002",
+ buyTokenChain: arbitrum,
+ sellerAddress: "0x0000000000000000000000000000000000000000",
transactionData: "",
- buyTokenInfo: undefined,
- buyWithCrypto: true,
- buyWithFiat: true,
- },
- connectOptions: {
- walletIds: [
- "io.metamask",
- "com.coinbase.wallet",
- "me.rainbow",
- "io.rabby",
- "io.zerion.wallet",
- ],
- modalTitle: undefined,
- modalTitleIcon: undefined,
- localeId: "en_US",
- enableAuth: false,
- termsOfServiceLink: undefined,
- privacyPolicyLink: undefined,
- enableAccountAbstraction: false,
- buttonLabel: undefined,
- ShowThirdwebBranding: true,
- requireApproval: false,
},
};
-export default function PayEmbedPlayground(props: {
+export default function BridgeComponentsPlayground(props: {
searchParams: Promise<{ tab: string }>;
}) {
const searchParams = use(props.searchParams);
- const [options, setOptions] = useState(
+ const [options, setOptions] = useState(
defaultConnectOptions,
);
diff --git a/apps/playground-web/src/app/connect/pay/fund-wallet/page.tsx b/apps/playground-web/src/app/connect/pay/fund-wallet/page.tsx
index 122ce8865d9..89e49d55264 100644
--- a/apps/playground-web/src/app/connect/pay/fund-wallet/page.tsx
+++ b/apps/playground-web/src/app/connect/pay/fund-wallet/page.tsx
@@ -1,7 +1,7 @@
import { PageLayout } from "@/components/blocks/APIHeader";
import { CodeExample } from "@/components/code/code-example";
-import { StyledPayEmbedPreview } from "@/components/pay/embed";
import ThirdwebProvider from "@/components/thirdweb-provider";
+import { StyledBuyWidgetPreview } from "@/components/universal-bridge/buy";
import { metadataBase } from "@/lib/constants";
import type { Metadata } from "next";
@@ -25,17 +25,17 @@ export default function Page() {
}
docsLink="https://portal.thirdweb.com/connect/pay/get-started?utm_source=playground"
>
-
+
);
}
-function StyledPayEmbed() {
+function StyledPayWidget() {
return (
Inline component that allows users to buy any currency.
@@ -44,26 +44,19 @@ function StyledPayEmbed() {
>
),
}}
- preview={}
+ preview={}
code={`\
-import { PayEmbed } from "thirdweb/react";
+import { BuyWidget } from "thirdweb/react";
function App() {
return (
-
+
);
}`}
lang="tsx"
diff --git a/apps/playground-web/src/app/connect/pay/transactions/page.tsx b/apps/playground-web/src/app/connect/pay/transactions/page.tsx
index 23fbd581a96..b1016de56cd 100644
--- a/apps/playground-web/src/app/connect/pay/transactions/page.tsx
+++ b/apps/playground-web/src/app/connect/pay/transactions/page.tsx
@@ -56,7 +56,6 @@ function BuyOnchainAsset() {
code={`import { claimTo } from "thirdweb/extensions/erc1155";
import { PayEmbed, useActiveAccount } from "thirdweb/react";
-
function App() {
const account = useActiveAccount();
const { data: nft } = useReadContract(getNFT, {
@@ -64,21 +63,20 @@ function BuyOnchainAsset() {
tokenId: 0n,
});
-
return (
-
+
);
};`}
lang="tsx"
diff --git a/apps/playground-web/src/app/navLinks.ts b/apps/playground-web/src/app/navLinks.ts
index dc8ce973c1e..e8270ffa447 100644
--- a/apps/playground-web/src/app/navLinks.ts
+++ b/apps/playground-web/src/app/navLinks.ts
@@ -98,7 +98,7 @@ const universalBridgeSidebarLinks: SidebarLink = {
href: "/connect/pay/fund-wallet",
},
{
- name: "Commerce",
+ name: "Checkout",
href: "/connect/pay/commerce",
},
{
diff --git a/apps/playground-web/src/components/pay/direct-payment.tsx b/apps/playground-web/src/components/pay/direct-payment.tsx
index c3120ab9e75..59bb3d22c52 100644
--- a/apps/playground-web/src/components/pay/direct-payment.tsx
+++ b/apps/playground-web/src/components/pay/direct-payment.tsx
@@ -1,28 +1,22 @@
"use client";
+import { toUnits } from "thirdweb";
import { base } from "thirdweb/chains";
-import { PayEmbed, getDefaultToken } from "thirdweb/react";
+import { CheckoutWidget } from "thirdweb/react";
import { THIRDWEB_CLIENT } from "../../lib/client";
export function BuyMerchPreview() {
return (
<>
-
>
);
diff --git a/apps/playground-web/src/components/pay/embed.tsx b/apps/playground-web/src/components/pay/embed.tsx
deleted file mode 100644
index 5fb83859ce8..00000000000
--- a/apps/playground-web/src/components/pay/embed.tsx
+++ /dev/null
@@ -1,40 +0,0 @@
-"use client";
-
-import { THIRDWEB_CLIENT } from "@/lib/client";
-import { useTheme } from "next-themes";
-import {
- arbitrum,
- arbitrumNova,
- base,
- defineChain,
- treasure,
-} from "thirdweb/chains";
-import { PayEmbed } from "thirdweb/react";
-import { StyledConnectButton } from "../styled-connect-button";
-
-export function StyledPayEmbedPreview() {
- const { theme } = useTheme();
-
- return (
-
-
-
-
-
- );
-}
diff --git a/apps/playground-web/src/components/pay/transaction-button.tsx b/apps/playground-web/src/components/pay/transaction-button.tsx
index f34df14ee83..3d4fd7e3b53 100644
--- a/apps/playground-web/src/components/pay/transaction-button.tsx
+++ b/apps/playground-web/src/components/pay/transaction-button.tsx
@@ -1,14 +1,13 @@
"use client";
import { useTheme } from "next-themes";
-import { getContract } from "thirdweb";
-import { prepareTransaction } from "thirdweb";
+import { getContract, prepareTransaction } from "thirdweb";
import { base, baseSepolia, polygon } from "thirdweb/chains";
import { transfer } from "thirdweb/extensions/erc20";
import { claimTo, getNFT } from "thirdweb/extensions/erc1155";
import {
- PayEmbed,
TransactionButton,
+ TransactionWidget,
getDefaultToken,
useActiveAccount,
useReadContract,
@@ -45,20 +44,18 @@ export function PayTransactionPreview() {
{account && (
-
)}
>
diff --git a/apps/playground-web/src/components/universal-bridge/buy.tsx b/apps/playground-web/src/components/universal-bridge/buy.tsx
new file mode 100644
index 00000000000..df6d4ca34a3
--- /dev/null
+++ b/apps/playground-web/src/components/universal-bridge/buy.tsx
@@ -0,0 +1,25 @@
+"use client";
+
+import { THIRDWEB_CLIENT } from "@/lib/client";
+import { useTheme } from "next-themes";
+import { NATIVE_TOKEN_ADDRESS, toWei } from "thirdweb";
+import { arbitrum } from "thirdweb/chains";
+import { BuyWidget } from "thirdweb/react";
+
+export function StyledBuyWidgetPreview() {
+ const { theme } = useTheme();
+
+ return (
+
+
+
+
+ );
+}
diff --git a/packages/thirdweb/package.json b/packages/thirdweb/package.json
index 921c7ed9996..f45e1a944a4 100644
--- a/packages/thirdweb/package.json
+++ b/packages/thirdweb/package.json
@@ -147,66 +147,26 @@
},
"typesVersions": {
"*": {
- "adapters/*": [
- "./dist/types/exports/adapters/*.d.ts"
- ],
- "auth": [
- "./dist/types/exports/auth.d.ts"
- ],
- "chains": [
- "./dist/types/exports/chains.d.ts"
- ],
- "contract": [
- "./dist/types/exports/contract.d.ts"
- ],
- "deploys": [
- "./dist/types/exports/deploys.d.ts"
- ],
- "event": [
- "./dist/types/exports/event.d.ts"
- ],
- "extensions/*": [
- "./dist/types/exports/extensions/*.d.ts"
- ],
- "pay": [
- "./dist/types/exports/pay.d.ts"
- ],
- "react": [
- "./dist/types/exports/react.d.ts"
- ],
- "react-native": [
- "./dist/types/exports/react.native.d.ts"
- ],
- "rpc": [
- "./dist/types/exports/rpc.d.ts"
- ],
- "storage": [
- "./dist/types/exports/storage.d.ts"
- ],
- "transaction": [
- "./dist/types/exports/transaction.d.ts"
- ],
- "utils": [
- "./dist/types/exports/utils.d.ts"
- ],
- "wallets": [
- "./dist/types/exports/wallets.d.ts"
- ],
- "wallets/*": [
- "./dist/types/exports/wallets/*.d.ts"
- ],
- "modules": [
- "./dist/types/exports/modules.d.ts"
- ],
- "social": [
- "./dist/types/exports/social.d.ts"
- ],
- "ai": [
- "./dist/types/exports/ai.d.ts"
- ],
- "bridge": [
- "./dist/types/exports/bridge.d.ts"
- ]
+ "adapters/*": ["./dist/types/exports/adapters/*.d.ts"],
+ "auth": ["./dist/types/exports/auth.d.ts"],
+ "chains": ["./dist/types/exports/chains.d.ts"],
+ "contract": ["./dist/types/exports/contract.d.ts"],
+ "deploys": ["./dist/types/exports/deploys.d.ts"],
+ "event": ["./dist/types/exports/event.d.ts"],
+ "extensions/*": ["./dist/types/exports/extensions/*.d.ts"],
+ "pay": ["./dist/types/exports/pay.d.ts"],
+ "react": ["./dist/types/exports/react.d.ts"],
+ "react-native": ["./dist/types/exports/react.native.d.ts"],
+ "rpc": ["./dist/types/exports/rpc.d.ts"],
+ "storage": ["./dist/types/exports/storage.d.ts"],
+ "transaction": ["./dist/types/exports/transaction.d.ts"],
+ "utils": ["./dist/types/exports/utils.d.ts"],
+ "wallets": ["./dist/types/exports/wallets.d.ts"],
+ "wallets/*": ["./dist/types/exports/wallets/*.d.ts"],
+ "modules": ["./dist/types/exports/modules.d.ts"],
+ "social": ["./dist/types/exports/social.d.ts"],
+ "ai": ["./dist/types/exports/ai.d.ts"],
+ "bridge": ["./dist/types/exports/bridge.d.ts"]
}
},
"browser": {
@@ -235,6 +195,7 @@
"@radix-ui/react-focus-scope": "1.1.7",
"@radix-ui/react-icons": "1.3.2",
"@radix-ui/react-tooltip": "1.2.7",
+ "@storybook/react": "9.0.8",
"@tanstack/react-query": "5.80.7",
"@thirdweb-dev/engine": "workspace:*",
"@thirdweb-dev/insight": "workspace:*",
diff --git a/packages/thirdweb/src/bridge/Routes.ts b/packages/thirdweb/src/bridge/Routes.ts
index d023c7d7e95..012a9fb42fc 100644
--- a/packages/thirdweb/src/bridge/Routes.ts
+++ b/packages/thirdweb/src/bridge/Routes.ts
@@ -131,6 +131,7 @@ export async function routes(options: routes.Options): Promise {
sortBy,
limit,
offset,
+ includePrices,
} = options;
const clientFetch = getClientFetch(client);
@@ -159,6 +160,9 @@ export async function routes(options: routes.Options): Promise {
if (sortBy) {
url.searchParams.set("sortBy", sortBy);
}
+ if (includePrices) {
+ url.searchParams.set("includePrices", includePrices.toString());
+ }
const response = await clientFetch(url.toString());
if (!response.ok) {
@@ -185,6 +189,7 @@ export declare namespace routes {
transactionHash?: ox__Hex.Hex;
sortBy?: "popularity";
maxSteps?: number;
+ includePrices?: boolean;
limit?: number;
offset?: number;
};
diff --git a/packages/thirdweb/src/bridge/Token.ts b/packages/thirdweb/src/bridge/Token.ts
index 68699915fb9..29a67e8d45f 100644
--- a/packages/thirdweb/src/bridge/Token.ts
+++ b/packages/thirdweb/src/bridge/Token.ts
@@ -158,7 +158,7 @@ export async function tokens(options: tokens.Options): Promise {
export declare namespace tokens {
/**
- * Input parameters for {@link Bridge.tokens}.
+ * Input parameters for {@link tokens}.
*/
type Options = {
/** Your {@link ThirdwebClient} instance. */
@@ -182,3 +182,84 @@ export declare namespace tokens {
*/
type Result = Token[];
}
+
+/**
+ * Adds a token to the Universal Bridge for indexing.
+ *
+ * This function requests the Universal Bridge to index a specific token on a given chain.
+ * Once indexed, the token will be available for cross-chain operations.
+ *
+ * @example
+ * ```typescript
+ * import { Bridge } from "thirdweb";
+ *
+ * // Add a token for indexing
+ * const result = await Bridge.add({
+ * client: thirdwebClient,
+ * chainId: 1,
+ * tokenAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", // USDC
+ * });
+ * ```
+ *
+ * @param options - The options for adding a token.
+ * @param options.client - Your thirdweb client.
+ * @param options.chainId - The chain ID where the token is deployed.
+ * @param options.tokenAddress - The contract address of the token to add.
+ *
+ * @returns A promise that resolves when the token has been successfully submitted for indexing.
+ *
+ * @throws Will throw an error if there is an issue adding the token.
+ * @bridge
+ * @beta
+ */
+export async function add(options: add.Options): Promise {
+ const { client, chainId, tokenAddress } = options;
+
+ const clientFetch = getClientFetch(client);
+ const url = `${getThirdwebBaseUrl("bridge")}/v1/tokens`;
+
+ const requestBody = {
+ chainId,
+ tokenAddress,
+ };
+
+ const response = await clientFetch(url, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(requestBody),
+ });
+
+ if (!response.ok) {
+ const errorJson = await response.json();
+ throw new ApiError({
+ code: errorJson.code || "UNKNOWN_ERROR",
+ message: errorJson.message || response.statusText,
+ correlationId: errorJson.correlationId || undefined,
+ statusCode: response.status,
+ });
+ }
+
+ const { data }: { data: Token } = await response.json();
+ return data;
+}
+
+export declare namespace add {
+ /**
+ * Input parameters for {@link add}.
+ */
+ type Options = {
+ /** Your {@link ThirdwebClient} instance. */
+ client: ThirdwebClient;
+ /** The chain ID where the token is deployed. */
+ chainId: number;
+ /** The contract address of the token to add. */
+ tokenAddress: string;
+ };
+
+ /**
+ * The result returned from {@link Bridge.add}.
+ */
+ type Result = Token;
+}
diff --git a/packages/thirdweb/src/bridge/types/BridgeAction.ts b/packages/thirdweb/src/bridge/types/BridgeAction.ts
index 7a83d5c9d3a..ffc4d58a31e 100644
--- a/packages/thirdweb/src/bridge/types/BridgeAction.ts
+++ b/packages/thirdweb/src/bridge/types/BridgeAction.ts
@@ -1 +1 @@
-export type Action = "approval" | "transfer" | "buy" | "sell";
+export type Action = "approval" | "transfer" | "buy" | "sell" | "fee";
diff --git a/packages/thirdweb/src/bridge/types/Errors.ts b/packages/thirdweb/src/bridge/types/Errors.ts
index ffa8da23164..f7f2074da20 100644
--- a/packages/thirdweb/src/bridge/types/Errors.ts
+++ b/packages/thirdweb/src/bridge/types/Errors.ts
@@ -1,3 +1,5 @@
+import { stringify } from "../../utils/json.js";
+
type ErrorCode =
| "INVALID_INPUT"
| "ROUTE_NOT_FOUND"
@@ -22,4 +24,13 @@ export class ApiError extends Error {
this.correlationId = args.correlationId;
this.statusCode = args.statusCode;
}
+
+ override toString() {
+ return stringify({
+ code: this.code,
+ message: this.message,
+ statusCode: this.statusCode,
+ correlationId: this.correlationId,
+ });
+ }
}
diff --git a/packages/thirdweb/src/exports/react.ts b/packages/thirdweb/src/exports/react.ts
index 5434611744b..dc3a0fe36f5 100644
--- a/packages/thirdweb/src/exports/react.ts
+++ b/packages/thirdweb/src/exports/react.ts
@@ -130,6 +130,22 @@ export type { AutoConnectProps } from "../wallets/connection/types.js";
// auth
export type { SiweAuthOptions } from "../react/core/hooks/auth/useSiweAuth.js";
+export {
+ BuyWidget,
+ type BuyWidgetProps,
+} from "../react/web/ui/Bridge/BuyWidget.js";
+export {
+ CheckoutWidget,
+ type CheckoutWidgetProps,
+} from "../react/web/ui/Bridge/CheckoutWidget.js";
+export {
+ TransactionWidget,
+ type TransactionWidgetProps,
+} from "../react/web/ui/Bridge/TransactionWidget.js";
+export {
+ useBridgeRoutes,
+ type UseBridgeRoutesParams,
+} from "../react/core/hooks/useBridgeRoutes.js";
export {
PayEmbed,
type PayEmbedProps,
diff --git a/packages/thirdweb/src/pay/buyWithFiat/getQuote.ts b/packages/thirdweb/src/pay/buyWithFiat/getQuote.ts
index 2ce99e691be..d7c722f2b9f 100644
--- a/packages/thirdweb/src/pay/buyWithFiat/getQuote.ts
+++ b/packages/thirdweb/src/pay/buyWithFiat/getQuote.ts
@@ -291,9 +291,9 @@ export async function getBuyWithFiatQuote(
provider?: FiatProvider,
): "stripe" | "coinbase" | "transak" => {
switch (provider) {
- case "STRIPE":
+ case "stripe":
return "stripe";
- case "TRANSAK":
+ case "transak":
return "transak";
default: // default to coinbase when undefined or any other value
return "coinbase";
diff --git a/packages/thirdweb/src/pay/convert/cryptoToFiat.ts b/packages/thirdweb/src/pay/convert/cryptoToFiat.ts
index fc6b04b0d81..6b80fd3a353 100644
--- a/packages/thirdweb/src/pay/convert/cryptoToFiat.ts
+++ b/packages/thirdweb/src/pay/convert/cryptoToFiat.ts
@@ -2,7 +2,7 @@ import type { Address } from "abitype";
import type { Chain } from "../../chains/types.js";
import type { ThirdwebClient } from "../../client/client.js";
import { isAddress } from "../../utils/address.js";
-import { getTokenPrice } from "./get-token.js";
+import { getToken } from "./get-token.js";
import type { SupportedFiatCurrency } from "./type.js";
/**
@@ -73,11 +73,11 @@ export async function convertCryptoToFiat(
"Invalid fromTokenAddress. Expected a valid EVM contract address",
);
}
- const price = await getTokenPrice(client, fromTokenAddress, chain.id);
- if (!price) {
+ const token = await getToken(client, fromTokenAddress, chain.id);
+ if (token.priceUsd === 0) {
throw new Error(
`Error: Failed to fetch price for token ${fromTokenAddress} on chainId: ${chain.id}`,
);
}
- return { result: price * fromAmount };
+ return { result: token.priceUsd * fromAmount };
}
diff --git a/packages/thirdweb/src/pay/convert/fiatToCrypto.ts b/packages/thirdweb/src/pay/convert/fiatToCrypto.ts
index 82ab392727e..f0843f0974d 100644
--- a/packages/thirdweb/src/pay/convert/fiatToCrypto.ts
+++ b/packages/thirdweb/src/pay/convert/fiatToCrypto.ts
@@ -2,7 +2,7 @@ import type { Address } from "abitype";
import type { Chain } from "../../chains/types.js";
import type { ThirdwebClient } from "../../client/client.js";
import { isAddress } from "../../utils/address.js";
-import { getTokenPrice } from "./get-token.js";
+import { getToken } from "./get-token.js";
import type { SupportedFiatCurrency } from "./type.js";
/**
@@ -72,11 +72,11 @@ export async function convertFiatToCrypto(
if (!isAddress(to)) {
throw new Error("Invalid `to`. Expected a valid EVM contract address");
}
- const price = await getTokenPrice(client, to, chain.id);
- if (!price || price === 0) {
+ const token = await getToken(client, to, chain.id);
+ if (!token || token.priceUsd === 0) {
throw new Error(
`Error: Failed to fetch price for token ${to} on chainId: ${chain.id}`,
);
}
- return { result: fromAmount / price };
+ return { result: fromAmount / token.priceUsd };
}
diff --git a/packages/thirdweb/src/pay/convert/get-token.ts b/packages/thirdweb/src/pay/convert/get-token.ts
index 6ec2eace996..351f7cdce9d 100644
--- a/packages/thirdweb/src/pay/convert/get-token.ts
+++ b/packages/thirdweb/src/pay/convert/get-token.ts
@@ -1,12 +1,13 @@
-import { tokens } from "../../bridge/Token.js";
+import { add, tokens } from "../../bridge/Token.js";
+import type { Token } from "../../bridge/types/Token.js";
import type { ThirdwebClient } from "../../client/client.js";
import { withCache } from "../../utils/promise/withCache.js";
-export async function getTokenPrice(
+export async function getToken(
client: ThirdwebClient,
tokenAddress: string,
chainId: number,
-) {
+): Promise {
return withCache(
async () => {
const result = await tokens({
@@ -14,7 +15,19 @@ export async function getTokenPrice(
tokenAddress,
chainId,
});
- return result[0]?.priceUsd;
+ const token = result[0];
+ if (!token) {
+ // Attempt to add the token
+ const tokenResult = await add({
+ client,
+ chainId,
+ tokenAddress,
+ }).catch(() => {
+ throw new Error("Token not supported");
+ });
+ return tokenResult;
+ }
+ return token;
},
{
cacheKey: `get-token-price-${tokenAddress}-${chainId}`,
diff --git a/packages/thirdweb/src/pay/utils/commonTypes.ts b/packages/thirdweb/src/pay/utils/commonTypes.ts
index 2f8fc723a4d..ef94c3fef4d 100644
--- a/packages/thirdweb/src/pay/utils/commonTypes.ts
+++ b/packages/thirdweb/src/pay/utils/commonTypes.ts
@@ -19,4 +19,4 @@ export type PayOnChainTransactionDetails = {
export type FiatProvider = (typeof FiatProviders)[number];
-export const FiatProviders = ["COINBASE", "STRIPE", "TRANSAK"] as const;
+export const FiatProviders = ["coinbase", "stripe", "transak"] as const;
diff --git a/packages/thirdweb/src/react/components.md b/packages/thirdweb/src/react/components.md
new file mode 100644
index 00000000000..7c47a245182
--- /dev/null
+++ b/packages/thirdweb/src/react/components.md
@@ -0,0 +1,134 @@
+# Web UI Components Catalog
+
+This document catalogs the UI components found within `packages/thirdweb/src/react/web/ui`.
+
+## Core Components (`packages/thirdweb/src/react/web/ui/components`)
+
+| Component | Occurrences |
+| ------------------- | ----------- |
+| Container | 100+ |
+| Text | 93 |
+| Spacer | 85 |
+| Button | 58 |
+| Skeleton | 40 |
+| ModalHeader | 40 |
+| Spinner | 31 |
+| Img | 31 |
+| Line | 28 |
+| ChainIcon | 19 |
+| TokenIcon | 16 |
+| Input | 11 |
+| SwitchNetworkButton | 10 |
+| WalletImage | 11 |
+| ToolTip | 6 |
+| Drawer | 5 |
+| QRCode | 5 |
+| CopyIcon | 4 |
+| ChainActiveDot | 3 |
+| Label | 3 |
+| ModalTitle | 3 |
+| TextDivider | 3 |
+| DynamicHeight | 3 |
+| StepBar | 2 |
+| IconContainer | 2 |
+| OTPInput | 2 |
+| ChainName | 1 |
+| BackButton | 1 |
+| IconButton | 1 |
+| ButtonLink | 1 |
+| Overlay | 1 |
+| Tabs | 1 |
+| FadeIn | 0 |
+| InputContainer | 0 |
+
+## Prebuilt Components (`packages/thirdweb/src/react/web/ui/prebuilt`)
+
+### NFT
+
+| Component | Occurrences (internal) |
+| -------------- | ---------------------- |
+| NFTName | 0 |
+| NFTMedia | 0 |
+| NFTDescription | 0 |
+| NFTProvider | 0 |
+
+### Account
+
+| Component | Occurrences (internal) |
+| -------------- | ---------------------- |
+| AccountBalance | 6 |
+| AccountAvatar | 2 |
+| AccountBlobbie | 4 |
+| AccountName | 2 |
+| AccountAddress | 4 |
+
+### Chain
+
+| Component | Occurrences (internal) |
+| ------------- | ---------------------- |
+| ChainName | 5 |
+| ChainIcon | 7 |
+| ChainProvider | 2 |
+
+### Token
+
+| Component | Occurrences (internal) |
+| ------------- | ---------------------- |
+| TokenName | 0 |
+| TokenSymbol | 12 |
+| TokenIcon | 7 |
+| TokenProvider | 0 |
+
+### Wallet
+
+| Component | Occurrences (internal) |
+| ---------- | ---------------------- |
+| WalletName | 0 |
+| WalletIcon | 0 |
+
+### Thirdweb
+
+| Component | Occurrences (internal) |
+| ------------------------- | ---------------------- |
+| ClaimButton | 0 |
+| BuyDirectListingButton | 0 |
+| CreateDirectListingButton | 0 |
+
+## Re-used Components (`packages/thirdweb/src/react/web/ui`)
+
+### Non-Core/Non-Prebuilt Components (ConnectWallet folder analysis)
+
+| Component | Occurrences | Source/Type |
+| ------------------------------ | ----------- | ---------------------------- |
+| LoadingScreen | 19 | Wallets shared component |
+| Suspense | 8 | React built-in |
+| WalletRow | 8 | Buy/swap utility component |
+| PoweredByThirdweb | 6 | Custom branding component |
+| Modal | 5 | Core UI component |
+| WalletUIStatesProvider | 4 | Wallet state management |
+| NetworkSelectorContent | 4 | Network selection component |
+| PayTokenIcon | 3 | Buy screen utility component |
+| FiatValue | 3 | Buy/swap utility component |
+| TOS | 3 | Terms of service component |
+| ErrorState | 3 | Error handling component |
+| AnimatedButton | 3 | Animation component |
+| ConnectModalContent | 3 | Modal content layout |
+| AnyWalletConnectUI | 2 | Wallet connection screen |
+| SmartConnectUI | 2 | Smart wallet connection UI |
+| WalletEntryButton | 2 | Wallet selection button |
+| TokenSelector | 2 | Token selection component |
+| SignatureScreen | 2 | Wallet signature screen |
+| WalletSwitcherConnectionScreen | 2 | Wallet switching UI |
+| ErrorText | 2 | Error display component |
+| SwapSummary | 2 | Swap transaction summary |
+| EstimatedTimeAndFees | 2 | Transaction info component |
+
+### Other Re-used Components
+
+| Component | Occurrences |
+| --------- | ----------- |
+| PayEmbed | 1 |
+| SiteEmbed | 0 |
+| SiteLink | 0 |
+
+**Note:** Occurrences are based on direct import and usage (e.g., `;
+}
diff --git a/packages/thirdweb/src/react/core/errors/.keep b/packages/thirdweb/src/react/core/errors/.keep
new file mode 100644
index 00000000000..fa0e58ded98
--- /dev/null
+++ b/packages/thirdweb/src/react/core/errors/.keep
@@ -0,0 +1,2 @@
+# Placeholder file to maintain directory structure
+# This directory will contain error mapping and normalization utilities
\ No newline at end of file
diff --git a/packages/thirdweb/src/react/core/errors/mapBridgeError.test.ts b/packages/thirdweb/src/react/core/errors/mapBridgeError.test.ts
new file mode 100644
index 00000000000..57c2d357243
--- /dev/null
+++ b/packages/thirdweb/src/react/core/errors/mapBridgeError.test.ts
@@ -0,0 +1,98 @@
+import { describe, expect, it } from "vitest";
+import { ApiError } from "../../../bridge/types/Errors.js";
+import { isRetryable, mapBridgeError } from "./mapBridgeError.js";
+
+describe("mapBridgeError", () => {
+ it("should return the same error for INVALID_INPUT", () => {
+ const error = new ApiError({
+ code: "INVALID_INPUT",
+ message: "Invalid input provided",
+ statusCode: 400,
+ correlationId: "test-correlation-id",
+ });
+
+ const result = mapBridgeError(error);
+
+ expect(result).toBe(error);
+ expect(result.code).toBe("INVALID_INPUT");
+ expect(result.message).toBe("Invalid input provided");
+ expect(result.statusCode).toBe(400);
+ expect(result.correlationId).toBe("test-correlation-id");
+ });
+
+ it("should return the same error for INTERNAL_SERVER_ERROR", () => {
+ const error = new ApiError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Internal server error occurred",
+ statusCode: 500,
+ correlationId: "internal-error-id",
+ });
+
+ const result = mapBridgeError(error);
+
+ expect(result).toBe(error);
+ expect(result.code).toBe("INTERNAL_SERVER_ERROR");
+ expect(result.message).toBe("Internal server error occurred");
+ expect(result.statusCode).toBe(500);
+ expect(result.correlationId).toBe("internal-error-id");
+ });
+
+ it("should return the same error for ROUTE_NOT_FOUND", () => {
+ const error = new ApiError({
+ code: "ROUTE_NOT_FOUND",
+ message: "No route found for the requested parameters",
+ statusCode: 404,
+ });
+
+ const result = mapBridgeError(error);
+
+ expect(result).toBe(error);
+ expect(result.code).toBe("ROUTE_NOT_FOUND");
+ expect(result.message).toBe("No route found for the requested parameters");
+ expect(result.statusCode).toBe(404);
+ expect(result.correlationId).toBeUndefined();
+ });
+
+ it("should return the same error for AMOUNT_TOO_LOW", () => {
+ const error = new ApiError({
+ code: "AMOUNT_TOO_LOW",
+ message: "Amount is below minimum threshold",
+ statusCode: 400,
+ correlationId: "amount-validation-id",
+ });
+
+ const result = mapBridgeError(error);
+
+ expect(result).toBe(error);
+ expect(result.code).toBe("AMOUNT_TOO_LOW");
+ expect(result.message).toBe("Amount is below minimum threshold");
+ expect(result.statusCode).toBe(400);
+ expect(result.correlationId).toBe("amount-validation-id");
+ });
+});
+
+describe("isRetryable", () => {
+ it("should return true for INTERNAL_SERVER_ERROR", () => {
+ expect(isRetryable("INTERNAL_SERVER_ERROR")).toBe(true);
+ });
+
+ it("should return true for UNKNOWN_ERROR", () => {
+ expect(isRetryable("UNKNOWN_ERROR")).toBe(true);
+ });
+
+ it("should return false for INVALID_INPUT", () => {
+ expect(isRetryable("INVALID_INPUT")).toBe(false);
+ });
+
+ it("should return false for ROUTE_NOT_FOUND", () => {
+ expect(isRetryable("ROUTE_NOT_FOUND")).toBe(false);
+ });
+
+ it("should return false for AMOUNT_TOO_LOW", () => {
+ expect(isRetryable("AMOUNT_TOO_LOW")).toBe(false);
+ });
+
+ it("should return false for AMOUNT_TOO_HIGH", () => {
+ expect(isRetryable("AMOUNT_TOO_HIGH")).toBe(false);
+ });
+});
diff --git a/packages/thirdweb/src/react/core/errors/mapBridgeError.ts b/packages/thirdweb/src/react/core/errors/mapBridgeError.ts
new file mode 100644
index 00000000000..d9159410fe3
--- /dev/null
+++ b/packages/thirdweb/src/react/core/errors/mapBridgeError.ts
@@ -0,0 +1,25 @@
+import type { ApiError } from "../../../bridge/types/Errors.js";
+
+/**
+ * Maps raw ApiError instances from the Bridge SDK into UI-friendly domain errors.
+ * Currently returns the same error; will evolve to provide better user-facing messages.
+ *
+ * @param e - The raw ApiError from the Bridge SDK
+ * @returns The mapped ApiError (currently unchanged)
+ */
+export function mapBridgeError(e: ApiError): ApiError {
+ // For now, return the same error
+ // TODO: This will evolve to provide better user-facing error messages
+ return e;
+}
+
+/**
+ * Determines if an error code represents a retryable error condition.
+ *
+ * @param code - The error code from ApiError
+ * @returns true if the error is retryable, false otherwise
+ */
+export function isRetryable(code: ApiError["code"]): boolean {
+ // Treat INTERNAL_SERVER_ERROR & UNKNOWN_ERROR as retryable
+ return code === "INTERNAL_SERVER_ERROR" || code === "UNKNOWN_ERROR";
+}
diff --git a/packages/thirdweb/src/react/core/hooks/connection/ConnectButtonProps.ts b/packages/thirdweb/src/react/core/hooks/connection/ConnectButtonProps.ts
index aa2b40bef3e..56f70df16ca 100644
--- a/packages/thirdweb/src/react/core/hooks/connection/ConnectButtonProps.ts
+++ b/packages/thirdweb/src/react/core/hooks/connection/ConnectButtonProps.ts
@@ -25,40 +25,42 @@ import type {
} from "../../utils/defaultTokens.js";
import type { SiweAuthOptions } from "../auth/useSiweAuth.js";
-export type PaymentInfo = {
- /**
- * The chain to receive the payment on.
- */
- chain: Chain;
- /**
- * The address of the seller wallet to receive the payment on.
- */
- sellerAddress: string;
- /**
- * Optional ERC20 token to receive the payment on.
- * If not provided, the native token will be used.
- */
- token?: TokenInfo;
- /**
- * For direct transfers, specify who will pay the transfer fee. Can be "sender" or "receiver".
- */
- feePayer?: "sender" | "receiver";
-} & (
- | {
- /**
- * The amount of tokens to receive in ETH or tokens.
- * ex: 0.1 ETH or 100 USDC
- */
- amount: string;
- }
- | {
- /**
- * The amount of tokens to receive in wei.
- * ex: 1000000000000000000 wei
- */
- amountWei: bigint;
- }
-);
+export type PaymentInfo = Prettify<
+ {
+ /**
+ * The chain to receive the payment on.
+ */
+ chain: Chain;
+ /**
+ * The address of the seller wallet to receive the payment on.
+ */
+ sellerAddress: string;
+ /**
+ * Optional ERC20 token to receive the payment on.
+ * If not provided, the native token will be used.
+ */
+ token?: Partial & { address: string };
+ /**
+ * For direct transfers, specify who will pay the transfer fee. Can be "sender" or "receiver".
+ */
+ feePayer?: "sender" | "receiver";
+ } & (
+ | {
+ /**
+ * The amount of tokens to receive in ETH or tokens.
+ * ex: 0.1 ETH or 100 USDC
+ */
+ amount: string;
+ }
+ | {
+ /**
+ * The amount of tokens to receive in wei.
+ * ex: 1000000000000000000 wei
+ */
+ amountWei: bigint;
+ }
+ )
+>;
export type PayUIOptions = Prettify<
{
@@ -78,7 +80,7 @@ export type PayUIOptions = Prettify<
testMode?: boolean;
prefillSource?: {
chain: Chain;
- token?: TokenInfo;
+ token?: Partial & { address: string };
allowEdits?: {
token: boolean;
chain: boolean;
@@ -115,7 +117,8 @@ export type PayUIOptions = Prettify<
* Callback to be called when the user successfully completes the purchase.
*/
onPurchaseSuccess?: (
- info:
+ // TODO: remove this type from the callback entirely or adapt it from the new format
+ info?:
| {
type: "crypto";
status: BuyWithCryptoStatus;
@@ -135,6 +138,7 @@ export type PayUIOptions = Prettify<
*/
metadata?: {
name?: string;
+ description?: string;
image?: string;
};
@@ -160,13 +164,14 @@ export type FundWalletOptions = {
*/
prefillBuy?: {
chain: Chain;
- token?: TokenInfo;
+ token?: Partial & { address: string };
amount?: string;
allowEdits?: {
amount: boolean;
token: boolean;
chain: boolean;
};
+ presetOptions?: [number, number, number];
};
};
diff --git a/packages/thirdweb/src/react/core/hooks/others/useChainQuery.ts b/packages/thirdweb/src/react/core/hooks/others/useChainQuery.ts
index 4cda3eab0e9..0eb36c2f7f0 100644
--- a/packages/thirdweb/src/react/core/hooks/others/useChainQuery.ts
+++ b/packages/thirdweb/src/react/core/hooks/others/useChainQuery.ts
@@ -137,7 +137,7 @@ export function useChainExplorers(chain?: Chain) {
function getQueryOptions(chain?: Chain) {
return {
- queryKey: ["chain", chain],
+ queryKey: ["chain", chain?.id],
enabled: !!chain,
staleTime: 1000 * 60 * 60, // 1 hour
} as const;
diff --git a/packages/thirdweb/src/react/core/hooks/pay/useBuyWithFiatQuotesForProviders.ts b/packages/thirdweb/src/react/core/hooks/pay/useBuyWithFiatQuotesForProviders.ts
new file mode 100644
index 00000000000..77619e4c59b
--- /dev/null
+++ b/packages/thirdweb/src/react/core/hooks/pay/useBuyWithFiatQuotesForProviders.ts
@@ -0,0 +1,100 @@
+import { type UseQueryOptions, useQueries } from "@tanstack/react-query";
+import { prepare as prepareOnramp } from "../../../../bridge/Onramp.js";
+import type { ThirdwebClient } from "../../../../client/client.js";
+import { getToken } from "../../../../pay/convert/get-token.js";
+import type { Address } from "../../../../utils/address.js";
+import { toUnits } from "../../../../utils/units.js";
+
+/**
+ * @internal
+ */
+type UseBuyWithFiatQuotesForProvidersParams = {
+ /**
+ * A client is the entry point to the thirdweb SDK.
+ */
+ client: ThirdwebClient;
+ /**
+ * The destination chain ID.
+ */
+ chainId: number;
+ /**
+ * The destination token address.
+ */
+ tokenAddress: Address;
+ /**
+ * The address that will receive the tokens.
+ */
+ receiver: Address;
+ /**
+ * The desired token amount in wei.
+ */
+ amount: string;
+ /**
+ * The fiat currency (e.g., "USD"). Defaults to "USD".
+ */
+ currency?: string;
+};
+
+/**
+ * @internal
+ */
+type OnrampQuoteQueryOptions = Omit<
+ UseQueryOptions>>,
+ "queryFn" | "queryKey" | "enabled"
+>;
+
+/**
+ * @internal
+ */
+type UseBuyWithFiatQuotesForProvidersResult = {
+ data: Awaited> | undefined;
+ isLoading: boolean;
+ error: Error | null;
+ isError: boolean;
+ isSuccess: boolean;
+}[];
+
+/**
+ * @internal
+ * Hook to get prepared onramp quotes from Coinbase, Stripe, and Transak providers.
+ */
+export function useBuyWithFiatQuotesForProviders(
+ params?: UseBuyWithFiatQuotesForProvidersParams,
+ queryOptions?: OnrampQuoteQueryOptions,
+): UseBuyWithFiatQuotesForProvidersResult {
+ const providers = ["coinbase", "stripe", "transak"] as const;
+
+ const queries = useQueries({
+ queries: providers.map((provider) => ({
+ ...queryOptions,
+ queryKey: ["onramp-prepare", provider, params],
+ queryFn: async () => {
+ if (!params) {
+ throw new Error("No params provided");
+ }
+
+ const token = await getToken(
+ params.client,
+ params.tokenAddress,
+ params.chainId,
+ );
+
+ const amountWei = toUnits(params.amount, token.decimals);
+
+ return prepareOnramp({
+ client: params.client,
+ onramp: provider,
+ chainId: params.chainId,
+ tokenAddress: params.tokenAddress,
+ receiver: params.receiver,
+ amount: amountWei,
+ currency: params.currency || "USD",
+ });
+ },
+ enabled: !!params,
+ retry: false,
+ })),
+ });
+
+ return queries;
+}
diff --git a/packages/thirdweb/src/react/core/hooks/transaction/useSendTransaction.ts b/packages/thirdweb/src/react/core/hooks/transaction/useSendTransaction.ts
index 78fbcfa3e60..8498ec5ff31 100644
--- a/packages/thirdweb/src/react/core/hooks/transaction/useSendTransaction.ts
+++ b/packages/thirdweb/src/react/core/hooks/transaction/useSendTransaction.ts
@@ -70,7 +70,7 @@ export type SendTransactionPayModalConfig =
* Callback to be called when the user successfully completes the purchase.
*/
onPurchaseSuccess?: (
- info:
+ info?:
| {
type: "crypto";
status: BuyWithCryptoStatus;
diff --git a/packages/thirdweb/src/react/core/hooks/useBridgeError.test.ts b/packages/thirdweb/src/react/core/hooks/useBridgeError.test.ts
new file mode 100644
index 00000000000..b47fa3b8654
--- /dev/null
+++ b/packages/thirdweb/src/react/core/hooks/useBridgeError.test.ts
@@ -0,0 +1,172 @@
+import { describe, expect, it } from "vitest";
+import { ApiError } from "../../../bridge/types/Errors.js";
+import { useBridgeError } from "./useBridgeError.js";
+
+describe("useBridgeError", () => {
+ it("should handle null error", () => {
+ const result = useBridgeError({ error: null });
+
+ expect(result).toEqual({
+ mappedError: null,
+ isRetryable: false,
+ userMessage: "",
+ errorCode: null,
+ statusCode: null,
+ isClientError: false,
+ isServerError: false,
+ });
+ });
+
+ it("should handle undefined error", () => {
+ const result = useBridgeError({ error: undefined });
+
+ expect(result).toEqual({
+ mappedError: null,
+ isRetryable: false,
+ userMessage: "",
+ errorCode: null,
+ statusCode: null,
+ isClientError: false,
+ isServerError: false,
+ });
+ });
+
+ it("should process ApiError correctly", () => {
+ const apiError = new ApiError({
+ code: "INVALID_INPUT",
+ message: "Invalid parameters provided",
+ statusCode: 400,
+ });
+
+ const result = useBridgeError({ error: apiError });
+
+ expect(result.mappedError).toBeInstanceOf(ApiError);
+ expect(result.errorCode).toBe("INVALID_INPUT");
+ expect(result.statusCode).toBe(400);
+ expect(result.isClientError).toBe(true);
+ expect(result.isServerError).toBe(false);
+ expect(result.isRetryable).toBe(false); // INVALID_INPUT is not retryable
+ expect(result.userMessage).toBe(
+ "Invalid input provided. Please check your parameters and try again.",
+ );
+ });
+
+ it("should convert generic Error to ApiError", () => {
+ const genericError = new Error("Network connection failed");
+
+ const result = useBridgeError({ error: genericError });
+
+ expect(result.mappedError).toBeInstanceOf(ApiError);
+ expect(result.errorCode).toBe("UNKNOWN_ERROR");
+ expect(result.statusCode).toBe(500);
+ expect(result.isClientError).toBe(false);
+ expect(result.isServerError).toBe(true);
+ expect(result.isRetryable).toBe(true);
+ expect(result.userMessage).toBe(
+ "An unexpected error occurred. Please try again.",
+ );
+ });
+
+ it("should identify server errors correctly", () => {
+ const serverError = new ApiError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Server error",
+ statusCode: 500,
+ });
+
+ const result = useBridgeError({ error: serverError });
+
+ expect(result.statusCode).toBe(500);
+ expect(result.isClientError).toBe(false);
+ expect(result.isServerError).toBe(true);
+ expect(result.isRetryable).toBe(true); // INTERNAL_SERVER_ERROR is retryable
+ expect(result.userMessage).toBe(
+ "A temporary error occurred. Please try again in a moment.",
+ );
+ });
+
+ it("should provide user-friendly messages for known error codes", () => {
+ // Test INVALID_INPUT
+ const invalidInputError = new ApiError({
+ code: "INVALID_INPUT",
+ message: "Technical error message",
+ statusCode: 400,
+ });
+ const invalidInputResult = useBridgeError({ error: invalidInputError });
+ expect(invalidInputResult.userMessage).toBe(
+ "Invalid input provided. Please check your parameters and try again.",
+ );
+
+ // Test INTERNAL_SERVER_ERROR
+ const serverError = new ApiError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Technical error message",
+ statusCode: 500,
+ });
+ const serverResult = useBridgeError({ error: serverError });
+ expect(serverResult.userMessage).toBe(
+ "A temporary error occurred. Please try again in a moment.",
+ );
+ });
+
+ it("should use original error message for unknown error codes", () => {
+ const unknownError = new ApiError({
+ code: "UNKNOWN_ERROR",
+ message: "Custom error message",
+ statusCode: 418,
+ });
+
+ const result = useBridgeError({ error: unknownError });
+
+ expect(result.userMessage).toBe(
+ "An unexpected error occurred. Please try again.",
+ );
+ expect(result.errorCode).toBe("UNKNOWN_ERROR");
+ });
+
+ it("should detect client vs server errors correctly", () => {
+ // Client error (4xx)
+ const clientError = new ApiError({
+ code: "INVALID_INPUT",
+ message: "Bad request",
+ statusCode: 400,
+ });
+
+ const clientResult = useBridgeError({ error: clientError });
+ expect(clientResult.isClientError).toBe(true);
+ expect(clientResult.isServerError).toBe(false);
+
+ // Server error (5xx)
+ const serverError = new ApiError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Internal error",
+ statusCode: 503,
+ });
+
+ const serverResult = useBridgeError({ error: serverError });
+ expect(serverResult.isClientError).toBe(false);
+ expect(serverResult.isServerError).toBe(true);
+
+ // No status code
+ const noStatusError = new ApiError({
+ code: "UNKNOWN_ERROR",
+ message: "Unknown error",
+ statusCode: 500,
+ });
+
+ const noStatusResult = useBridgeError({ error: noStatusError });
+ expect(noStatusResult.isClientError).toBe(false);
+ expect(noStatusResult.isServerError).toBe(true); // 500 is a server error
+ });
+
+ it("should handle Error without message", () => {
+ const errorWithoutMessage = new Error();
+
+ const result = useBridgeError({ error: errorWithoutMessage });
+
+ expect(result.userMessage).toBe(
+ "An unexpected error occurred. Please try again.",
+ );
+ expect(result.errorCode).toBe("UNKNOWN_ERROR");
+ });
+});
diff --git a/packages/thirdweb/src/react/core/hooks/useBridgeError.ts b/packages/thirdweb/src/react/core/hooks/useBridgeError.ts
new file mode 100644
index 00000000000..ea4b7751648
--- /dev/null
+++ b/packages/thirdweb/src/react/core/hooks/useBridgeError.ts
@@ -0,0 +1,149 @@
+import { ApiError } from "../../../bridge/types/Errors.js";
+import { isRetryable, mapBridgeError } from "../errors/mapBridgeError.js";
+
+/**
+ * Parameters for the useBridgeError hook
+ */
+interface UseBridgeErrorParams {
+ /**
+ * The error to process. Can be an ApiError or generic Error.
+ */
+ error: Error | ApiError | null | undefined;
+}
+
+/**
+ * Result returned by the useBridgeError hook
+ */
+interface UseBridgeErrorResult {
+ /**
+ * The mapped/normalized error, null if no error provided
+ */
+ mappedError: ApiError | null;
+
+ /**
+ * Whether this error can be retried
+ */
+ isRetryable: boolean;
+
+ /**
+ * User-friendly error message
+ */
+ userMessage: string;
+
+ /**
+ * Technical error code for debugging
+ */
+ errorCode: string | null;
+
+ /**
+ * HTTP status code if available
+ */
+ statusCode: number | null;
+
+ /**
+ * Whether this is a client-side error (4xx)
+ */
+ isClientError: boolean;
+
+ /**
+ * Whether this is a server-side error (5xx)
+ */
+ isServerError: boolean;
+}
+
+/**
+ * Hook that processes bridge errors using mapBridgeError and isRetryable
+ *
+ * @param params - Parameters containing the error to process
+ * @returns Processed error information with retry logic and user-friendly messages
+ *
+ * @example
+ * ```tsx
+ * const { data, error } = useBridgeRoutes({ client, originChainId: 1 });
+ * const {
+ * mappedError,
+ * isRetryable,
+ * userMessage,
+ * isClientError
+ * } = useBridgeError({ error });
+ *
+ * if (error) {
+ * return (
+ *
+ *
{userMessage}
+ * {isRetryable && }
+ *
+ * );
+ * }
+ * ```
+ */
+export function useBridgeError(
+ params: UseBridgeErrorParams,
+): UseBridgeErrorResult {
+ const { error } = params;
+
+ // No error case
+ if (!error) {
+ return {
+ mappedError: null,
+ isRetryable: false,
+ userMessage: "",
+ errorCode: null,
+ statusCode: null,
+ isClientError: false,
+ isServerError: false,
+ };
+ }
+
+ // Convert to ApiError if it's not already
+ let apiError: ApiError;
+ if (error instanceof ApiError) {
+ apiError = mapBridgeError(error);
+ } else {
+ // Create ApiError from generic Error
+ apiError = new ApiError({
+ code: "UNKNOWN_ERROR",
+ message: error.message || "An unknown error occurred",
+ statusCode: 500, // Default for generic errors
+ });
+ }
+
+ const statusCode = apiError.statusCode || null;
+ const isClientError =
+ statusCode !== null && statusCode >= 400 && statusCode < 500;
+ const isServerError = statusCode !== null && statusCode >= 500;
+
+ // Generate user-friendly message based on error code
+ const userMessage = getUserFriendlyMessage(apiError);
+
+ return {
+ mappedError: apiError,
+ isRetryable: isRetryable(apiError.code),
+ userMessage,
+ errorCode: apiError.code,
+ statusCode,
+ isClientError,
+ isServerError,
+ };
+}
+
+/**
+ * Converts technical error codes to user-friendly messages
+ */
+function getUserFriendlyMessage(error: ApiError): string {
+ switch (error.code) {
+ case "INVALID_INPUT":
+ return "Invalid input provided. Please check your parameters and try again.";
+ case "ROUTE_NOT_FOUND":
+ return "No route found for this transaction. Please try a different token pair or amount.";
+ case "AMOUNT_TOO_LOW":
+ return "The amount is too low for this transaction. Please increase the amount.";
+ case "AMOUNT_TOO_HIGH":
+ return "The amount is too high for this transaction. Please decrease the amount.";
+ case "INTERNAL_SERVER_ERROR":
+ return "A temporary error occurred. Please try again in a moment.";
+ default:
+ // Fallback to the original error message if available
+ return error.message || "An unexpected error occurred. Please try again.";
+ }
+}
diff --git a/packages/thirdweb/src/react/core/hooks/useBridgePrepare.test.ts b/packages/thirdweb/src/react/core/hooks/useBridgePrepare.test.ts
new file mode 100644
index 00000000000..5293e528879
--- /dev/null
+++ b/packages/thirdweb/src/react/core/hooks/useBridgePrepare.test.ts
@@ -0,0 +1,161 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { ThirdwebClient } from "../../../client/client.js";
+import type {
+ BridgePrepareRequest,
+ UseBridgePrepareParams,
+} from "./useBridgePrepare.js";
+
+// Mock client
+const mockClient = { clientId: "test" } as ThirdwebClient;
+
+describe("useBridgePrepare", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("should have correct type structure for buy prepare request", () => {
+ const buyRequest: BridgePrepareRequest = {
+ type: "buy",
+ client: mockClient,
+ originChainId: 1,
+ originTokenAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
+ destinationChainId: 137,
+ destinationTokenAddress: "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174",
+ amount: 1000000n,
+ sender: "0x1234567890123456789012345678901234567890",
+ receiver: "0x1234567890123456789012345678901234567890",
+ };
+
+ expect(buyRequest.type).toBe("buy");
+ expect(buyRequest.amount).toBe(1000000n);
+ expect(buyRequest.client).toBe(mockClient);
+ });
+
+ it("should have correct type structure for transfer prepare request", () => {
+ const transferRequest: BridgePrepareRequest = {
+ type: "transfer",
+ client: mockClient,
+ chainId: 1,
+ tokenAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
+ amount: 1000000n,
+ sender: "0x1234567890123456789012345678901234567890",
+ receiver: "0x1234567890123456789012345678901234567890",
+ };
+
+ expect(transferRequest.type).toBe("transfer");
+ expect(transferRequest.amount).toBe(1000000n);
+ expect(transferRequest.client).toBe(mockClient);
+ });
+
+ it("should have correct type structure for sell prepare request", () => {
+ const sellRequest: BridgePrepareRequest = {
+ type: "sell",
+ client: mockClient,
+ originChainId: 1,
+ originTokenAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
+ destinationChainId: 137,
+ destinationTokenAddress: "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174",
+ amount: 1000000n,
+ sender: "0x1234567890123456789012345678901234567890",
+ receiver: "0x1234567890123456789012345678901234567890",
+ };
+
+ expect(sellRequest.type).toBe("sell");
+ expect(sellRequest.amount).toBe(1000000n);
+ expect(sellRequest.client).toBe(mockClient);
+ });
+
+ it("should have correct type structure for onramp prepare request", () => {
+ const onrampRequest: BridgePrepareRequest = {
+ type: "onramp",
+ client: mockClient,
+ onramp: "stripe",
+ chainId: 137,
+ tokenAddress: "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174",
+ receiver: "0x1234567890123456789012345678901234567890",
+ amount: 1000000n,
+ };
+
+ expect(onrampRequest.type).toBe("onramp");
+ expect(onrampRequest.amount).toBe(1000000n);
+ expect(onrampRequest.client).toBe(mockClient);
+ });
+
+ it("should handle UseBridgePrepareParams with enabled option", () => {
+ const params: UseBridgePrepareParams = {
+ type: "buy",
+ client: mockClient,
+ originChainId: 1,
+ originTokenAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
+ destinationChainId: 137,
+ destinationTokenAddress: "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174",
+ amount: 1000000n,
+ sender: "0x1234567890123456789012345678901234567890",
+ receiver: "0x1234567890123456789012345678901234567890",
+ enabled: false,
+ };
+
+ expect(params.enabled).toBe(false);
+ expect(params.type).toBe("buy");
+ });
+
+ it("should have optional enabled parameter", () => {
+ const paramsWithoutEnabled: UseBridgePrepareParams = {
+ type: "transfer",
+ client: mockClient,
+ chainId: 1,
+ tokenAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
+ amount: 1000000n,
+ sender: "0x1234567890123456789012345678901234567890",
+ receiver: "0x1234567890123456789012345678901234567890",
+ };
+
+ expect(paramsWithoutEnabled.enabled).toBeUndefined(); // Should be optional
+ expect(paramsWithoutEnabled.type).toBe("transfer");
+ });
+
+ it("should correctly discriminate between different prepare request types", () => {
+ const buyRequest: BridgePrepareRequest = {
+ type: "buy",
+ client: mockClient,
+ originChainId: 1,
+ originTokenAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
+ destinationChainId: 137,
+ destinationTokenAddress: "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174",
+ amount: 1000000n,
+ sender: "0x1234567890123456789012345678901234567890",
+ receiver: "0x1234567890123456789012345678901234567890",
+ };
+
+ // Type narrowing should work
+ if (buyRequest.type === "buy") {
+ expect(buyRequest.sender).toBe(
+ "0x1234567890123456789012345678901234567890",
+ );
+ expect(buyRequest.receiver).toBe(
+ "0x1234567890123456789012345678901234567890",
+ );
+ }
+
+ const onrampRequest: BridgePrepareRequest = {
+ type: "onramp",
+ client: mockClient,
+ onramp: "stripe",
+ chainId: 137,
+ tokenAddress: "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174",
+ receiver: "0x1234567890123456789012345678901234567890",
+ amount: 1000000n,
+ };
+
+ // Type narrowing should work for onramp
+ if (onrampRequest.type === "onramp") {
+ expect(onrampRequest.receiver).toBe(
+ "0x1234567890123456789012345678901234567890",
+ );
+ expect(onrampRequest.tokenAddress).toBe(
+ "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174",
+ );
+ expect(onrampRequest.onramp).toBe("stripe");
+ }
+ });
+});
diff --git a/packages/thirdweb/src/react/core/hooks/useBridgePrepare.ts b/packages/thirdweb/src/react/core/hooks/useBridgePrepare.ts
new file mode 100644
index 00000000000..8ee3c592871
--- /dev/null
+++ b/packages/thirdweb/src/react/core/hooks/useBridgePrepare.ts
@@ -0,0 +1,133 @@
+import { useQuery } from "@tanstack/react-query";
+import type { prepare as BuyPrepare } from "../../../bridge/Buy.js";
+import type { prepare as OnrampPrepare } from "../../../bridge/Onramp.js";
+import type { prepare as SellPrepare } from "../../../bridge/Sell.js";
+import type { prepare as TransferPrepare } from "../../../bridge/Transfer.js";
+import * as Bridge from "../../../bridge/index.js";
+import { ApiError } from "../../../bridge/types/Errors.js";
+import { stringify } from "../../../utils/json.js";
+import { mapBridgeError } from "../errors/mapBridgeError.js";
+
+/**
+ * Union type for different Bridge prepare request types
+ */
+export type BridgePrepareRequest =
+ | ({ type: "buy" } & BuyPrepare.Options)
+ | ({ type: "sell" } & SellPrepare.Options)
+ | ({ type: "transfer" } & TransferPrepare.Options)
+ | ({ type: "onramp" } & OnrampPrepare.Options);
+
+/**
+ * Union type for different Bridge prepare result types
+ */
+export type BridgePrepareResult =
+ | ({ type: "buy" } & BuyPrepare.Result)
+ | ({ type: "sell" } & SellPrepare.Result)
+ | ({ type: "transfer" } & TransferPrepare.Result)
+ | ({ type: "onramp" } & OnrampPrepare.Result);
+
+/**
+ * Parameters for the useBridgePrepare hook
+ */
+export type UseBridgePrepareParams = BridgePrepareRequest & {
+ /**
+ * Whether to enable the query. Useful for conditional fetching.
+ * @default true
+ */
+ enabled?: boolean;
+};
+
+/**
+ * Hook that prepares bridge transactions with caching and retry logic
+ *
+ * @param params - Parameters for preparing bridge transactions including type and specific options
+ * @returns React Query result with prepared transaction data, loading state, and error handling
+ *
+ * @example
+ * ```tsx
+ * // Buy preparation
+ * const { data: preparedBuy, isLoading, error } = useBridgePrepare({
+ * type: "buy",
+ * client: thirdwebClient,
+ * originChainId: 1,
+ * originTokenAddress: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE",
+ * destinationChainId: 137,
+ * destinationTokenAddress: "0x7ceB23fD6bC0adD59E62ac25578270cFf1b9f619",
+ * amount: parseEther("1"),
+ * sender: "0x...",
+ * receiver: "0x..."
+ * });
+ *
+ * // Transfer preparation
+ * const { data: preparedTransfer } = useBridgePrepare({
+ * type: "transfer",
+ * client: thirdwebClient,
+ * originChainId: 1,
+ * originTokenAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
+ * destinationChainId: 137,
+ * destinationTokenAddress: "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174",
+ * amount: 1000000n,
+ * sender: "0x...",
+ * receiver: "0x..."
+ * });
+ * ```
+ */
+export function useBridgePrepare(params: UseBridgePrepareParams) {
+ const { enabled = true, type, ...prepareParams } = params;
+
+ return useQuery({
+ queryKey: ["bridge-prepare", type, stringify(prepareParams)],
+ queryFn: async (): Promise => {
+ switch (type) {
+ case "buy": {
+ const result = await Bridge.Buy.prepare(
+ prepareParams as BuyPrepare.Options,
+ );
+ return { type: "buy", ...result };
+ }
+ case "sell": {
+ const result = await Bridge.Sell.prepare(
+ prepareParams as SellPrepare.Options,
+ );
+ return { type: "sell", ...result };
+ }
+ case "transfer": {
+ const result = await Bridge.Transfer.prepare(
+ prepareParams as TransferPrepare.Options,
+ );
+ return { type: "transfer", ...result };
+ }
+ case "onramp": {
+ const result = await Bridge.Onramp.prepare(
+ prepareParams as OnrampPrepare.Options,
+ );
+ return { type: "onramp", ...result };
+ }
+ default:
+ throw new Error(`Unsupported bridge prepare type: ${type}`);
+ }
+ },
+ enabled: enabled && !!prepareParams.client,
+ staleTime: 2 * 60 * 1000, // 2 minutes - prepared quotes have shorter validity
+ gcTime: 5 * 60 * 1000, // 5 minutes garbage collection
+ retry: (failureCount, error) => {
+ // Handle both ApiError and generic Error instances
+ if (error instanceof ApiError) {
+ const bridgeError = mapBridgeError(error);
+
+ // Don't retry on client-side errors (4xx)
+ if (
+ bridgeError.statusCode &&
+ bridgeError.statusCode >= 400 &&
+ bridgeError.statusCode < 500
+ ) {
+ return false;
+ }
+ }
+
+ // Retry up to 2 times for prepared quotes (they're more time-sensitive)
+ return failureCount < 2;
+ },
+ retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 10000), // Exponential backoff, max 10s
+ });
+}
diff --git a/packages/thirdweb/src/react/core/hooks/useBridgeQuote.ts b/packages/thirdweb/src/react/core/hooks/useBridgeQuote.ts
new file mode 100644
index 00000000000..5d1661e7a55
--- /dev/null
+++ b/packages/thirdweb/src/react/core/hooks/useBridgeQuote.ts
@@ -0,0 +1,67 @@
+"use client";
+import { useQuery } from "@tanstack/react-query";
+import * as Buy from "../../../bridge/Buy.js";
+import * as Transfer from "../../../bridge/Transfer.js";
+import type { Token } from "../../../bridge/types/Token.js";
+import type { ThirdwebClient } from "../../../client/client.js";
+import { checksumAddress } from "../../../utils/address.js";
+
+interface UseBridgeQuoteParams {
+ originToken: Token;
+ destinationToken: Token;
+ destinationAmount: bigint;
+ client: ThirdwebClient;
+ enabled?: boolean;
+}
+
+export function useBridgeQuote({
+ originToken,
+ destinationToken,
+ destinationAmount,
+ client,
+ enabled = true,
+}: UseBridgeQuoteParams) {
+ return useQuery({
+ queryKey: [
+ "bridge-quote",
+ originToken.chainId,
+ originToken.address,
+ destinationToken.chainId,
+ destinationToken.address,
+ destinationAmount.toString(),
+ ],
+ queryFn: async () => {
+ // if ssame token and chain, use transfer
+ if (
+ checksumAddress(originToken.address) ===
+ checksumAddress(destinationToken.address) &&
+ originToken.chainId === destinationToken.chainId
+ ) {
+ const transfer = await Transfer.prepare({
+ client,
+ chainId: originToken.chainId,
+ tokenAddress: originToken.address,
+ sender: originToken.address,
+ receiver: destinationToken.address,
+ amount: destinationAmount,
+ });
+ return transfer;
+ }
+ const quote = await Buy.quote({
+ originChainId: originToken.chainId,
+ originTokenAddress: originToken.address,
+ destinationChainId: destinationToken.chainId,
+ destinationTokenAddress: destinationToken.address,
+ amount: destinationAmount,
+ client,
+ });
+
+ return quote;
+ },
+ enabled:
+ enabled && !!originToken && !!destinationToken && !!destinationAmount,
+ staleTime: 30000, // 30 seconds
+ refetchInterval: 60000, // 1 minute
+ retry: 3,
+ });
+}
diff --git a/packages/thirdweb/src/react/core/hooks/useBridgeRoutes.test.ts b/packages/thirdweb/src/react/core/hooks/useBridgeRoutes.test.ts
new file mode 100644
index 00000000000..8a539cbe9ee
--- /dev/null
+++ b/packages/thirdweb/src/react/core/hooks/useBridgeRoutes.test.ts
@@ -0,0 +1,137 @@
+import {
+ type MockedFunction,
+ beforeEach,
+ describe,
+ expect,
+ it,
+ vi,
+} from "vitest";
+import { routes } from "../../../bridge/Routes.js";
+import { ApiError } from "../../../bridge/types/Errors.js";
+import type { Route } from "../../../bridge/types/Route.js";
+import type { ThirdwebClient } from "../../../client/client.js";
+import type { UseBridgeRoutesParams } from "./useBridgeRoutes.js";
+
+// Mock the Bridge routes function
+vi.mock("../../../bridge/Routes.js", () => ({
+ routes: vi.fn(),
+}));
+
+const mockRoutes = routes as MockedFunction;
+
+// Mock client
+const mockClient = { clientId: "test" } as ThirdwebClient;
+
+// Mock route data
+const mockRouteData: Route[] = [
+ {
+ originToken: {
+ chainId: 1,
+ address: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE",
+ symbol: "ETH",
+ name: "Ethereum",
+ decimals: 18,
+ priceUsd: 2000.0,
+ },
+ destinationToken: {
+ chainId: 137,
+ address: "0x7ceB23fD6bC0adD59E62ac25578270cFf1b9f619",
+ symbol: "WETH",
+ name: "Wrapped Ethereum",
+ decimals: 18,
+ priceUsd: 2000.0,
+ },
+ },
+];
+
+describe("useBridgeRoutes", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("should export correct hook parameters type", () => {
+ // Type-only test to verify UseBridgeRoutesParams interface
+ const params: UseBridgeRoutesParams = {
+ client: mockClient,
+ originChainId: 1,
+ destinationChainId: 137,
+ enabled: true,
+ };
+
+ expect(params).toBeDefined();
+ expect(params.client).toBe(mockClient);
+ expect(params.originChainId).toBe(1);
+ expect(params.destinationChainId).toBe(137);
+ expect(params.enabled).toBe(true);
+ });
+
+ it("should handle different parameter combinations", () => {
+ const fullParams: UseBridgeRoutesParams = {
+ client: mockClient,
+ originChainId: 1,
+ originTokenAddress: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE",
+ destinationChainId: 137,
+ destinationTokenAddress: "0x7ceB23fD6bC0adD59E62ac25578270cFf1b9f619",
+ maxSteps: 3,
+ sortBy: "popularity",
+ limit: 10,
+ offset: 0,
+ enabled: false,
+ };
+
+ expect(fullParams).toBeDefined();
+ expect(fullParams.sortBy).toBe("popularity");
+ expect(fullParams.maxSteps).toBe(3);
+ expect(fullParams.limit).toBe(10);
+ expect(fullParams.offset).toBe(0);
+ });
+
+ it("should have optional enabled parameter defaulting to true", () => {
+ const paramsWithoutEnabled: UseBridgeRoutesParams = {
+ client: mockClient,
+ originChainId: 1,
+ destinationChainId: 137,
+ };
+
+ expect(paramsWithoutEnabled.enabled).toBeUndefined(); // Should be optional
+ });
+
+ it("should validate that Bridge.routes would be called with correct parameters", async () => {
+ const testParams = {
+ client: mockClient,
+ originChainId: 1,
+ destinationChainId: 137,
+ originTokenAddress: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE" as const,
+ };
+
+ // Mock the routes function to return our test data
+ mockRoutes.mockResolvedValue(mockRouteData);
+
+ // Directly call the routes function to verify it works with our parameters
+ const result = await routes(testParams);
+
+ expect(mockRoutes).toHaveBeenCalledWith(testParams);
+ expect(result).toEqual(mockRouteData);
+ });
+
+ it("should handle API errors properly", async () => {
+ const apiError = new ApiError({
+ code: "INVALID_INPUT",
+ message: "Invalid parameters",
+ statusCode: 400,
+ });
+
+ mockRoutes.mockRejectedValue(apiError);
+
+ try {
+ await routes({
+ client: mockClient,
+ originChainId: 1,
+ destinationChainId: 137,
+ });
+ } catch (error) {
+ expect(error).toBe(apiError);
+ expect(error).toBeInstanceOf(ApiError);
+ }
+ });
+});
diff --git a/packages/thirdweb/src/react/core/hooks/useBridgeRoutes.ts b/packages/thirdweb/src/react/core/hooks/useBridgeRoutes.ts
new file mode 100644
index 00000000000..39eddea54f8
--- /dev/null
+++ b/packages/thirdweb/src/react/core/hooks/useBridgeRoutes.ts
@@ -0,0 +1,75 @@
+import { useQuery } from "@tanstack/react-query";
+import { routes } from "../../../bridge/Routes.js";
+import type { routes as RoutesTypes } from "../../../bridge/Routes.js";
+import { ApiError } from "../../../bridge/types/Errors.js";
+import { mapBridgeError } from "../errors/mapBridgeError.js";
+
+/**
+ * Parameters for the useBridgeRoutes hook
+ */
+export type UseBridgeRoutesParams = RoutesTypes.Options & {
+ /**
+ * Whether to enable the query. Useful for conditional fetching.
+ * @default true
+ */
+ enabled?: boolean;
+};
+
+/**
+ * Hook that fetches available bridge routes with caching and retry logic
+ *
+ * @param params - Parameters for fetching routes including client and filter options
+ * @returns React Query result with routes data, loading state, and error handling
+ *
+ * @example
+ * ```tsx
+ * const { data: routes, isLoading, error } = useBridgeRoutes({
+ * client: thirdwebClient,
+ * originChainId: 1,
+ * destinationChainId: 137,
+ * originTokenAddress: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE"
+ * });
+ * ```
+ */
+export function useBridgeRoutes(params: UseBridgeRoutesParams) {
+ const { enabled = true, ...routeParams } = params;
+
+ return useQuery({
+ queryKey: [
+ "bridge-routes",
+ {
+ originChainId: routeParams.originChainId,
+ originTokenAddress: routeParams.originTokenAddress,
+ destinationChainId: routeParams.destinationChainId,
+ destinationTokenAddress: routeParams.destinationTokenAddress,
+ maxSteps: routeParams.maxSteps,
+ sortBy: routeParams.sortBy,
+ limit: routeParams.limit,
+ offset: routeParams.offset,
+ },
+ ],
+ queryFn: () => routes(routeParams),
+ enabled: enabled && !!routeParams.client,
+ staleTime: 5 * 60 * 1000, // 5 minutes - routes are relatively stable
+ gcTime: 10 * 60 * 1000, // 10 minutes garbage collection
+ retry: (failureCount, error) => {
+ // Handle both ApiError and generic Error instances
+ if (error instanceof ApiError) {
+ const bridgeError = mapBridgeError(error);
+
+ // Don't retry on client-side errors (4xx)
+ if (
+ bridgeError.statusCode &&
+ bridgeError.statusCode >= 400 &&
+ bridgeError.statusCode < 500
+ ) {
+ return false;
+ }
+ }
+
+ // Retry up to 3 times for server errors or network issues
+ return failureCount < 3;
+ },
+ retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000), // Exponential backoff, max 30s
+ });
+}
diff --git a/packages/thirdweb/src/react/core/hooks/usePaymentMethods.test.ts b/packages/thirdweb/src/react/core/hooks/usePaymentMethods.test.ts
new file mode 100644
index 00000000000..92a68f5da1e
--- /dev/null
+++ b/packages/thirdweb/src/react/core/hooks/usePaymentMethods.test.ts
@@ -0,0 +1,336 @@
+/**
+ * @vitest-environment happy-dom
+ */
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { renderHook, waitFor } from "@testing-library/react";
+import { createElement } from "react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { routes } from "../../../bridge/Routes.js";
+import type { Token } from "../../../bridge/types/Token.js";
+import type { ThirdwebClient } from "../../../client/client.js";
+import { usePaymentMethods } from "./usePaymentMethods.js";
+
+// Mock the routes API
+vi.mock("../../../bridge/Routes.js", () => ({
+ routes: vi.fn(),
+}));
+
+const mockRoutes = vi.mocked(routes);
+
+// Mock data
+const mockDestinationToken: Token = {
+ chainId: 1,
+ address: "0xA0b86a33E6441aA7A6fbEEc9bb27e5e8bc3b8eD7",
+ decimals: 6,
+ symbol: "USDC",
+ name: "USD Coin",
+ priceUsd: 1.0,
+};
+
+const mockClient = {
+ clientId: "test-client-id",
+} as ThirdwebClient;
+
+const mockRouteData = [
+ {
+ originToken: {
+ chainId: 1,
+ address: "0xA0b86a33E6441aA7A6fbEEc9bb27e5e8bc3b8eD7",
+ decimals: 18,
+ symbol: "ETH",
+ name: "Ethereum",
+ priceUsd: 2000,
+ },
+ destinationToken: mockDestinationToken,
+ steps: [],
+ },
+ {
+ originToken: {
+ chainId: 137,
+ address: "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174",
+ decimals: 6,
+ symbol: "USDC",
+ name: "USD Coin",
+ priceUsd: 1.0,
+ },
+ destinationToken: mockDestinationToken,
+ steps: [],
+ },
+ {
+ originToken: {
+ chainId: 42161,
+ address: "0xFF970A61A04b1cA14834A43f5dE4533eBDDB5CC8",
+ decimals: 6,
+ symbol: "USDC",
+ name: "USD Coin",
+ priceUsd: 1.0,
+ },
+ destinationToken: mockDestinationToken,
+ steps: [],
+ },
+];
+
+// Test wrapper component
+const createWrapper = () => {
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: false,
+ },
+ },
+ });
+
+ return ({ children }: { children: React.ReactNode }) =>
+ createElement(QueryClientProvider, { client: queryClient }, children);
+};
+
+describe("usePaymentMethods", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("should require destinationToken and client parameters", () => {
+ const wrapper = createWrapper();
+
+ const { result } = renderHook(
+ () =>
+ usePaymentMethods({
+ destinationToken: mockDestinationToken,
+ destinationAmount: "1",
+ client: mockClient,
+ }),
+ { wrapper },
+ );
+
+ expect(result.current).toBeDefined();
+ expect(result.current.isLoading).toBe(true);
+ });
+
+ it("should fetch routes and transform data correctly", async () => {
+ mockRoutes.mockResolvedValueOnce(mockRouteData);
+ const wrapper = createWrapper();
+
+ const { result } = renderHook(
+ () =>
+ usePaymentMethods({
+ destinationToken: mockDestinationToken,
+ destinationAmount: "1",
+ client: mockClient,
+ }),
+ { wrapper },
+ );
+
+ // Initially loading
+ expect(result.current.isLoading).toBe(true);
+ expect(result.current.data).toEqual([]);
+
+ // Wait for query to resolve
+ await waitFor(() => {
+ expect(result.current.isSuccess).toBe(true);
+ });
+
+ // Should have transformed data
+ expect(result.current.data).toHaveLength(4); // 3 wallet methods + 1 fiat method
+
+ const walletMethod = result.current.data[0];
+ expect(walletMethod?.type).toBe("wallet");
+ if (walletMethod?.type === "wallet") {
+ expect(walletMethod.originToken).toEqual(mockRouteData[0]?.originToken);
+ }
+
+ const fiatMethod = result.current.data[3];
+ expect(fiatMethod?.type).toBe("fiat");
+ if (fiatMethod?.type === "fiat") {
+ expect(fiatMethod.currency).toBe("USD");
+ }
+ });
+
+ it("should call routes API with correct parameters", async () => {
+ mockRoutes.mockResolvedValueOnce(mockRouteData);
+ const wrapper = createWrapper();
+
+ renderHook(
+ () =>
+ usePaymentMethods({
+ destinationToken: mockDestinationToken,
+ destinationAmount: "1",
+ client: mockClient,
+ }),
+ { wrapper },
+ );
+
+ await waitFor(() => {
+ expect(mockRoutes).toHaveBeenCalledWith({
+ client: mockClient,
+ destinationChainId: mockDestinationToken.chainId,
+ destinationTokenAddress: mockDestinationToken.address,
+ sortBy: "popularity",
+ limit: 50,
+ });
+ });
+ });
+
+ it("should handle empty routes data", async () => {
+ mockRoutes.mockResolvedValueOnce([]);
+ const wrapper = createWrapper();
+
+ const { result } = renderHook(
+ () =>
+ usePaymentMethods({
+ destinationToken: mockDestinationToken,
+ destinationAmount: "1",
+ client: mockClient,
+ }),
+ { wrapper },
+ );
+
+ await waitFor(() => {
+ expect(result.current.isSuccess).toBe(true);
+ });
+
+ // Should only have fiat method when no routes
+ expect(result.current.data).toHaveLength(1);
+ expect(result.current.data[0]).toEqual({
+ type: "fiat",
+ currency: "USD",
+ });
+ });
+
+ it("should handle API errors gracefully", async () => {
+ const mockError = new Error("API Error");
+ mockRoutes.mockRejectedValueOnce(mockError);
+ const wrapper = createWrapper();
+
+ const { result } = renderHook(
+ () =>
+ usePaymentMethods({
+ destinationToken: mockDestinationToken,
+ destinationAmount: "1",
+ client: mockClient,
+ }),
+ { wrapper },
+ );
+
+ await waitFor(() => {
+ expect(result.current.isError).toBe(true);
+ });
+
+ expect(result.current.error).toBeTruthy();
+ expect(result.current.data).toEqual([]);
+ });
+
+ it("should deduplicate origin tokens", async () => {
+ // Mock data with duplicate origin tokens
+ const firstRoute = mockRouteData[0];
+ if (!firstRoute) {
+ throw new Error("Mock data is invalid");
+ }
+
+ const mockDataWithDuplicates = [
+ ...mockRouteData,
+ {
+ originToken: firstRoute.originToken, // Duplicate ETH
+ destinationToken: mockDestinationToken,
+ steps: [],
+ },
+ ];
+
+ mockRoutes.mockResolvedValueOnce(mockDataWithDuplicates);
+ const wrapper = createWrapper();
+
+ const { result } = renderHook(
+ () =>
+ usePaymentMethods({
+ destinationToken: mockDestinationToken,
+ destinationAmount: "1",
+ client: mockClient,
+ }),
+ { wrapper },
+ );
+
+ await waitFor(() => {
+ expect(result.current.isSuccess).toBe(true);
+ });
+
+ // Should still have only 4 methods (3 unique wallet + 1 fiat)
+ expect(result.current.data).toHaveLength(4);
+
+ // Check that ETH only appears once
+ const walletMethods = result.current.data.filter(
+ (m) => m.type === "wallet",
+ );
+ const ethMethods = walletMethods.filter(
+ (m) => m.type === "wallet" && m.originToken?.symbol === "ETH",
+ );
+ expect(ethMethods).toHaveLength(1);
+ });
+
+ it("should always include fiat payment option", async () => {
+ mockRoutes.mockResolvedValueOnce(mockRouteData);
+ const wrapper = createWrapper();
+
+ const { result } = renderHook(
+ () =>
+ usePaymentMethods({
+ destinationToken: mockDestinationToken,
+ destinationAmount: "1",
+ client: mockClient,
+ }),
+ { wrapper },
+ );
+
+ await waitFor(() => {
+ expect(result.current.isSuccess).toBe(true);
+ });
+
+ const fiatMethods = result.current.data.filter((m) => m.type === "fiat");
+ expect(fiatMethods).toHaveLength(1);
+ expect(fiatMethods[0]).toEqual({
+ type: "fiat",
+ currency: "USD",
+ });
+ });
+
+ it("should have correct query key for caching", async () => {
+ mockRoutes.mockResolvedValueOnce(mockRouteData);
+ const wrapper = createWrapper();
+
+ const { result } = renderHook(
+ () =>
+ usePaymentMethods({
+ destinationToken: mockDestinationToken,
+ destinationAmount: "1",
+ client: mockClient,
+ }),
+ { wrapper },
+ );
+
+ // The hook should use a query key that includes chain ID and token address
+ await waitFor(() => {
+ expect(result.current.isSuccess).toBe(true);
+ });
+
+ expect(mockRoutes).toHaveBeenCalledTimes(1);
+ });
+
+ it("should provide refetch functionality", async () => {
+ mockRoutes.mockResolvedValueOnce(mockRouteData);
+ const wrapper = createWrapper();
+
+ const { result } = renderHook(
+ () =>
+ usePaymentMethods({
+ destinationToken: mockDestinationToken,
+ destinationAmount: "1",
+ client: mockClient,
+ }),
+ { wrapper },
+ );
+
+ await waitFor(() => {
+ expect(result.current.isSuccess).toBe(true);
+ });
+
+ expect(typeof result.current.refetch).toBe("function");
+ });
+});
diff --git a/packages/thirdweb/src/react/core/hooks/usePaymentMethods.ts b/packages/thirdweb/src/react/core/hooks/usePaymentMethods.ts
new file mode 100644
index 00000000000..1083259071a
--- /dev/null
+++ b/packages/thirdweb/src/react/core/hooks/usePaymentMethods.ts
@@ -0,0 +1,203 @@
+import { useQuery } from "@tanstack/react-query";
+import { routes } from "../../../bridge/Routes.js";
+import type { Token } from "../../../bridge/types/Token.js";
+import { getCachedChain } from "../../../chains/utils.js";
+import type { ThirdwebClient } from "../../../client/client.js";
+import { isInsightEnabled } from "../../../insight/common.js";
+import { getOwnedTokens } from "../../../insight/get-tokens.js";
+import { toTokens } from "../../../utils/units.js";
+import type { Wallet } from "../../../wallets/interfaces/wallet.js";
+import type { PaymentMethod } from "../machines/paymentMachine.js";
+import { useActiveWallet } from "./wallets/useActiveWallet.js";
+
+type OwnedTokenWithQuote = {
+ originToken: Token;
+ balance: bigint;
+ originAmount: bigint;
+};
+
+/**
+ * Hook that returns available payment methods for BridgeEmbed
+ * Fetches real routes data based on the destination token
+ *
+ * @param options - Configuration options
+ * @param options.destinationToken - The destination token to find routes for
+ * @param options.client - ThirdwebClient for API calls
+ * @returns Available payment methods with route data
+ *
+ * @example
+ * ```tsx
+ * const { data: paymentMethods, isLoading, error } = usePaymentMethods({
+ * destinationToken,
+ * client
+ * });
+ * ```
+ */
+export function usePaymentMethods(options: {
+ destinationToken: Token;
+ destinationAmount: string;
+ client: ThirdwebClient;
+ payerWallet?: Wallet;
+ includeDestinationToken?: boolean;
+}) {
+ const {
+ destinationToken,
+ destinationAmount,
+ client,
+ payerWallet,
+ includeDestinationToken,
+ } = options;
+ const localWallet = useActiveWallet(); // TODO (bridge): get all connected wallets
+ const wallet = payerWallet || localWallet;
+
+ const routesQuery = useQuery({
+ queryKey: [
+ "bridge-routes",
+ destinationToken.chainId,
+ destinationToken.address,
+ destinationAmount,
+ payerWallet?.getAccount()?.address,
+ includeDestinationToken,
+ ],
+ queryFn: async (): Promise => {
+ if (!wallet) {
+ throw new Error("No wallet connected");
+ }
+ const allRoutes = await routes({
+ client,
+ destinationChainId: destinationToken.chainId,
+ destinationTokenAddress: destinationToken.address,
+ sortBy: "popularity",
+ includePrices: true,
+ maxSteps: 3,
+ limit: 100, // Get top 100 most popular routes
+ });
+
+ const allOriginTokens = includeDestinationToken
+ ? [destinationToken, ...allRoutes.map((route) => route.originToken)]
+ : allRoutes.map((route) => route.originToken);
+
+ // 1. Resolve all unique chains in the supported token map
+ const uniqueChains = Array.from(
+ new Set(allOriginTokens.map((t) => t.chainId)),
+ );
+
+ // 2. Check insight availability once per chain
+ const insightSupport = await Promise.all(
+ uniqueChains.map(async (c) => ({
+ chain: getCachedChain(c),
+ enabled: await isInsightEnabled(getCachedChain(c)),
+ })),
+ );
+ const insightEnabledChains = insightSupport.filter((c) => c.enabled);
+
+ // 3. ERC-20 balances for insight-enabled chains (batched 5 chains / call)
+ let owned: OwnedTokenWithQuote[] = [];
+ let page = 0;
+ const limit = 100;
+
+ while (true) {
+ const batch = await getOwnedTokens({
+ ownerAddress: wallet.getAccount()?.address || "",
+ chains: insightEnabledChains.map((c) => c.chain),
+ client,
+ queryOptions: {
+ limit,
+ page,
+ metadata: "false",
+ },
+ });
+
+ if (batch.length === 0) {
+ break;
+ }
+
+ // find matching origin token in allRoutes
+ const tokensWithBalance = batch
+ .map((b) => ({
+ originToken: allOriginTokens.find(
+ (t) =>
+ t.address.toLowerCase() === b.tokenAddress.toLowerCase() &&
+ t.chainId === b.chainId,
+ ),
+ balance: b.value,
+ originAmount: 0n,
+ }))
+ .filter((t) => !!t.originToken) as OwnedTokenWithQuote[];
+
+ owned = [...owned, ...tokensWithBalance];
+ page += 1;
+ }
+
+ const requiredDollarAmount =
+ Number.parseFloat(destinationAmount) * destinationToken.priceUsd;
+
+ // sort by dollar balance descending
+ owned.sort((a, b) => {
+ const aDollarBalance =
+ Number.parseFloat(toTokens(a.balance, a.originToken.decimals)) *
+ a.originToken.priceUsd;
+ const bDollarBalance =
+ Number.parseFloat(toTokens(b.balance, b.originToken.decimals)) *
+ b.originToken.priceUsd;
+ return bDollarBalance - aDollarBalance;
+ });
+
+ const suitableOriginTokens: OwnedTokenWithQuote[] = [];
+
+ for (const b of owned) {
+ if (b.originToken && b.balance > 0n) {
+ const dollarBalance =
+ Number.parseFloat(toTokens(b.balance, b.originToken.decimals)) *
+ b.originToken.priceUsd;
+ if (b.originToken.priceUsd && dollarBalance < requiredDollarAmount) {
+ continue;
+ }
+
+ if (
+ includeDestinationToken &&
+ b.originToken.address.toLowerCase() ===
+ destinationToken.address.toLowerCase() &&
+ b.originToken.chainId === destinationToken.chainId
+ ) {
+ // add same token to the front of the list
+ suitableOriginTokens.unshift({
+ balance: b.balance,
+ originAmount: 0n,
+ originToken: b.originToken,
+ });
+ continue;
+ }
+
+ suitableOriginTokens.push({
+ balance: b.balance,
+ originAmount: 0n,
+ originToken: b.originToken,
+ });
+ }
+ }
+
+ const transformedRoutes = [
+ ...suitableOriginTokens.map((s) => ({
+ type: "wallet" as const,
+ payerWallet: wallet,
+ originToken: s.originToken,
+ balance: s.balance,
+ })),
+ ];
+ return transformedRoutes;
+ },
+ staleTime: 5 * 60 * 1000, // 5 minutes
+ refetchOnWindowFocus: false,
+ enabled: !!wallet,
+ });
+
+ return {
+ data: routesQuery.data || [],
+ isLoading: routesQuery.isLoading,
+ error: routesQuery.error,
+ isError: routesQuery.isError,
+ isSuccess: routesQuery.isSuccess,
+ refetch: routesQuery.refetch,
+ };
+}
diff --git a/packages/thirdweb/src/react/core/hooks/useStepExecutor.ts b/packages/thirdweb/src/react/core/hooks/useStepExecutor.ts
new file mode 100644
index 00000000000..fe6e1825cc0
--- /dev/null
+++ b/packages/thirdweb/src/react/core/hooks/useStepExecutor.ts
@@ -0,0 +1,606 @@
+import { useQuery } from "@tanstack/react-query";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import type { status as OnrampStatus } from "../../../bridge/OnrampStatus.js";
+import { ApiError } from "../../../bridge/types/Errors.js";
+import type {
+ RouteStep,
+ RouteTransaction,
+} from "../../../bridge/types/Route.js";
+import type { Status } from "../../../bridge/types/Status.js";
+import { getCachedChain } from "../../../chains/utils.js";
+import type { ThirdwebClient } from "../../../client/client.js";
+import { waitForReceipt } from "../../../transaction/actions/wait-for-tx-receipt.js";
+import { stringify } from "../../../utils/json.js";
+import type { Account, Wallet } from "../../../wallets/interfaces/wallet.js";
+import type { WindowAdapter } from "../adapters/WindowAdapter.js";
+import {
+ type BridgePrepareRequest,
+ type BridgePrepareResult,
+ useBridgePrepare,
+} from "./useBridgePrepare.js";
+
+/**
+ * Type for completed status results from Bridge.status and Onramp.status
+ */
+export type CompletedStatusResult =
+ | ({ type: "buy" } & Extract)
+ | ({ type: "sell" } & Extract)
+ | ({ type: "transfer" } & Extract)
+ | ({ type: "onramp" } & Extract<
+ OnrampStatus.Result,
+ { status: "COMPLETED" }
+ >);
+
+/**
+ * Options for the step executor hook
+ */
+interface StepExecutorOptions {
+ /** Prepared quote returned by Bridge.prepare */
+ request: BridgePrepareRequest;
+ /** Wallet instance providing getAccount() & sendTransaction */
+ wallet: Wallet;
+ /** Window adapter for opening on-ramp URLs (web / RN) */
+ windowAdapter: WindowAdapter;
+ /** Thirdweb client for API calls */
+ client: ThirdwebClient;
+ /** Auto start execution as soon as hook mounts */
+ autoStart?: boolean;
+ /** Callback when all steps complete successfully - receives array of all completed status results */
+ onComplete?: (completedStatuses: CompletedStatusResult[]) => void;
+}
+
+/**
+ * Internal flattened transaction type
+ */
+interface FlattenedTx extends RouteTransaction {
+ /** Index in flat array */
+ _index: number;
+ /** Parent step index */
+ _stepIndex: number;
+}
+
+/**
+ * Public return type of useStepExecutor
+ */
+interface StepExecutorResult {
+ currentStep?: RouteStep;
+ currentTxIndex?: number;
+ progress: number; // 0–100
+ onrampStatus?: "pending" | "executing" | "completed" | "failed";
+ executionState: "fetching" | "idle" | "executing" | "auto-starting";
+ steps?: RouteStep[];
+ error?: ApiError;
+ start: () => void;
+ cancel: () => void;
+ retry: () => void;
+}
+
+/**
+ * Flatten RouteStep[] into a linear list of transactions preserving ordering & indices.
+ */
+function flattenRouteSteps(steps: RouteStep[]): FlattenedTx[] {
+ const out: FlattenedTx[] = [];
+ steps.forEach((step, stepIdx) => {
+ step.transactions?.forEach((tx, _txIdx) => {
+ out.push({
+ ...(tx as RouteTransaction),
+ _index: out.length,
+ _stepIndex: stepIdx,
+ });
+ });
+ });
+ return out;
+}
+
+/**
+ * Hook that sequentially executes prepared steps.
+ * NOTE: initial implementation only exposes progress + basic state machine. Actual execution logic will follow in later subtasks.
+ */
+export function useStepExecutor(
+ options: StepExecutorOptions,
+): StepExecutorResult {
+ const {
+ request,
+ wallet,
+ windowAdapter,
+ client,
+ autoStart = false,
+ onComplete,
+ } = options;
+
+ const { data: preparedQuote, isLoading } = useBridgePrepare(request);
+
+ // Flatten all transactions upfront
+ const flatTxs = useMemo(
+ () => (preparedQuote?.steps ? flattenRouteSteps(preparedQuote.steps) : []),
+ [preparedQuote?.steps],
+ );
+
+ // State management
+ const [currentTxIndex, setCurrentTxIndex] = useState(
+ undefined,
+ );
+ const [executionState, setExecutionState] = useState<
+ "fetching" | "idle" | "executing" | "auto-starting"
+ >("idle");
+ const [error, setError] = useState(undefined);
+ const [completedTxs, setCompletedTxs] = useState>(new Set());
+ const [onrampStatus, setOnrampStatus] = useState<
+ "pending" | "executing" | "completed" | "failed" | undefined
+ >(preparedQuote?.type === "onramp" ? "pending" : undefined);
+
+ useQuery({
+ queryKey: [
+ "bridge-quote-execution-state",
+ stringify(preparedQuote?.steps),
+ isLoading,
+ ],
+ queryFn: async () => {
+ if (!isLoading) {
+ setExecutionState("idle");
+ } else {
+ setExecutionState("fetching");
+ }
+ return executionState;
+ },
+ });
+
+ // Cancellation tracking
+ const abortControllerRef = useRef(null);
+
+ // Get current step based on current tx index
+ const currentStep = useMemo(() => {
+ if (typeof preparedQuote?.steps === "undefined") return undefined;
+ if (currentTxIndex === undefined) {
+ return undefined;
+ }
+ const tx = flatTxs[currentTxIndex];
+ return tx ? preparedQuote.steps[tx._stepIndex] : undefined;
+ }, [currentTxIndex, flatTxs, preparedQuote?.steps]);
+
+ // Calculate progress including onramp step
+ const progress = useMemo(() => {
+ if (typeof preparedQuote?.type === "undefined") return 0;
+ const totalSteps =
+ flatTxs.length + (preparedQuote.type === "onramp" ? 1 : 0);
+ if (totalSteps === 0) {
+ return 0;
+ }
+ const completedSteps =
+ completedTxs.size + (onrampStatus === "completed" ? 1 : 0);
+ return Math.round((completedSteps / totalSteps) * 100);
+ }, [completedTxs.size, flatTxs.length, preparedQuote?.type, onrampStatus]);
+
+ // Exponential backoff polling utility
+ const poller = useCallback(
+ async (
+ pollFn: () => Promise<{
+ completed: boolean;
+ }>,
+ abortSignal: AbortSignal,
+ ) => {
+ const delay = 2000; // 2 second poll interval
+
+ while (!abortSignal.aborted) {
+ const result = await pollFn();
+ if (result.completed) {
+ return;
+ }
+
+ await new Promise((resolve) => {
+ const timeout = setTimeout(resolve, delay);
+ abortSignal.addEventListener("abort", () => clearTimeout(timeout), {
+ once: true,
+ });
+ });
+ }
+
+ throw new Error("Polling aborted");
+ },
+ [],
+ );
+
+ // Execute a single transaction
+ const executeSingleTx = useCallback(
+ async (
+ tx: FlattenedTx,
+ account: Account,
+ completedStatusResults: CompletedStatusResult[],
+ abortSignal: AbortSignal,
+ ) => {
+ if (typeof preparedQuote?.type === "undefined") {
+ throw new Error("No quote generated. This is unexpected.");
+ }
+ const { prepareTransaction } = await import(
+ "../../../transaction/prepare-transaction.js"
+ );
+ const { sendTransaction } = await import(
+ "../../../transaction/actions/send-transaction.js"
+ );
+
+ // Prepare the transaction
+ const preparedTx = prepareTransaction({
+ chain: tx.chain,
+ client: tx.client,
+ to: tx.to,
+ data: tx.data,
+ value: tx.value,
+ });
+
+ // Send the transaction
+ const result = await sendTransaction({
+ account,
+ transaction: preparedTx,
+ });
+ const hash = result.transactionHash;
+
+ if (tx.action === "approval" || tx.action === "fee") {
+ // don't poll status for approval transactions, just wait for confirmation
+ await waitForReceipt(result);
+ return;
+ }
+
+ // Poll for completion
+ const { status } = await import("../../../bridge/Status.js");
+ await poller(async () => {
+ const statusResult = await status({
+ transactionHash: hash,
+ chainId: tx.chainId,
+ client: tx.client,
+ });
+
+ if (statusResult.status === "COMPLETED") {
+ // Add type field from preparedQuote for discriminated union
+ const typedStatusResult = {
+ type: preparedQuote.type,
+ ...statusResult,
+ };
+ completedStatusResults.push(typedStatusResult);
+ return { completed: true };
+ }
+
+ if (statusResult.status === "FAILED") {
+ throw new Error("Payment failed");
+ }
+
+ return { completed: false };
+ }, abortSignal);
+ },
+ [poller, preparedQuote?.type],
+ );
+
+ // Execute batch transactions
+ const executeBatch = useCallback(
+ async (
+ txs: FlattenedTx[],
+ account: Account,
+ completedStatusResults: CompletedStatusResult[],
+ abortSignal: AbortSignal,
+ ) => {
+ if (typeof preparedQuote?.type === "undefined") {
+ throw new Error("No quote generated. This is unexpected.");
+ }
+ if (!account.sendBatchTransaction) {
+ throw new Error("Account does not support batch transactions");
+ }
+
+ const { prepareTransaction } = await import(
+ "../../../transaction/prepare-transaction.js"
+ );
+ const { sendBatchTransaction } = await import(
+ "../../../transaction/actions/send-batch-transaction.js"
+ );
+
+ // Prepare and convert all transactions
+ const serializableTxs = await Promise.all(
+ txs.map(async (tx) => {
+ const preparedTx = prepareTransaction({
+ chain: tx.chain,
+ client: tx.client,
+ to: tx.to,
+ data: tx.data,
+ value: tx.value,
+ });
+ return preparedTx;
+ }),
+ );
+
+ // Send batch
+ const result = await sendBatchTransaction({
+ account,
+ transactions: serializableTxs,
+ });
+ // Batch transactions return a single receipt, we need to handle this differently
+ // For now, we'll assume all transactions in the batch succeed together
+
+ // Poll for the first transaction's completion (representative of the batch)
+ if (txs.length === 0) {
+ throw new Error("No transactions to batch");
+ }
+ const firstTx = txs[0];
+ if (!firstTx) {
+ throw new Error("Invalid batch transaction");
+ }
+
+ const { status } = await import("../../../bridge/Status.js");
+ await poller(async () => {
+ const statusResult = await status({
+ transactionHash: result.transactionHash,
+ chainId: firstTx.chainId,
+ client: firstTx.client,
+ });
+
+ if (statusResult.status === "COMPLETED") {
+ // Add type field from preparedQuote for discriminated union
+ const typedStatusResult = {
+ type: preparedQuote.type,
+ ...statusResult,
+ };
+ completedStatusResults.push(typedStatusResult);
+ return { completed: true };
+ }
+
+ if (statusResult.status === "FAILED") {
+ throw new Error("Payment failed");
+ }
+
+ return { completed: false };
+ }, abortSignal);
+ },
+ [poller, preparedQuote?.type],
+ );
+
+ // Execute onramp step
+ const executeOnramp = useCallback(
+ async (
+ onrampQuote: Extract,
+ completedStatusResults: CompletedStatusResult[],
+ abortSignal: AbortSignal,
+ ) => {
+ setOnrampStatus("executing");
+ // Open the payment URL
+ windowAdapter.open(onrampQuote.link);
+
+ // Poll for completion using the session ID
+ const { Onramp } = await import("../../../bridge/index.js");
+ await poller(async () => {
+ const statusResult = await Onramp.status({
+ id: onrampQuote.id,
+ client: client,
+ });
+
+ const status = statusResult.status;
+ if (status === "COMPLETED") {
+ setOnrampStatus("completed");
+ // Add type field for discriminated union
+ const typedStatusResult = {
+ type: "onramp" as const,
+ ...statusResult,
+ };
+ completedStatusResults.push(typedStatusResult);
+ return { completed: true };
+ } else if (status === "FAILED") {
+ setOnrampStatus("failed");
+ }
+
+ return { completed: false };
+ }, abortSignal);
+ },
+ [poller, client, windowAdapter],
+ );
+
+ // Main execution function
+ const execute = useCallback(async () => {
+ if (typeof preparedQuote?.type === "undefined") {
+ throw new Error("No quote generated. This is unexpected.");
+ }
+ if (executionState !== "idle") {
+ return;
+ }
+
+ setExecutionState("executing");
+ setError(undefined);
+ const completedStatusResults: CompletedStatusResult[] = [];
+
+ // Create new abort controller
+ const abortController = new AbortController();
+ abortControllerRef.current = abortController;
+
+ try {
+ // Execute onramp first if configured and not already completed
+ if (preparedQuote.type === "onramp" && onrampStatus === "pending") {
+ await executeOnramp(
+ preparedQuote,
+ completedStatusResults,
+ abortController.signal,
+ );
+ }
+
+ // Then execute transactions
+ const account = wallet.getAccount();
+ if (!account) {
+ throw new ApiError({
+ code: "INVALID_INPUT",
+ message: "Wallet not connected",
+ statusCode: 400,
+ });
+ }
+
+ // Start from where we left off, or from the beginning
+ const startIndex = currentTxIndex ?? 0;
+
+ for (let i = startIndex; i < flatTxs.length; i++) {
+ if (abortController.signal.aborted) {
+ break;
+ }
+
+ const currentTx = flatTxs[i];
+ if (!currentTx) {
+ continue; // Skip invalid index
+ }
+
+ setCurrentTxIndex(i);
+ const currentStepData = preparedQuote.steps[currentTx._stepIndex];
+ if (!currentStepData) {
+ throw new Error(`Invalid step index: ${currentTx._stepIndex}`);
+ }
+
+ // switch chain if needed
+ if (currentTx.chainId !== wallet.getChain()?.id) {
+ await wallet.switchChain(getCachedChain(currentTx.chainId));
+ }
+
+ // Check if we can batch transactions
+ const canBatch =
+ account.sendBatchTransaction !== undefined && i < flatTxs.length - 1; // Not the last transaction
+
+ if (canBatch) {
+ // Find consecutive transactions on the same chain
+ const batchTxs: FlattenedTx[] = [currentTx];
+ let j = i + 1;
+ while (j < flatTxs.length) {
+ const nextTx = flatTxs[j];
+ if (!nextTx || nextTx.chainId !== currentTx.chainId) {
+ break;
+ }
+ batchTxs.push(nextTx);
+ j++;
+ }
+
+ // Execute batch if we have multiple transactions
+ if (batchTxs.length > 1) {
+ await executeBatch(
+ batchTxs,
+ account,
+ completedStatusResults,
+ abortController.signal,
+ );
+
+ // Mark all batched transactions as completed
+ for (const tx of batchTxs) {
+ setCompletedTxs((prev) => new Set(prev).add(tx._index));
+ }
+
+ // Skip ahead
+ i = j - 1;
+ continue;
+ }
+ }
+
+ // Execute single transaction
+ await executeSingleTx(
+ currentTx,
+ account,
+ completedStatusResults,
+ abortController.signal,
+ );
+
+ // Mark transaction as completed
+ setCompletedTxs((prev) => new Set(prev).add(currentTx._index));
+ }
+
+ // All done - check if we actually completed everything
+ if (!abortController.signal.aborted) {
+ setCurrentTxIndex(undefined);
+
+ // Call completion callback with all completed status results
+ if (onComplete) {
+ onComplete(completedStatusResults);
+ }
+ }
+ } catch (err) {
+ console.error("Error executing payment", err);
+ if (err instanceof ApiError) {
+ setError(err);
+ } else {
+ setError(
+ new ApiError({
+ code: "UNKNOWN_ERROR",
+ message: (err as Error)?.message || "An unknown error occurred",
+ statusCode: 500,
+ }),
+ );
+ }
+ } finally {
+ setExecutionState("idle");
+ abortControllerRef.current = null;
+ }
+ }, [
+ executionState,
+ wallet,
+ currentTxIndex,
+ flatTxs,
+ executeSingleTx,
+ executeBatch,
+ onrampStatus,
+ executeOnramp,
+ onComplete,
+ preparedQuote,
+ ]);
+
+ // Start execution
+ const start = useCallback(() => {
+ if (executionState === "idle") {
+ execute();
+ }
+ }, [execute, executionState]);
+
+ // Cancel execution
+ const cancel = useCallback(() => {
+ if (abortControllerRef.current) {
+ abortControllerRef.current.abort();
+ }
+ setExecutionState("idle");
+ if (onrampStatus === "executing") {
+ setOnrampStatus("pending");
+ }
+ }, [onrampStatus]);
+
+ // Retry from failed transaction
+ const retry = useCallback(() => {
+ if (error) {
+ setError(undefined);
+ execute();
+ }
+ }, [error, execute]);
+
+ const hasInitialized = useRef(false);
+
+ useEffect(() => {
+ if (
+ autoStart &&
+ executionState === "idle" &&
+ currentTxIndex === undefined &&
+ !hasInitialized.current
+ ) {
+ hasInitialized.current = true;
+ setExecutionState("auto-starting");
+ // add a delay to ensure the UI is ready
+ setTimeout(() => {
+ start();
+ }, 500);
+ }
+ }, [autoStart, executionState, currentTxIndex, start]);
+
+ // Cleanup on unmount
+ useEffect(() => {
+ return () => {
+ if (abortControllerRef.current) {
+ abortControllerRef.current.abort();
+ }
+ };
+ }, []);
+
+ return {
+ currentStep,
+ currentTxIndex,
+ progress,
+ executionState,
+ steps: preparedQuote?.steps,
+ onrampStatus,
+ error,
+ start,
+ cancel,
+ retry,
+ };
+}
diff --git a/packages/thirdweb/src/react/core/hooks/useTransactionDetails.ts b/packages/thirdweb/src/react/core/hooks/useTransactionDetails.ts
new file mode 100644
index 00000000000..a6c79f48a48
--- /dev/null
+++ b/packages/thirdweb/src/react/core/hooks/useTransactionDetails.ts
@@ -0,0 +1,177 @@
+import { useQuery } from "@tanstack/react-query";
+import type { AbiFunction } from "abitype";
+import { toFunctionSelector } from "viem";
+import type { Token } from "../../../bridge/index.js";
+import type { ThirdwebClient } from "../../../client/client.js";
+import { NATIVE_TOKEN_ADDRESS } from "../../../constants/addresses.js";
+import type { CompilerMetadata } from "../../../contract/actions/compiler-metadata.js";
+import { getCompilerMetadata } from "../../../contract/actions/get-compiler-metadata.js";
+import { getContract } from "../../../contract/contract.js";
+import { decimals } from "../../../extensions/erc20/read/decimals.js";
+import { getToken } from "../../../pay/convert/get-token.js";
+import { encode } from "../../../transaction/actions/encode.js";
+import type { PreparedTransaction } from "../../../transaction/prepare-transaction.js";
+import { getTransactionGasCost } from "../../../transaction/utils.js";
+import { resolvePromisedValue } from "../../../utils/promise/resolve-promised-value.js";
+import { toTokens } from "../../../utils/units.js";
+import {
+ formatCurrencyAmount,
+ formatTokenAmount,
+} from "../../web/ui/ConnectWallet/screens/formatTokenBalance.js";
+import { useChainMetadata } from "./others/useChainQuery.js";
+
+interface TransactionDetails {
+ contractMetadata: CompilerMetadata | null;
+ functionInfo: {
+ functionName: string;
+ selector: string;
+ description?: string;
+ };
+ usdValueDisplay: string | null;
+ txCostDisplay: string;
+ gasCostDisplay: string | null;
+ tokenInfo: Token | null;
+ costWei: bigint;
+ gasCostWei: bigint | null;
+ totalCost: string;
+ totalCostWei: bigint;
+}
+
+interface UseTransactionDetailsOptions {
+ transaction: PreparedTransaction;
+ client: ThirdwebClient;
+}
+
+/**
+ * Hook to fetch comprehensive transaction details including contract metadata,
+ * function information, cost calculations, and gas estimates.
+ */
+export function useTransactionDetails({
+ transaction,
+ client,
+}: UseTransactionDetailsOptions) {
+ const chainMetadata = useChainMetadata(transaction.chain);
+
+ return useQuery({
+ queryKey: [
+ "transaction-details",
+ transaction.to,
+ transaction.chain.id,
+ transaction.erc20Value,
+ ],
+ queryFn: async (): Promise => {
+ // Create contract instance for metadata fetching
+ const contract = getContract({
+ client,
+ chain: transaction.chain,
+ address: transaction.to as string,
+ });
+
+ const [contractMetadata, value, erc20Value, transactionData] =
+ await Promise.all([
+ getCompilerMetadata(contract).catch(() => null),
+ resolvePromisedValue(transaction.value),
+ resolvePromisedValue(transaction.erc20Value),
+ encode(transaction).catch(() => "0x"),
+ ]);
+
+ const [tokenInfo, gasCostWei] = await Promise.all([
+ getToken(
+ client,
+ erc20Value ? erc20Value.tokenAddress : NATIVE_TOKEN_ADDRESS,
+ transaction.chain.id,
+ ).catch(() => null),
+ getTransactionGasCost(transaction).catch(() => null),
+ ]);
+
+ // Process function info from ABI if available
+ let functionInfo = {
+ functionName: "Contract Call",
+ selector: "0x",
+ description: undefined,
+ };
+
+ if (contractMetadata?.abi && transactionData.length >= 10) {
+ try {
+ const selector = transactionData.slice(0, 10) as `0x${string}`;
+ const abi = contractMetadata.abi;
+
+ // Find matching function in ABI
+ const abiItems = Array.isArray(abi) ? abi : [];
+ const functions = abiItems
+ .filter(
+ (item) =>
+ item &&
+ typeof item === "object" &&
+ "type" in item &&
+ (item as { type: string }).type === "function",
+ )
+ .map((item) => item as AbiFunction);
+
+ const matchingFunction = functions.find((fn) => {
+ return toFunctionSelector(fn) === selector;
+ });
+
+ if (matchingFunction) {
+ functionInfo = {
+ functionName: matchingFunction.name,
+ selector,
+ description: undefined, // Skip devdoc for now
+ };
+ }
+ } catch {
+ // Keep default values
+ }
+ }
+
+ const resolveDecimals = async () => {
+ if (tokenInfo) {
+ return tokenInfo.decimals;
+ }
+ if (erc20Value) {
+ return decimals({
+ contract: getContract({
+ client,
+ chain: transaction.chain,
+ address: erc20Value.tokenAddress,
+ }),
+ });
+ }
+ return 18;
+ };
+
+ const decimal = await resolveDecimals();
+ const costWei = erc20Value ? erc20Value.amountWei : value || 0n;
+ const nativeTokenSymbol =
+ chainMetadata.data?.nativeCurrency?.symbol || "ETH";
+ const tokenSymbol = tokenInfo?.symbol || nativeTokenSymbol;
+
+ const totalCostWei = erc20Value
+ ? erc20Value.amountWei
+ : (value || 0n) + (gasCostWei || 0n);
+ const totalCost = toTokens(totalCostWei, decimal);
+
+ const usdValue = tokenInfo?.priceUsd
+ ? Number(totalCost) * tokenInfo.priceUsd
+ : null;
+
+ return {
+ contractMetadata,
+ functionInfo,
+ usdValueDisplay: usdValue
+ ? formatCurrencyAmount("USD", usdValue)
+ : null,
+ txCostDisplay: `${formatTokenAmount(costWei, decimal)} ${tokenSymbol}`,
+ gasCostDisplay: gasCostWei
+ ? `${formatTokenAmount(gasCostWei, 18)} ${nativeTokenSymbol}`
+ : null,
+ tokenInfo,
+ costWei,
+ gasCostWei,
+ totalCost,
+ totalCostWei,
+ };
+ },
+ enabled: !!transaction.to && !!chainMetadata.data,
+ });
+}
diff --git a/packages/thirdweb/src/react/core/hooks/wallets/useAutoConnectCore.test.tsx b/packages/thirdweb/src/react/core/hooks/wallets/useAutoConnectCore.test.tsx
index 5cb10c093b6..be98b304186 100644
--- a/packages/thirdweb/src/react/core/hooks/wallets/useAutoConnectCore.test.tsx
+++ b/packages/thirdweb/src/react/core/hooks/wallets/useAutoConnectCore.test.tsx
@@ -1,6 +1,6 @@
import { renderHook, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
-import { describe, expect, it, vi } from "vitest";
+import { describe, expect, it } from "vitest";
import { MockStorage } from "~test/mocks/storage.js";
import { TEST_CLIENT } from "~test/test-clients.js";
import { TEST_ACCOUNT_A } from "~test/test-wallets.js";
@@ -98,60 +98,4 @@ describe("useAutoConnectCore", () => {
{ timeout: 1000 },
);
});
-
- it("should call onTimeout on ... timeout", async () => {
- const wallet = createWalletAdapter({
- adaptedAccount: TEST_ACCOUNT_A,
- client: TEST_CLIENT,
- chain: ethereum,
- onDisconnect: () => {},
- switchChain: () => {},
- });
- mockStorage.setItem("thirdweb:active-wallet-id", wallet.id);
- const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
- const infoSpy = vi.spyOn(console, "info").mockImplementation(() => {});
- // Purposefully mock the wallet.autoConnect method to test the timeout logic
- wallet.autoConnect = () =>
- new Promise((resolve) => {
- setTimeout(() => {
- // @ts-ignore Mock purpose
- resolve("Connection successful");
- }, 2100);
- });
- renderHook(
- () =>
- useAutoConnectCore(
- mockStorage,
- {
- wallets: [wallet],
- client: TEST_CLIENT,
- onTimeout: () => console.info("TIMEOUTTED"),
- timeout: 0,
- },
- (id: WalletId) =>
- createWalletAdapter({
- adaptedAccount: TEST_ACCOUNT_A,
- client: TEST_CLIENT,
- chain: ethereum,
- onDisconnect: () => {
- console.warn(id);
- },
- switchChain: () => {},
- }),
- ),
- { wrapper },
- );
- await waitFor(
- () => {
- expect(warnSpy).toHaveBeenCalled();
- expect(warnSpy).toHaveBeenCalledWith(
- "AutoConnect timeout: 0ms limit exceeded.",
- );
- expect(infoSpy).toHaveBeenCalled();
- expect(infoSpy).toHaveBeenCalledWith("TIMEOUTTED");
- warnSpy.mockRestore();
- },
- { timeout: 2000 },
- );
- });
});
diff --git a/packages/thirdweb/src/react/core/machines/.keep b/packages/thirdweb/src/react/core/machines/.keep
new file mode 100644
index 00000000000..f5a7aa3fffa
--- /dev/null
+++ b/packages/thirdweb/src/react/core/machines/.keep
@@ -0,0 +1,2 @@
+# Placeholder file to maintain directory structure
+# This directory will contain XState machine definitions for payment flows
\ No newline at end of file
diff --git a/packages/thirdweb/src/react/core/machines/paymentMachine.test.ts b/packages/thirdweb/src/react/core/machines/paymentMachine.test.ts
new file mode 100644
index 00000000000..f720770eaa6
--- /dev/null
+++ b/packages/thirdweb/src/react/core/machines/paymentMachine.test.ts
@@ -0,0 +1,691 @@
+/**
+ * @vitest-environment happy-dom
+ */
+import { act, renderHook } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { TEST_CLIENT } from "../../../../test/src/test-clients.js";
+import { TEST_IN_APP_WALLET_A } from "../../../../test/src/test-wallets.js";
+import type { Token } from "../../../bridge/types/Token.js";
+import { defineChain } from "../../../chains/utils.js";
+import { NATIVE_TOKEN_ADDRESS } from "../../../constants/addresses.js";
+import type { AsyncStorage } from "../../../utils/storage/AsyncStorage.js";
+import type { WindowAdapter } from "../adapters/WindowAdapter.js";
+import type { BridgePrepareResult } from "../hooks/useBridgePrepare.js";
+import {
+ type PaymentMachineContext,
+ type PaymentMethod,
+ usePaymentMachine,
+} from "./paymentMachine.js";
+
+// Mock adapters
+const mockWindowAdapter: WindowAdapter = {
+ open: vi.fn().mockResolvedValue(undefined),
+};
+
+const mockStorage: AsyncStorage = {
+ getItem: vi.fn().mockResolvedValue(null),
+ setItem: vi.fn().mockResolvedValue(undefined),
+ removeItem: vi.fn().mockResolvedValue(undefined),
+};
+
+// Test token objects
+const testUSDCToken: Token = {
+ chainId: 137,
+ address: "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174",
+ name: "USD Coin (PoS)",
+ symbol: "USDC",
+ decimals: 6,
+ priceUsd: 1.0,
+};
+
+const testETHToken: Token = {
+ chainId: 1,
+ address: NATIVE_TOKEN_ADDRESS,
+ name: "Ethereum",
+ symbol: "ETH",
+ decimals: 18,
+ priceUsd: 2500.0,
+};
+
+const testTokenForPayment: Token = {
+ chainId: 1,
+ address: "0xA0b86a33E6425c03e54c4b45DCb6d75b6B72E2AA",
+ name: "Test Token",
+ symbol: "TT",
+ decimals: 18,
+ priceUsd: 1.0,
+};
+
+const mockBuyQuote: BridgePrepareResult = {
+ type: "buy",
+ originAmount: 1000000000000000000n, // 1 ETH
+ destinationAmount: 100000000n, // 100 USDC
+ timestamp: Date.now(),
+ estimatedExecutionTimeMs: 120000, // 2 minutes
+ steps: [
+ {
+ originToken: testETHToken,
+ destinationToken: testUSDCToken,
+ originAmount: 1000000000000000000n,
+ destinationAmount: 100000000n,
+ estimatedExecutionTimeMs: 120000,
+ transactions: [
+ {
+ action: "approval" as const,
+ id: "0x123" as const,
+ to: "0x456" as const,
+ data: "0x789" as const,
+ chainId: 1,
+ client: TEST_CLIENT,
+ chain: defineChain(1),
+ },
+ {
+ action: "buy" as const,
+ id: "0xabc" as const,
+ to: "0xdef" as const,
+ data: "0x012" as const,
+ value: 1000000000000000000n,
+ chainId: 1,
+ client: TEST_CLIENT,
+ chain: defineChain(1),
+ },
+ ],
+ },
+ ],
+ intent: {
+ originChainId: 1,
+ originTokenAddress: NATIVE_TOKEN_ADDRESS,
+ destinationChainId: 137,
+ destinationTokenAddress: "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174",
+ amount: 100000000n,
+ sender: "0xa3841994009B4fEabb01ebcC62062F9E56F701CD",
+ receiver: "0xa3841994009B4fEabb01ebcC62062F9E56F701CD",
+ },
+};
+
+describe("PaymentMachine", () => {
+ let adapters: PaymentMachineContext["adapters"];
+
+ beforeEach(() => {
+ adapters = {
+ window: mockWindowAdapter,
+ storage: mockStorage,
+ };
+ });
+
+ it("should initialize in init state", () => {
+ const { result } = renderHook(() =>
+ usePaymentMachine(adapters, "fund_wallet"),
+ );
+ const [state] = result.current;
+
+ expect(state.value).toBe("init");
+ expect(state.context.mode).toBe("fund_wallet");
+ expect(state.context.adapters).toBe(adapters);
+ });
+
+ it("should transition through happy path with wallet payment method", () => {
+ const { result } = renderHook(() =>
+ usePaymentMachine(adapters, "fund_wallet"),
+ );
+
+ // Confirm destination
+ act(() => {
+ const [, send] = result.current;
+ send({
+ type: "DESTINATION_CONFIRMED",
+ destinationToken: testTokenForPayment,
+ destinationAmount: "100",
+ receiverAddress: "0xa3841994009B4fEabb01ebcC62062F9E56F701CD",
+ });
+ });
+
+ let [state] = result.current;
+ expect(state.value).toBe("methodSelection");
+ expect(state.context.destinationToken).toEqual(testTokenForPayment);
+ expect(state.context.destinationAmount).toBe("100");
+ expect(state.context.receiverAddress).toBe(
+ "0xa3841994009B4fEabb01ebcC62062F9E56F701CD",
+ );
+
+ // Select wallet payment method
+ const walletPaymentMethod: PaymentMethod = {
+ type: "wallet",
+ originToken: testUSDCToken,
+ payerWallet: TEST_IN_APP_WALLET_A,
+ balance: 1000000000000000000n,
+ };
+
+ act(() => {
+ const [, send] = result.current;
+ send({
+ type: "PAYMENT_METHOD_SELECTED",
+ paymentMethod: walletPaymentMethod,
+ });
+ });
+
+ [state] = result.current;
+ expect(state.value).toBe("quote");
+ expect(state.context.selectedPaymentMethod).toEqual(walletPaymentMethod);
+
+ // Receive quote
+ act(() => {
+ const [, send] = result.current;
+ send({
+ type: "QUOTE_RECEIVED",
+ preparedQuote: mockBuyQuote,
+ });
+ });
+
+ [state] = result.current;
+ expect(state.value).toBe("preview");
+ expect(state.context.preparedQuote).toBe(mockBuyQuote);
+
+ // Confirm route
+ act(() => {
+ const [, send] = result.current;
+ send({
+ type: "ROUTE_CONFIRMED",
+ });
+ });
+
+ [state] = result.current;
+ expect(state.value).toBe("execute");
+ expect(state.context.selectedPaymentMethod).toBe(walletPaymentMethod);
+
+ // Complete execution
+ act(() => {
+ const [, send] = result.current;
+ send({
+ type: "EXECUTION_COMPLETE",
+ completedStatuses: [
+ {
+ type: "buy",
+ status: "COMPLETED",
+ paymentId: "test-payment-id",
+ originAmount: 1000000000000000000n,
+ destinationAmount: 100000000n,
+ originChainId: 1,
+ destinationChainId: 137,
+ originTokenAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
+ destinationTokenAddress:
+ "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174",
+ originToken: testETHToken,
+ destinationToken: testUSDCToken,
+ sender: "0xa3841994009B4fEabb01ebcC62062F9E56F701CD",
+ receiver: "0xa3841994009B4fEabb01ebcC62062F9E56F701CD",
+ transactions: [
+ {
+ chainId: 1,
+ transactionHash: "0xtest123",
+ },
+ ],
+ },
+ ],
+ });
+ });
+
+ [state] = result.current;
+ expect(state.value).toBe("success");
+ expect(state.context.completedStatuses).toBeDefined();
+ expect(state.context.completedStatuses).toHaveLength(1);
+ expect(state.context.completedStatuses?.[0]?.status).toBe("COMPLETED");
+ });
+
+ it("should handle errors and allow retry", () => {
+ const { result } = renderHook(() =>
+ usePaymentMachine(adapters, "fund_wallet"),
+ );
+
+ const testError = new Error("Network error");
+ act(() => {
+ const [, send] = result.current;
+ send({
+ type: "ERROR_OCCURRED",
+ error: testError,
+ });
+ });
+
+ let [state] = result.current;
+ expect(state.value).toBe("error");
+ expect(state.context.currentError).toBe(testError);
+ expect(state.context.retryState).toBe("init");
+
+ // Retry should clear error and return to beginning
+ act(() => {
+ const [, send] = result.current;
+ send({
+ type: "RETRY",
+ });
+ });
+
+ [state] = result.current;
+ expect(state.value).toBe("init");
+ expect(state.context.currentError).toBeUndefined();
+ expect(state.context.retryState).toBeUndefined();
+ });
+
+ it("should preserve context data through transitions", () => {
+ const { result } = renderHook(() =>
+ usePaymentMachine(adapters, "fund_wallet"),
+ );
+
+ const testToken: Token = {
+ chainId: 42,
+ address: "0xtest",
+ name: "Test Token",
+ symbol: "TEST",
+ decimals: 18,
+ priceUsd: 1.0,
+ };
+
+ // Confirm destination
+ act(() => {
+ const [, send] = result.current;
+ send({
+ type: "DESTINATION_CONFIRMED",
+ destinationToken: testToken,
+ destinationAmount: "50",
+ receiverAddress: "0xa3841994009B4fEabb01ebcC62062F9E56F701CD",
+ });
+ });
+
+ // Select payment method
+ const paymentMethod: PaymentMethod = {
+ type: "wallet",
+ payerWallet: TEST_IN_APP_WALLET_A,
+ originToken: testUSDCToken,
+ balance: 1000000000000000000n,
+ };
+
+ act(() => {
+ const [, send] = result.current;
+ send({
+ type: "PAYMENT_METHOD_SELECTED",
+ paymentMethod,
+ });
+ });
+
+ const [state] = result.current;
+ // All context should be preserved
+ expect(state.context.destinationToken).toEqual(testToken);
+ expect(state.context.destinationAmount).toBe("50");
+ expect(state.context.selectedPaymentMethod).toEqual(paymentMethod);
+ expect(state.context.mode).toBe("fund_wallet");
+ expect(state.context.adapters).toBe(adapters);
+ });
+
+ it("should handle state transitions correctly", () => {
+ const { result } = renderHook(() =>
+ usePaymentMachine(adapters, "fund_wallet"),
+ );
+
+ const [initialState] = result.current;
+ expect(initialState.value).toBe("init");
+
+ // Only DESTINATION_CONFIRMED should be valid from initial state
+ act(() => {
+ const [, send] = result.current;
+ send({
+ type: "PAYMENT_METHOD_SELECTED",
+ paymentMethod: {
+ type: "fiat",
+ currency: "USD",
+ payerWallet: TEST_IN_APP_WALLET_A,
+ onramp: "stripe",
+ },
+ });
+ });
+
+ let [state] = result.current;
+ expect(state.value).toBe("init"); // Should stay in same state for invalid transition
+
+ // Valid transition
+ act(() => {
+ const [, send] = result.current;
+ send({
+ type: "DESTINATION_CONFIRMED",
+ destinationToken: testTokenForPayment,
+ destinationAmount: "100",
+ receiverAddress: "0xa3841994009B4fEabb01ebcC62062F9E56F701CD",
+ });
+ });
+
+ [state] = result.current;
+ expect(state.value).toBe("methodSelection");
+ });
+
+ it("should reset to initial state", () => {
+ const { result } = renderHook(() =>
+ usePaymentMachine(adapters, "fund_wallet"),
+ );
+
+ // Go through some states
+ act(() => {
+ const [, send] = result.current;
+ send({
+ type: "DESTINATION_CONFIRMED",
+ destinationToken: testTokenForPayment,
+ destinationAmount: "100",
+ receiverAddress: "0xa3841994009B4fEabb01ebcC62062F9E56F701CD",
+ });
+ });
+
+ act(() => {
+ const [, send] = result.current;
+ send({
+ type: "PAYMENT_METHOD_SELECTED",
+ paymentMethod: {
+ type: "fiat",
+ currency: "USD",
+ payerWallet: TEST_IN_APP_WALLET_A,
+ onramp: "stripe",
+ },
+ });
+ });
+
+ let [state] = result.current;
+ expect(state.value).toBe("quote");
+
+ // Trigger error
+ act(() => {
+ const [, send] = result.current;
+ send({
+ type: "ERROR_OCCURRED",
+ error: new Error("Test error"),
+ });
+ });
+
+ [state] = result.current;
+ expect(state.value).toBe("error");
+
+ // Reset
+ act(() => {
+ const [, send] = result.current;
+ send({
+ type: "RESET",
+ });
+ });
+
+ [state] = result.current;
+ expect(state.value).toBe("init");
+ // Context should still have adapters and mode but other data should be cleared
+ expect(state.context.adapters).toBe(adapters);
+ expect(state.context.mode).toBe("fund_wallet");
+ });
+
+ it("should handle error states from all major states", () => {
+ const { result } = renderHook(() =>
+ usePaymentMachine(adapters, "fund_wallet"),
+ );
+
+ // Test error from init
+ act(() => {
+ const [, send] = result.current;
+ send({
+ type: "ERROR_OCCURRED",
+ error: new Error("Init error"),
+ });
+ });
+
+ let [state] = result.current;
+ expect(state.value).toBe("error");
+ expect(state.context.retryState).toBe("init");
+
+ // Reset and test error from methodSelection
+ act(() => {
+ const [, send] = result.current;
+ send({ type: "RESET" });
+ });
+
+ act(() => {
+ const [, send] = result.current;
+ send({
+ type: "DESTINATION_CONFIRMED",
+ destinationToken: testTokenForPayment,
+ destinationAmount: "100",
+ receiverAddress: "0xa3841994009B4fEabb01ebcC62062F9E56F701CD",
+ });
+ });
+
+ act(() => {
+ const [, send] = result.current;
+ send({
+ type: "ERROR_OCCURRED",
+ error: new Error("Method selection error"),
+ });
+ });
+
+ [state] = result.current;
+ expect(state.value).toBe("error");
+ expect(state.context.retryState).toBe("methodSelection");
+ });
+
+ it("should handle back navigation", () => {
+ const { result } = renderHook(() =>
+ usePaymentMachine(adapters, "fund_wallet"),
+ );
+
+ // Go to methodSelection
+ act(() => {
+ const [, send] = result.current;
+ send({
+ type: "DESTINATION_CONFIRMED",
+ destinationToken: testTokenForPayment,
+ destinationAmount: "100",
+ receiverAddress: "0xa3841994009B4fEabb01ebcC62062F9E56F701CD",
+ });
+ });
+
+ // Go to quote
+ act(() => {
+ const [, send] = result.current;
+ send({
+ type: "PAYMENT_METHOD_SELECTED",
+ paymentMethod: {
+ type: "fiat",
+ currency: "USD",
+ payerWallet: TEST_IN_APP_WALLET_A,
+ onramp: "stripe",
+ },
+ });
+ });
+
+ let [state] = result.current;
+ expect(state.value).toBe("quote");
+
+ // Navigate back to methodSelection
+ act(() => {
+ const [, send] = result.current;
+ send({
+ type: "BACK",
+ });
+ });
+
+ [state] = result.current;
+ expect(state.value).toBe("methodSelection");
+
+ // Navigate back to init
+ act(() => {
+ const [, send] = result.current;
+ send({
+ type: "BACK",
+ });
+ });
+
+ [state] = result.current;
+ expect(state.value).toBe("init");
+ });
+
+ it("should clear prepared quote when payment method changes", () => {
+ const { result } = renderHook(() =>
+ usePaymentMachine(adapters, "fund_wallet"),
+ );
+
+ // Go to methodSelection
+ act(() => {
+ const [, send] = result.current;
+ send({
+ type: "DESTINATION_CONFIRMED",
+ destinationToken: testTokenForPayment,
+ destinationAmount: "100",
+ receiverAddress: "0xa3841994009B4fEabb01ebcC62062F9E56F701CD",
+ });
+ });
+
+ // Select first payment method and get quote
+ act(() => {
+ const [, send] = result.current;
+ send({
+ type: "PAYMENT_METHOD_SELECTED",
+ paymentMethod: {
+ type: "fiat",
+ currency: "USD",
+ payerWallet: TEST_IN_APP_WALLET_A,
+ onramp: "stripe",
+ },
+ });
+ });
+
+ act(() => {
+ const [, send] = result.current;
+ send({
+ type: "QUOTE_RECEIVED",
+ preparedQuote: mockBuyQuote,
+ });
+ });
+
+ let [state] = result.current;
+ expect(state.context.preparedQuote).toBe(mockBuyQuote);
+
+ // Go back and select different payment method
+ act(() => {
+ const [, send] = result.current;
+ send({ type: "BACK" });
+ });
+
+ act(() => {
+ const [, send] = result.current;
+ send({
+ type: "PAYMENT_METHOD_SELECTED",
+ paymentMethod: {
+ type: "wallet",
+ payerWallet: TEST_IN_APP_WALLET_A,
+ originToken: testUSDCToken,
+ balance: 1000000000000000000n,
+ },
+ });
+ });
+
+ [state] = result.current;
+ expect(state.context.preparedQuote).toBeUndefined(); // Should be cleared
+ });
+
+ it("should handle post-buy-transaction state flow", () => {
+ const { result } = renderHook(() =>
+ usePaymentMachine(adapters, "fund_wallet"),
+ );
+
+ // Go through the complete happy path to reach success state
+ act(() => {
+ const [, send] = result.current;
+ send({
+ type: "DESTINATION_CONFIRMED",
+ destinationToken: testTokenForPayment,
+ destinationAmount: "100",
+ receiverAddress: "0xa3841994009B4fEabb01ebcC62062F9E56F701CD",
+ });
+ });
+
+ act(() => {
+ const [, send] = result.current;
+ send({
+ type: "PAYMENT_METHOD_SELECTED",
+ paymentMethod: {
+ type: "wallet",
+ payerWallet: TEST_IN_APP_WALLET_A,
+ originToken: testUSDCToken,
+ balance: 1000000000000000000n,
+ },
+ });
+ });
+
+ act(() => {
+ const [, send] = result.current;
+ send({
+ type: "QUOTE_RECEIVED",
+ preparedQuote: mockBuyQuote,
+ });
+ });
+
+ act(() => {
+ const [, send] = result.current;
+ send({
+ type: "ROUTE_CONFIRMED",
+ });
+ });
+
+ act(() => {
+ const [, send] = result.current;
+ send({
+ type: "EXECUTION_COMPLETE",
+ completedStatuses: [
+ {
+ type: "buy",
+ status: "COMPLETED",
+ paymentId: "test-payment-id",
+ originAmount: 1000000000000000000n,
+ destinationAmount: 100000000n,
+ originChainId: 1,
+ destinationChainId: 137,
+ originTokenAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
+ destinationTokenAddress:
+ "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174",
+ originToken: testETHToken,
+ destinationToken: testUSDCToken,
+ sender: "0xa3841994009B4fEabb01ebcC62062F9E56F701CD",
+ receiver: "0xa3841994009B4fEabb01ebcC62062F9E56F701CD",
+ transactions: [
+ {
+ chainId: 1,
+ transactionHash: "0xtest123",
+ },
+ ],
+ },
+ ],
+ });
+ });
+
+ let [state] = result.current;
+ expect(state.value).toBe("success");
+
+ // Continue to post-buy transaction
+ act(() => {
+ const [, send] = result.current;
+ send({
+ type: "CONTINUE_TO_TRANSACTION",
+ });
+ });
+
+ [state] = result.current;
+ expect(state.value).toBe("post-buy-transaction");
+
+ // Reset from post-buy-transaction should go back to init
+ act(() => {
+ const [, send] = result.current;
+ send({
+ type: "RESET",
+ });
+ });
+
+ [state] = result.current;
+ expect(state.value).toBe("init");
+ // Context should be reset to initial state with only adapters and mode
+ expect(state.context.adapters).toBe(adapters);
+ expect(state.context.mode).toBe("fund_wallet");
+ expect(state.context.destinationToken).toBeUndefined();
+ expect(state.context.selectedPaymentMethod).toBeUndefined();
+ expect(state.context.preparedQuote).toBeUndefined();
+ expect(state.context.completedStatuses).toBeUndefined();
+ });
+});
diff --git a/packages/thirdweb/src/react/core/machines/paymentMachine.ts b/packages/thirdweb/src/react/core/machines/paymentMachine.ts
new file mode 100644
index 00000000000..4f0b8fc35ef
--- /dev/null
+++ b/packages/thirdweb/src/react/core/machines/paymentMachine.ts
@@ -0,0 +1,290 @@
+import { useCallback, useState } from "react";
+import type { Token } from "../../../bridge/types/Token.js";
+import type { Address } from "../../../utils/address.js";
+import type { AsyncStorage } from "../../../utils/storage/AsyncStorage.js";
+import type { Wallet } from "../../../wallets/interfaces/wallet.js";
+import type { WindowAdapter } from "../adapters/WindowAdapter.js";
+import type {
+ BridgePrepareRequest,
+ BridgePrepareResult,
+} from "../hooks/useBridgePrepare.js";
+import type { CompletedStatusResult } from "../hooks/useStepExecutor.js";
+
+/**
+ * Payment modes supported by BridgeEmbed
+ */
+type PaymentMode = "fund_wallet" | "direct_payment" | "transaction";
+
+/**
+ * Payment method types with their required data
+ */
+export type PaymentMethod =
+ | {
+ type: "wallet";
+ payerWallet: Wallet;
+ originToken: Token;
+ balance: bigint;
+ }
+ | {
+ type: "fiat";
+ payerWallet: Wallet;
+ currency: string;
+ onramp: "stripe" | "coinbase" | "transak";
+ };
+
+/**
+ * Payment machine context - holds all flow state data
+ */
+export interface PaymentMachineContext {
+ // Flow configuration
+ mode: PaymentMode;
+
+ // Target requirements (resolved in init state)
+ destinationAmount?: string;
+ destinationToken?: Token;
+ receiverAddress?: Address;
+
+ // User selections (set in methodSelection state)
+ selectedPaymentMethod?: PaymentMethod;
+
+ // Prepared quote data (set in quote state)
+ quote?: BridgePrepareResult;
+ request?: BridgePrepareRequest;
+
+ // Execution results (set in execute state on completion)
+ completedStatuses?: CompletedStatusResult[];
+
+ // Error handling
+ currentError?: Error;
+ retryState?: PaymentMachineState; // State to retry from
+
+ // Dependency injection
+ adapters: {
+ window: WindowAdapter;
+ storage: AsyncStorage;
+ };
+}
+
+/**
+ * Events that can be sent to the payment machine
+ */
+type PaymentMachineEvent =
+ | {
+ type: "DESTINATION_CONFIRMED";
+ destinationToken: Token;
+ destinationAmount: string;
+ receiverAddress: Address;
+ }
+ | { type: "PAYMENT_METHOD_SELECTED"; paymentMethod: PaymentMethod }
+ | {
+ type: "QUOTE_RECEIVED";
+ quote: BridgePrepareResult;
+ request: BridgePrepareRequest;
+ }
+ | { type: "ROUTE_CONFIRMED" }
+ | { type: "EXECUTION_COMPLETE"; completedStatuses: CompletedStatusResult[] }
+ | { type: "ERROR_OCCURRED"; error: Error }
+ | { type: "CONTINUE_TO_TRANSACTION" }
+ | { type: "RETRY" }
+ | { type: "RESET" }
+ | { type: "BACK" };
+
+type PaymentMachineState =
+ | "init"
+ | "methodSelection"
+ | "quote"
+ | "preview"
+ | "execute"
+ | "success"
+ | "post-buy-transaction"
+ | "error";
+
+/**
+ * Hook to create and use the payment machine
+ */
+export function usePaymentMachine(
+ adapters: PaymentMachineContext["adapters"],
+ mode: PaymentMode = "fund_wallet",
+) {
+ const [currentState, setCurrentState] = useState("init");
+ const [context, setContext] = useState({
+ mode,
+ adapters,
+ });
+
+ const send = useCallback(
+ (event: PaymentMachineEvent) => {
+ setCurrentState((state) => {
+ setContext((ctx) => {
+ switch (state) {
+ case "init":
+ if (event.type === "DESTINATION_CONFIRMED") {
+ return {
+ ...ctx,
+ destinationToken: event.destinationToken,
+ destinationAmount: event.destinationAmount,
+ receiverAddress: event.receiverAddress,
+ };
+ } else if (event.type === "ERROR_OCCURRED") {
+ return {
+ ...ctx,
+ currentError: event.error,
+ retryState: "init",
+ };
+ }
+ break;
+
+ case "methodSelection":
+ if (event.type === "PAYMENT_METHOD_SELECTED") {
+ return {
+ ...ctx,
+ quote: undefined, // reset quote when method changes
+ selectedPaymentMethod: event.paymentMethod,
+ };
+ } else if (event.type === "ERROR_OCCURRED") {
+ return {
+ ...ctx,
+ currentError: event.error,
+ retryState: "methodSelection",
+ };
+ }
+ break;
+
+ case "quote":
+ if (event.type === "QUOTE_RECEIVED") {
+ return {
+ ...ctx,
+ quote: event.quote,
+ request: event.request,
+ };
+ } else if (event.type === "ERROR_OCCURRED") {
+ return {
+ ...ctx,
+ currentError: event.error,
+ retryState: "quote",
+ };
+ }
+ break;
+
+ case "preview":
+ if (event.type === "ERROR_OCCURRED") {
+ return {
+ ...ctx,
+ currentError: event.error,
+ retryState: "preview",
+ };
+ }
+ break;
+
+ case "execute":
+ if (event.type === "EXECUTION_COMPLETE") {
+ return {
+ ...ctx,
+ completedStatuses: event.completedStatuses,
+ };
+ } else if (event.type === "ERROR_OCCURRED") {
+ return {
+ ...ctx,
+ currentError: event.error,
+ retryState: "execute",
+ };
+ }
+ break;
+
+ case "error":
+ if (event.type === "RETRY" || event.type === "RESET") {
+ return {
+ ...ctx,
+ currentError: undefined,
+ retryState: undefined,
+ };
+ }
+ break;
+
+ case "success":
+ if (event.type === "RESET") {
+ return {
+ mode: ctx.mode,
+ adapters: ctx.adapters,
+ };
+ }
+ break;
+
+ case "post-buy-transaction":
+ if (event.type === "RESET") {
+ return {
+ mode: ctx.mode,
+ adapters: ctx.adapters,
+ };
+ }
+ break;
+ }
+ return ctx;
+ });
+
+ // State transitions
+ switch (state) {
+ case "init":
+ if (event.type === "DESTINATION_CONFIRMED")
+ return "methodSelection";
+ if (event.type === "ERROR_OCCURRED") return "error";
+ break;
+
+ case "methodSelection":
+ if (event.type === "PAYMENT_METHOD_SELECTED") return "quote";
+ if (event.type === "BACK") return "init";
+ if (event.type === "ERROR_OCCURRED") return "error";
+ break;
+
+ case "quote":
+ if (event.type === "QUOTE_RECEIVED") return "preview";
+ if (event.type === "BACK") return "methodSelection";
+ if (event.type === "ERROR_OCCURRED") return "error";
+ break;
+
+ case "preview":
+ if (event.type === "ROUTE_CONFIRMED") return "execute";
+ if (event.type === "BACK") return "methodSelection";
+ if (event.type === "ERROR_OCCURRED") return "error";
+ break;
+
+ case "execute":
+ if (event.type === "EXECUTION_COMPLETE") return "success";
+ if (event.type === "BACK") return "preview";
+ if (event.type === "ERROR_OCCURRED") return "error";
+ break;
+
+ case "success":
+ if (event.type === "CONTINUE_TO_TRANSACTION")
+ return "post-buy-transaction";
+ if (event.type === "RESET") return "init";
+ break;
+
+ case "post-buy-transaction":
+ if (event.type === "RESET") return "init";
+ break;
+
+ case "error":
+ if (event.type === "RETRY") {
+ return context.retryState ?? "init";
+ }
+ if (event.type === "RESET") {
+ return "init";
+ }
+ break;
+ }
+
+ return state;
+ });
+ },
+ [context.retryState],
+ );
+
+ return [
+ {
+ value: currentState,
+ context,
+ },
+ send,
+ ] as const;
+}
diff --git a/packages/thirdweb/src/react/core/types/.keep b/packages/thirdweb/src/react/core/types/.keep
new file mode 100644
index 00000000000..aacf1fca1d1
--- /dev/null
+++ b/packages/thirdweb/src/react/core/types/.keep
@@ -0,0 +1,2 @@
+# Placeholder file to maintain directory structure
+# This directory will contain shared type definitions and interfaces
\ No newline at end of file
diff --git a/packages/thirdweb/src/react/core/utils/wallet.test.ts b/packages/thirdweb/src/react/core/utils/wallet.test.ts
new file mode 100644
index 00000000000..37d8d5af4b0
--- /dev/null
+++ b/packages/thirdweb/src/react/core/utils/wallet.test.ts
@@ -0,0 +1,77 @@
+import { describe, expect, it } from "vitest";
+import type { Wallet } from "../../../wallets/interfaces/wallet.js";
+import { hasSponsoredTransactionsEnabled } from "../../../wallets/smart/is-smart-wallet.js";
+
+describe("hasSponsoredTransactionsEnabled", () => {
+ it("should return false for undefined wallet", () => {
+ expect(hasSponsoredTransactionsEnabled(undefined)).toBe(false);
+ });
+
+ it("should handle smart wallet with sponsorGas config", () => {
+ const mockSmartWallet = {
+ id: "smart",
+ getConfig: () => ({ sponsorGas: true }),
+ } as Wallet;
+ expect(hasSponsoredTransactionsEnabled(mockSmartWallet)).toBe(true);
+
+ const mockSmartWalletDisabled = {
+ id: "smart",
+ getConfig: () => ({ sponsorGas: false }),
+ } as Wallet;
+ expect(hasSponsoredTransactionsEnabled(mockSmartWalletDisabled)).toBe(
+ false,
+ );
+ });
+
+ it("should handle smart wallet with gasless config", () => {
+ const mockSmartWallet = {
+ id: "smart",
+ getConfig: () => ({ gasless: true }),
+ } as Wallet;
+ expect(hasSponsoredTransactionsEnabled(mockSmartWallet)).toBe(true);
+ });
+
+ it("should handle inApp wallet with smartAccount config", () => {
+ const mockInAppWallet = {
+ id: "inApp",
+ getConfig: () => ({
+ smartAccount: {
+ sponsorGas: true,
+ },
+ }),
+ } as Wallet;
+ expect(hasSponsoredTransactionsEnabled(mockInAppWallet)).toBe(true);
+
+ const mockInAppWalletDisabled = {
+ id: "inApp",
+ getConfig: () => ({
+ smartAccount: {
+ sponsorGas: false,
+ },
+ }),
+ } as Wallet;
+ expect(hasSponsoredTransactionsEnabled(mockInAppWalletDisabled)).toBe(
+ false,
+ );
+ });
+
+ it("should handle inApp wallet with gasless config", () => {
+ const mockInAppWallet = {
+ id: "inApp",
+ getConfig: () => ({
+ smartAccount: {
+ gasless: true,
+ },
+ }),
+ } as Wallet;
+ expect(hasSponsoredTransactionsEnabled(mockInAppWallet)).toBe(true);
+ });
+
+ it("should return false for regular wallet without smart account config", () => {
+ const mockRegularWallet = {
+ id: "inApp",
+ getConfig: () => ({}),
+ } as Wallet;
+ expect(hasSponsoredTransactionsEnabled(mockRegularWallet)).toBe(false);
+ });
+});
diff --git a/packages/thirdweb/src/react/native/flows/.keep b/packages/thirdweb/src/react/native/flows/.keep
new file mode 100644
index 00000000000..9a920ca00f5
--- /dev/null
+++ b/packages/thirdweb/src/react/native/flows/.keep
@@ -0,0 +1,2 @@
+# Placeholder file to maintain directory structure
+# This directory will contain React Native composite and flow components
\ No newline at end of file
diff --git a/packages/thirdweb/src/react/web/adapters/WindowAdapter.ts b/packages/thirdweb/src/react/web/adapters/WindowAdapter.ts
new file mode 100644
index 00000000000..db6347bdc5a
--- /dev/null
+++ b/packages/thirdweb/src/react/web/adapters/WindowAdapter.ts
@@ -0,0 +1,23 @@
+import type { WindowAdapter } from "../../core/adapters/WindowAdapter.js";
+
+/**
+ * Web implementation of WindowAdapter using the browser's window.open API.
+ * Opens URLs in a new tab/window.
+ */
+export class WebWindowAdapter implements WindowAdapter {
+ /**
+ * Opens a URL in a new browser tab/window.
+ *
+ * @param url - The URL to open
+ * @returns Promise that resolves when the operation is initiated
+ */
+ async open(url: string): Promise {
+ // Use window.open to open URL in new tab
+ window.open(url, "_blank", "noopener,noreferrer");
+ }
+}
+
+/**
+ * Default instance of the Web WindowAdapter.
+ */
+export const webWindowAdapter = new WebWindowAdapter();
diff --git a/packages/thirdweb/src/react/web/adapters/adapters.test.ts b/packages/thirdweb/src/react/web/adapters/adapters.test.ts
new file mode 100644
index 00000000000..612fcf34bd1
--- /dev/null
+++ b/packages/thirdweb/src/react/web/adapters/adapters.test.ts
@@ -0,0 +1,38 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { WebWindowAdapter } from "./WindowAdapter.js";
+
+describe("WebWindowAdapter", () => {
+ let windowAdapter: WebWindowAdapter;
+ let mockOpen: ReturnType;
+
+ beforeEach(() => {
+ windowAdapter = new WebWindowAdapter();
+
+ // Mock window.open using vi.stubGlobal
+ mockOpen = vi.fn();
+ vi.stubGlobal("window", {
+ open: mockOpen,
+ });
+ });
+
+ it("should open URL in new tab with correct parameters", async () => {
+ const mockWindow = {} as Partial;
+ mockOpen.mockReturnValue(mockWindow);
+
+ await windowAdapter.open("https://example.com");
+
+ expect(mockOpen).toHaveBeenCalledWith(
+ "https://example.com",
+ "_blank",
+ "noopener,noreferrer",
+ );
+ });
+
+ it("should throw error when popup is blocked", async () => {
+ mockOpen.mockReturnValue(null);
+
+ await expect(windowAdapter.open("https://example.com")).rejects.toThrow(
+ "Failed to open URL - popup may be blocked",
+ );
+ });
+});
diff --git a/packages/thirdweb/src/react/web/flows/.keep b/packages/thirdweb/src/react/web/flows/.keep
new file mode 100644
index 00000000000..c8a98fb3bdc
--- /dev/null
+++ b/packages/thirdweb/src/react/web/flows/.keep
@@ -0,0 +1,2 @@
+# Placeholder file to maintain directory structure
+# This directory will contain web-specific composite and flow components
\ No newline at end of file
diff --git a/packages/thirdweb/src/react/web/hooks/transaction/useSendTransaction.tsx b/packages/thirdweb/src/react/web/hooks/transaction/useSendTransaction.tsx
index 6348602db0c..16117a6cdd5 100644
--- a/packages/thirdweb/src/react/web/hooks/transaction/useSendTransaction.tsx
+++ b/packages/thirdweb/src/react/web/hooks/transaction/useSendTransaction.tsx
@@ -83,6 +83,11 @@ import { TransactionModal } from "../../ui/TransactionButton/TransactionModal.js
* value: toWei("0.1"),
* chain: sepolia,
* client: thirdwebClient,
+ * // Specify a token required for the transaction
+ * erc20Value: {
+ * amountWei: toWei("0.1"),
+ * tokenAddress: "0x...",
+ * },
* });
* sendTx(transaction);
* };
diff --git a/packages/thirdweb/src/react/web/ui/Bridge/BridgeOrchestrator.tsx b/packages/thirdweb/src/react/web/ui/Bridge/BridgeOrchestrator.tsx
new file mode 100644
index 00000000000..670145ea8e0
--- /dev/null
+++ b/packages/thirdweb/src/react/web/ui/Bridge/BridgeOrchestrator.tsx
@@ -0,0 +1,357 @@
+"use client";
+import { useCallback, useMemo } from "react";
+import type { Token } from "../../../../bridge/types/Token.js";
+import type { ThirdwebClient } from "../../../../client/client.js";
+import type { PreparedTransaction } from "../../../../transaction/prepare-transaction.js";
+import type { Address } from "../../../../utils/address.js";
+import { webLocalStorage } from "../../../../utils/storage/webStorage.js";
+import type { Prettify } from "../../../../utils/type-utils.js";
+import type {
+ BridgePrepareRequest,
+ BridgePrepareResult,
+} from "../../../core/hooks/useBridgePrepare.js";
+import type { CompletedStatusResult } from "../../../core/hooks/useStepExecutor.js";
+import {
+ type PaymentMethod,
+ usePaymentMachine,
+} from "../../../core/machines/paymentMachine.js";
+import { webWindowAdapter } from "../../adapters/WindowAdapter.js";
+import en from "../ConnectWallet/locale/en.js";
+import type { ConnectLocale } from "../ConnectWallet/locale/types.js";
+import type { PayEmbedConnectOptions } from "../PayEmbed.js";
+import { ExecutingTxScreen } from "../TransactionButton/ExecutingScreen.js";
+import { Container } from "../components/basic.js";
+import { DirectPayment } from "./DirectPayment.js";
+import { ErrorBanner } from "./ErrorBanner.js";
+import { FundWallet } from "./FundWallet.js";
+import { QuoteLoader } from "./QuoteLoader.js";
+import { StepRunner } from "./StepRunner.js";
+import { TransactionPayment } from "./TransactionPayment.js";
+import { PaymentDetails } from "./payment-details/PaymentDetails.js";
+import { PaymentSelection } from "./payment-selection/PaymentSelection.js";
+import { SuccessScreen } from "./payment-success/SuccessScreen.js";
+
+export type UIOptions = Prettify<
+ {
+ metadata?: {
+ title?: string;
+ description?: string;
+ image?: string;
+ };
+ } & (
+ | {
+ mode: "fund_wallet";
+ destinationToken: Token;
+ initialAmount?: string;
+ presetOptions?: [number, number, number];
+ }
+ | {
+ mode: "direct_payment";
+ paymentInfo: {
+ sellerAddress: Address;
+ token: Token;
+ amount: string;
+ feePayer?: "sender" | "receiver";
+ };
+ }
+ | { mode: "transaction"; transaction: PreparedTransaction }
+ )
+>;
+
+export interface BridgeOrchestratorProps {
+ /**
+ * UI configuration and mode
+ */
+ uiOptions: UIOptions;
+
+ /**
+ * The receiver address, defaults to the connected wallet address
+ */
+ receiverAddress: Address | undefined;
+
+ /**
+ * ThirdwebClient for blockchain interactions
+ */
+ client: ThirdwebClient;
+
+ /**
+ * Called when the flow is completed successfully
+ */
+ onComplete: () => void;
+
+ /**
+ * Called when the flow encounters an error
+ */
+ onError: (error: Error) => void;
+
+ /**
+ * Called when the user cancels the flow
+ */
+ onCancel: () => void;
+
+ /**
+ * Connect options for wallet connection
+ */
+ connectOptions: PayEmbedConnectOptions | undefined;
+
+ /**
+ * Locale for connect UI
+ */
+ connectLocale: ConnectLocale | undefined;
+
+ /**
+ * Optional purchase data for the payment
+ */
+ purchaseData: object | undefined;
+
+ /**
+ * Optional payment link ID for the payment
+ */
+ paymentLinkId: string | undefined;
+
+ /**
+ * Quick buy amounts
+ */
+ presetOptions: [number, number, number] | undefined;
+}
+
+export function BridgeOrchestrator({
+ client,
+ uiOptions,
+ receiverAddress,
+ onComplete,
+ onError,
+ onCancel,
+ connectOptions,
+ connectLocale,
+ purchaseData,
+ paymentLinkId,
+ presetOptions,
+}: BridgeOrchestratorProps) {
+ // Initialize adapters
+ const adapters = useMemo(
+ () => ({
+ window: webWindowAdapter,
+ storage: webLocalStorage,
+ }),
+ [],
+ );
+
+ // Use the payment machine hook
+ const [state, send] = usePaymentMachine(adapters, uiOptions.mode);
+
+ // Handle buy completion
+ const handleBuyComplete = useCallback(() => {
+ if (uiOptions.mode === "transaction") {
+ send({ type: "CONTINUE_TO_TRANSACTION" });
+ } else {
+ onComplete?.();
+ send({ type: "RESET" });
+ }
+ }, [onComplete, send, uiOptions.mode]);
+
+ // Handle post-buy transaction completion
+ const handlePostBuyTransactionComplete = useCallback(() => {
+ onComplete?.();
+ send({ type: "RESET" });
+ }, [onComplete, send]);
+
+ // Handle errors
+ const handleError = useCallback(
+ (error: Error) => {
+ onError?.(error);
+ send({ type: "ERROR_OCCURRED", error });
+ },
+ [onError, send],
+ );
+
+ // Handle payment method selection
+ const handlePaymentMethodSelected = useCallback(
+ (paymentMethod: PaymentMethod) => {
+ send({ type: "PAYMENT_METHOD_SELECTED", paymentMethod });
+ },
+ [send],
+ );
+
+ // Handle quote received
+ const handleQuoteReceived = useCallback(
+ (quote: BridgePrepareResult, request: BridgePrepareRequest) => {
+ send({ type: "QUOTE_RECEIVED", quote, request });
+ },
+ [send],
+ );
+
+ // Handle route confirmation
+ const handleRouteConfirmed = useCallback(() => {
+ send({ type: "ROUTE_CONFIRMED" });
+ }, [send]);
+
+ // Handle execution complete
+ const handleExecutionComplete = useCallback(
+ (completedStatuses: CompletedStatusResult[]) => {
+ send({ type: "EXECUTION_COMPLETE", completedStatuses });
+ },
+ [send],
+ );
+
+ // Handle retry
+ const handleRetry = useCallback(() => {
+ send({ type: "RETRY" });
+ }, [send]);
+
+ // Handle requirements resolved from FundWallet and DirectPayment
+ const handleRequirementsResolved = useCallback(
+ (amount: string, token: Token, receiverAddress: Address) => {
+ send({
+ type: "DESTINATION_CONFIRMED",
+ destinationToken: token,
+ receiverAddress,
+ destinationAmount: amount,
+ });
+ },
+ [send],
+ );
+
+ return (
+
+ {/* Error Banner */}
+ {state.value === "error" && state.context.currentError && (
+
+ )}
+
+ {/* Render current screen based on state */}
+ {state.value === "init" && uiOptions.mode === "fund_wallet" && (
+
+ )}
+
+ {state.value === "init" && uiOptions.mode === "direct_payment" && (
+
+ )}
+
+ {state.value === "init" && uiOptions.mode === "transaction" && (
+
+ )}
+
+ {state.value === "methodSelection" &&
+ state.context.destinationToken &&
+ state.context.destinationAmount &&
+ state.context.receiverAddress && (
+ {
+ send({ type: "BACK" });
+ }}
+ connectOptions={connectOptions}
+ connectLocale={connectLocale || en}
+ includeDestinationToken={uiOptions.mode !== "fund_wallet"}
+ />
+ )}
+
+ {state.value === "quote" &&
+ state.context.selectedPaymentMethod &&
+ state.context.receiverAddress &&
+ state.context.destinationToken &&
+ state.context.destinationAmount && (
+ {
+ send({ type: "BACK" });
+ }}
+ />
+ )}
+
+ {state.value === "preview" &&
+ state.context.selectedPaymentMethod &&
+ state.context.quote && (
+ {
+ send({ type: "BACK" });
+ }}
+ onError={handleError}
+ />
+ )}
+
+ {state.value === "execute" &&
+ state.context.quote &&
+ state.context.request &&
+ state.context.selectedPaymentMethod?.payerWallet && (
+ {
+ send({ type: "BACK" });
+ }}
+ />
+ )}
+
+ {state.value === "success" &&
+ state.context.quote &&
+ state.context.completedStatuses && (
+
+ )}
+
+ {state.value === "post-buy-transaction" &&
+ uiOptions.mode === "transaction" &&
+ uiOptions.transaction && (
+ {
+ // Do nothing
+ }}
+ />
+ )}
+
+ );
+}
diff --git a/packages/thirdweb/src/react/web/ui/Bridge/BuyWidget.tsx b/packages/thirdweb/src/react/web/ui/Bridge/BuyWidget.tsx
new file mode 100644
index 00000000000..0a8841fa07e
--- /dev/null
+++ b/packages/thirdweb/src/react/web/ui/Bridge/BuyWidget.tsx
@@ -0,0 +1,494 @@
+"use client";
+
+import { useQuery } from "@tanstack/react-query";
+import type { Token } from "../../../../bridge/index.js";
+import type { Chain } from "../../../../chains/types.js";
+import type { ThirdwebClient } from "../../../../client/client.js";
+import { NATIVE_TOKEN_ADDRESS } from "../../../../constants/addresses.js";
+import { getToken } from "../../../../pay/convert/get-token.js";
+import {
+ type Address,
+ checksumAddress,
+ isAddress,
+} from "../../../../utils/address.js";
+import { stringify } from "../../../../utils/json.js";
+import { toTokens } from "../../../../utils/units.js";
+import type { Wallet } from "../../../../wallets/interfaces/wallet.js";
+import type { SmartWalletOptions } from "../../../../wallets/smart/types.js";
+import type { AppMetadata } from "../../../../wallets/types.js";
+import type { WalletId } from "../../../../wallets/wallet-types.js";
+import { CustomThemeProvider } from "../../../core/design-system/CustomThemeProvider.js";
+import type { Theme } from "../../../core/design-system/index.js";
+import type { SiweAuthOptions } from "../../../core/hooks/auth/useSiweAuth.js";
+import type { ConnectButton_connectModalOptions } from "../../../core/hooks/connection/ConnectButtonProps.js";
+import type { SupportedTokens } from "../../../core/utils/defaultTokens.js";
+import { EmbedContainer } from "../ConnectWallet/Modal/ConnectEmbed.js";
+import { useConnectLocale } from "../ConnectWallet/locale/getConnectLocale.js";
+import { DynamicHeight } from "../components/DynamicHeight.js";
+import { Spinner } from "../components/Spinner.js";
+import type { LocaleId } from "../types.js";
+import { BridgeOrchestrator, type UIOptions } from "./BridgeOrchestrator.js";
+import { UnsupportedTokenScreen } from "./UnsupportedTokenScreen.js";
+
+export type BuyWidgetProps = {
+ supportedTokens?: SupportedTokens;
+ /**
+ * A client is the entry point to the thirdweb SDK.
+ * It is required for all other actions.
+ * You can create a client using the `createThirdwebClient` function. Refer to the [Creating a Client](https://portal.thirdweb.com/typescript/v5/client) documentation for more information.
+ *
+ * You must provide a `clientId` or `secretKey` in order to initialize a client. Pass `clientId` if you want for client-side usage and `secretKey` for server-side usage.
+ *
+ * ```tsx
+ * import { createThirdwebClient } from "thirdweb";
+ *
+ * const client = createThirdwebClient({
+ * clientId: "",
+ * })
+ * ```
+ */
+ client: ThirdwebClient;
+ /**
+ * By default - ConnectButton UI uses the `en-US` locale for english language users.
+ *
+ * You can customize the language used in the ConnectButton UI by setting the `locale` prop.
+ *
+ * Refer to the [`LocaleId`](https://portal.thirdweb.com/references/typescript/v5/LocaleId) type for supported locales.
+ */
+ locale?: LocaleId;
+ /**
+ * Set the theme for the `BuyWidget` component. By default it is set to `"dark"`
+ *
+ * theme can be set to either `"dark"`, `"light"` or a custom theme object.
+ * You can also import [`lightTheme`](https://portal.thirdweb.com/references/typescript/v5/lightTheme)
+ * or [`darkTheme`](https://portal.thirdweb.com/references/typescript/v5/darkTheme)
+ * functions from `thirdweb/react` to use the default themes as base and overrides parts of it.
+ * @example
+ * ```ts
+ * import { lightTheme } from "thirdweb/react";
+ *
+ * const customTheme = lightTheme({
+ * colors: {
+ * modalBg: 'red'
+ * }
+ * })
+ *
+ * function Example() {
+ * return
+ * }
+ * ```
+ */
+ theme?: "light" | "dark" | Theme;
+
+ /**
+ * Customize the options for "Connect" Button showing in the BuyWidget UI when the user is not connected to a wallet.
+ *
+ * Refer to the [`BuyWidgetConnectOptions`](https://portal.thirdweb.com/references/typescript/v5/BuyWidgetConnectOptions) type for more details.
+ */
+ connectOptions?: BuyWidgetConnectOptions;
+
+ /**
+ * All wallet IDs included in this array will be hidden from wallet selection when connected.
+ */
+ hiddenWallets?: WalletId[];
+
+ /**
+ * The wallet that should be pre-selected in the BuyWidget UI.
+ */
+ activeWallet?: Wallet;
+
+ style?: React.CSSProperties;
+
+ className?: string;
+
+ /**
+ * The chain the accepted token is on.
+ */
+ chain: Chain;
+
+ /**
+ * Address of the token to buy. Leave undefined for the native token, or use 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE.
+ */
+ tokenAddress?: Address;
+
+ /**
+ * The amount to buy **(in wei)**.
+ */
+ amount: bigint;
+
+ /**
+ * The title to display in the widget.
+ */
+ title?: string;
+
+ /**
+ * Preset fiat amounts to display in the UI. Defaults to [5, 10, 20].
+ */
+ presetOptions?: [number, number, number];
+
+ /**
+ * Arbitrary data to be included in the returned status and webhook events.
+ */
+ purchaseData?: Record;
+
+ /**
+ * Callback triggered when the purchase is successful.
+ */
+ onSuccess?: () => void;
+
+ /**
+ * Callback triggered when the purchase encounters an error.
+ */
+ onError?: (error: Error) => void;
+
+ /**
+ * Callback triggered when the user cancels the purchase.
+ */
+ onCancel?: () => void;
+
+ /**
+ * @hidden
+ */
+ paymentLinkId?: string;
+};
+
+// Enhanced UIOptions to handle unsupported token state
+type UIOptionsResult =
+ | { type: "success"; data: UIOptions }
+ | {
+ type: "indexing_token";
+ token: Token;
+ chain: Chain;
+ }
+ | {
+ type: "unsupported_token";
+ tokenAddress: Address;
+ chain: Chain;
+ };
+
+/**
+ * Widget is a prebuilt UI for purchasing a specific token.
+ *
+ * @param props - Props of type [`BuyWidgetProps`](https://portal.thirdweb.com/references/typescript/v5/BuyWidgetProps) to configure the BuyWidget component.
+ *
+ * @example
+ * ### Basic usage
+ *
+ * The `BuyWidget` component requires `client`, `chain`, and `amount` props to function.
+ *
+ * ```tsx
+ * import { ethereum } from "thirdweb/chains";
+ * import { toWei } from "thirdweb";
+ *
+ *
+ * ```
+ *
+ * ### Buy a specific token
+ *
+ * You can specify a token to purchase by passing the `tokenAddress` prop.
+ *
+ * ```tsx
+ *
+ * ```
+ *
+ * ### Customize the UI
+ *
+ * You can customize the UI of the `BuyWidget` component by passing a custom theme object to the `theme` prop.
+ *
+ * ```tsx
+ *
+ * ```
+ *
+ * Refer to the [`Theme`](https://portal.thirdweb.com/references/typescript/v5/Theme) type for more details.
+ *
+ * ### Update the Title
+ *
+ * You can update the title of the widget by passing a `title` prop to the `BuyWidget` component.
+ *
+ * ```tsx
+ *
+ * ```
+ *
+ * ### Configure the wallet connection
+ *
+ * You can customize the wallet connection flow by passing a `connectOptions` object to the `BuyWidget` component.
+ *
+ * ```tsx
+ *
+ * ```
+ *
+ * Refer to the [`BuyWidgetConnectOptions`](https://portal.thirdweb.com/references/typescript/v5/BuyWidgetConnectOptions) type for more details.
+ *
+ * @bridge
+ * @beta
+ * @react
+ */
+export function BuyWidget(props: BuyWidgetProps) {
+ const localeQuery = useConnectLocale(props.locale || "en_US");
+ const theme = props.theme || "dark";
+
+ const bridgeDataQuery = useQuery({
+ queryKey: ["bridgeData", stringify(props)],
+ queryFn: async (): Promise => {
+ if (
+ !props.tokenAddress ||
+ (isAddress(props.tokenAddress) &&
+ checksumAddress(props.tokenAddress) ===
+ checksumAddress(NATIVE_TOKEN_ADDRESS))
+ ) {
+ const ETH = await getToken(
+ props.client,
+ NATIVE_TOKEN_ADDRESS,
+ props.chain.id,
+ );
+ return {
+ type: "success",
+ data: {
+ mode: "fund_wallet",
+ destinationToken: ETH,
+ initialAmount: toTokens(props.amount, ETH.decimals),
+ },
+ };
+ }
+
+ const token = await getToken(
+ props.client,
+ props.tokenAddress,
+ props.chain.id,
+ ).catch((err) => {
+ err.message.includes("not supported") ? undefined : Promise.reject(err);
+ });
+ if (!token) {
+ return {
+ type: "unsupported_token",
+ tokenAddress: props.tokenAddress,
+ chain: props.chain,
+ };
+ }
+ return {
+ type: "success",
+ data: {
+ mode: "fund_wallet",
+ destinationToken: token,
+ initialAmount: toTokens(props.amount, token.decimals),
+ metadata: {
+ title: props.title,
+ },
+ },
+ };
+ },
+ });
+
+ let content = null;
+ if (!localeQuery.data || bridgeDataQuery.isLoading) {
+ content = (
+
+
+
+ );
+ } else if (bridgeDataQuery.data?.type === "unsupported_token") {
+ // Show unsupported token screen
+ content = ;
+ } else if (bridgeDataQuery.data?.type === "success") {
+ // Show normal bridge orchestrator
+ content = (
+ {
+ props.onSuccess?.();
+ }}
+ onError={(err: Error) => {
+ props.onError?.(err);
+ }}
+ onCancel={() => {
+ props.onCancel?.();
+ }}
+ presetOptions={props.presetOptions}
+ receiverAddress={undefined}
+ />
+ );
+ }
+
+ return (
+
+
+ {content}
+
+
+ );
+}
+
+/**
+ * Connection options for the `BuyWidget` component
+ *
+ * @example
+ * ```tsx
+ *
+ * ```
+ */
+type BuyWidgetConnectOptions = {
+ /**
+ * Configurations for the `ConnectButton`'s Modal that is shown for connecting a wallet
+ * Refer to the [`ConnectButton_connectModalOptions`](https://portal.thirdweb.com/references/typescript/v5/ConnectButton_connectModalOptions) type for more details
+ */
+ connectModal?: ConnectButton_connectModalOptions;
+
+ /**
+ * Configure options for WalletConnect
+ *
+ * By default WalletConnect uses the thirdweb's default project id.
+ * Setting your own project id is recommended.
+ *
+ * You can create a project id by signing up on [walletconnect.com](https://walletconnect.com/)
+ */
+ walletConnect?: {
+ projectId?: string;
+ };
+
+ /**
+ * Enable Account abstraction for all wallets. This will connect to the users's smart account based on the connected personal wallet and the given options.
+ *
+ * This allows to sponsor gas fees for your user's transaction using the thirdweb account abstraction infrastructure.
+ *
+ */
+ accountAbstraction?: SmartWalletOptions;
+
+ /**
+ * Array of wallets to show in Connect Modal. If not provided, default wallets will be used.
+ */
+ wallets?: Wallet[];
+ /**
+ * When the user has connected their wallet to your site, this configuration determines whether or not you want to automatically connect to the last connected wallet when user visits your site again in the future.
+ *
+ * By default it is set to `{ timeout: 15000 }` meaning that autoConnect is enabled and if the autoConnection does not succeed within 15 seconds, it will be cancelled.
+ *
+ * If you want to disable autoConnect, set this prop to `false`.
+ *
+ * If you want to customize the timeout, you can assign an object with a `timeout` key to this prop.
+ * ```
+ */
+ autoConnect?:
+ | {
+ timeout: number;
+ }
+ | boolean;
+
+ /**
+ * Metadata of the app that will be passed to connected wallet. Setting this is highly recommended.
+ */
+ appMetadata?: AppMetadata;
+
+ /**
+ * The [`Chain`](https://portal.thirdweb.com/references/typescript/v5/Chain) object of the blockchain you want the wallet to connect to
+ *
+ * If a `chain` is not specified, Wallet will be connected to whatever is the default set in the wallet.
+ *
+ * If a `chain` is specified, Wallet will be prompted to switch to given chain after connection if it is not already connected to it.
+ * This ensures that the wallet is connected to the correct blockchain before interacting with your app.
+ *
+ * The `ConnectButton` also shows a "Switch Network" button until the wallet is connected to the specified chain. Clicking on the "Switch Network" button triggers the wallet to switch to the specified chain.
+ *
+ * You can create a `Chain` object using the [`defineChain`](https://portal.thirdweb.com/references/typescript/v5/defineChain) function.
+ * At minimum, you need to pass the `id` of the blockchain to `defineChain` function to create a `Chain` object.
+ * ```
+ */
+ chain?: Chain;
+
+ /**
+ * Array of chains that your app supports.
+ *
+ * This is only relevant if your app is a multi-chain app and works across multiple blockchains.
+ * If your app only works on a single blockchain, you should only specify the `chain` prop.
+ *
+ * Given list of chains will used in various ways:
+ * - They will be displayed in the network selector in the `ConnectButton`'s details modal post connection
+ * - They will be sent to wallet at the time of connection if the wallet supports requesting multiple chains ( example: WalletConnect ) so that users can switch between the chains post connection easily
+ *
+ * You can create a `Chain` object using the [`defineChain`](https://portal.thirdweb.com/references/typescript/v5/defineChain) function.
+ * At minimum, you need to pass the `id` of the blockchain to `defineChain` function to create a `Chain` object.
+ *
+ * ```tsx
+ * import { defineChain } from "thirdweb/react";
+ *
+ * const polygon = defineChain({
+ * id: 137,
+ * });
+ * ```
+ */
+ chains?: Chain[];
+
+ /**
+ * Wallets to show as recommended in the `ConnectButton`'s Modal
+ */
+ recommendedWallets?: Wallet[];
+
+ /**
+ * By default, ConnectButton modal shows a "All Wallets" button that shows a list of 500+ wallets.
+ *
+ * You can disable this button by setting `showAllWallets` prop to `false`
+ */
+ showAllWallets?: boolean;
+
+ /**
+ * Enable SIWE (Sign in with Ethererum) by passing an object of type `SiweAuthOptions` to
+ * enforce the users to sign a message after connecting their wallet to authenticate themselves.
+ *
+ * Refer to the [`SiweAuthOptions`](https://portal.thirdweb.com/references/typescript/v5/SiweAuthOptions) for more details
+ */
+ auth?: SiweAuthOptions;
+};
diff --git a/packages/thirdweb/src/react/web/ui/Bridge/CheckoutWidget.tsx b/packages/thirdweb/src/react/web/ui/Bridge/CheckoutWidget.tsx
new file mode 100644
index 00000000000..163b09ccc4f
--- /dev/null
+++ b/packages/thirdweb/src/react/web/ui/Bridge/CheckoutWidget.tsx
@@ -0,0 +1,484 @@
+"use client";
+
+import { useQuery } from "@tanstack/react-query";
+import type { Token } from "../../../../bridge/index.js";
+import type { Chain } from "../../../../chains/types.js";
+import type { ThirdwebClient } from "../../../../client/client.js";
+import { NATIVE_TOKEN_ADDRESS } from "../../../../constants/addresses.js";
+import { getToken } from "../../../../pay/convert/get-token.js";
+import { type Address, checksumAddress } from "../../../../utils/address.js";
+import { stringify } from "../../../../utils/json.js";
+import { toTokens } from "../../../../utils/units.js";
+import type { Wallet } from "../../../../wallets/interfaces/wallet.js";
+import type { SmartWalletOptions } from "../../../../wallets/smart/types.js";
+import type { AppMetadata } from "../../../../wallets/types.js";
+import type { WalletId } from "../../../../wallets/wallet-types.js";
+import { CustomThemeProvider } from "../../../core/design-system/CustomThemeProvider.js";
+import type { Theme } from "../../../core/design-system/index.js";
+import type { SiweAuthOptions } from "../../../core/hooks/auth/useSiweAuth.js";
+import type { ConnectButton_connectModalOptions } from "../../../core/hooks/connection/ConnectButtonProps.js";
+import type { SupportedTokens } from "../../../core/utils/defaultTokens.js";
+import { EmbedContainer } from "../ConnectWallet/Modal/ConnectEmbed.js";
+import { useConnectLocale } from "../ConnectWallet/locale/getConnectLocale.js";
+import { DynamicHeight } from "../components/DynamicHeight.js";
+import { Spinner } from "../components/Spinner.js";
+import type { LocaleId } from "../types.js";
+import { BridgeOrchestrator, type UIOptions } from "./BridgeOrchestrator.js";
+import { UnsupportedTokenScreen } from "./UnsupportedTokenScreen.js";
+
+export type CheckoutWidgetProps = {
+ supportedTokens?: SupportedTokens;
+ /**
+ * A client is the entry point to the thirdweb SDK.
+ * It is required for all other actions.
+ * You can create a client using the `createThirdwebClient` function. Refer to the [Creating a Client](https://portal.thirdweb.com/typescript/v5/client) documentation for more information.
+ *
+ * You must provide a `clientId` or `secretKey` in order to initialize a client. Pass `clientId` if you want for client-side usage and `secretKey` for server-side usage.
+ *
+ * ```tsx
+ * import { createThirdwebClient } from "thirdweb";
+ *
+ * const client = createThirdwebClient({
+ * clientId: "",
+ * })
+ * ```
+ */
+ client: ThirdwebClient;
+ /**
+ * By default - ConnectButton UI uses the `en-US` locale for english language users.
+ *
+ * You can customize the language used in the ConnectButton UI by setting the `locale` prop.
+ *
+ * Refer to the [`LocaleId`](https://portal.thirdweb.com/references/typescript/v5/LocaleId) type for supported locales.
+ */
+ locale?: LocaleId;
+ /**
+ * Set the theme for the `CheckoutWidget` component. By default it is set to `"dark"`
+ *
+ * theme can be set to either `"dark"`, `"light"` or a custom theme object.
+ * You can also import [`lightTheme`](https://portal.thirdweb.com/references/typescript/v5/lightTheme)
+ * or [`darkTheme`](https://portal.thirdweb.com/references/typescript/v5/darkTheme)
+ * functions from `thirdweb/react` to use the default themes as base and overrides parts of it.
+ * @example
+ * ```ts
+ * import { lightTheme } from "thirdweb/react";
+ *
+ * const customTheme = lightTheme({
+ * colors: {
+ * modalBg: 'red'
+ * }
+ * })
+ *
+ * function Example() {
+ * return
+ * }
+ * ```
+ */
+ theme?: "light" | "dark" | Theme;
+
+ /**
+ * Customize the options for "Connect" Button showing in the CheckoutWidget UI when the user is not connected to a wallet.
+ *
+ * Refer to the [`CheckoutWidgetConnectOptions`](https://portal.thirdweb.com/references/typescript/v5/CheckoutWidgetConnectOptions) type for more details.
+ */
+ connectOptions?: CheckoutWidgetConnectOptions;
+
+ /**
+ * All wallet IDs included in this array will be hidden from wallet selection when connected.
+ */
+ hiddenWallets?: WalletId[];
+
+ /**
+ * The wallet that should be pre-selected in the CheckoutWidget UI.
+ */
+ activeWallet?: Wallet;
+
+ style?: React.CSSProperties;
+
+ className?: string;
+
+ /**
+ * The chain the accepted token is on.
+ */
+ chain: Chain;
+
+ /**
+ * Address of the token to accept as payment. Leave undefined for the native token, or use 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE.
+ */
+ tokenAddress?: Address;
+
+ /**
+ * The price of the item **(in wei)**.
+ */
+ amount: bigint;
+
+ /**
+ * The wallet address or ENS funds will be paid to.
+ */
+ seller: Address;
+
+ /**
+ * The product name.
+ */
+ name?: string;
+
+ /**
+ * The product description.
+ */
+ description?: string;
+
+ /**
+ * The product image URL.
+ */
+ image?: string;
+
+ /**
+ * Whether the user or the seller pays the protocol fees. Defaults to the user.
+ */
+ feePayer?: "user" | "seller";
+
+ /**
+ * Preset fiat amounts to display in the UI. Defaults to [5, 10, 20].
+ */
+ presetOptions?: [number, number, number];
+
+ /**
+ * Arbitrary data to be included in the returned status and webhook events.
+ */
+ purchaseData?: Record;
+
+ /**
+ * Callback triggered when the purchase is successful.
+ */
+ onSuccess?: () => void;
+
+ /**
+ * Callback triggered when the purchase encounters an error.
+ */
+ onError?: (error: Error) => void;
+
+ /**
+ * Callback triggered when the user cancels the purchase.
+ */
+ onCancel?: () => void;
+
+ /**
+ * @hidden
+ */
+ paymentLinkId?: string;
+};
+
+// Enhanced UIOptions to handle unsupported token state
+type UIOptionsResult =
+ | { type: "success"; data: UIOptions }
+ | {
+ type: "indexing_token";
+ token: Token;
+ chain: Chain;
+ }
+ | {
+ type: "unsupported_token";
+ tokenAddress: Address;
+ chain: Chain;
+ };
+
+/**
+ * Widget a prebuilt UI for purchasing a specific token.
+ *
+ * @param props - Props of type [`CheckoutWidgetProps`](https://portal.thirdweb.com/references/typescript/v5/CheckoutWidgetProps) to configure the CheckoutWidget component.
+ *
+ * @example
+ * ### Default configuration
+ *
+ * By default, the `CheckoutWidget` component will allows users to fund their wallets with crypto or fiat on any of the supported chains..
+ *
+ * ```tsx
+ *
+ * ```
+ *
+ * ### Enable/Disable payment methods
+ *
+ * You can use `disableOnramps` to prevent the use of onramps in the widget.
+ *
+ * ```tsx
+ *
+ * ```
+ *
+ * ### Customize the UI
+ *
+ * You can customize the UI of the `CheckoutWidget` component by passing a custom theme object to the `theme` prop.
+ *
+ * ```tsx
+ *
+ * ```
+ *
+ * Refer to the [`Theme`](https://portal.thirdweb.com/references/typescript/v5/Theme) type for more details.
+ *
+ * ### Update the Title
+ *
+ * You can update the title of the widget by passing a `title` prop to the `CheckoutWidget` component.
+ *
+ * ```tsx
+ *
+ * ```
+ *
+ * ### Configure the wallet connection
+ *
+ * You can customize the wallet connection flow by passing a `connectOptions` object to the `CheckoutWidget` component.
+ *
+ * ```tsx
+ *
+ * ```
+ *
+ * Refer to the [`CheckoutWidgetConnectOptions`](https://portal.thirdweb.com/references/typescript/v5/CheckoutWidgetConnectOptions) type for more details.
+ *
+ * @bridge
+ * @beta
+ * @react
+ */
+export function CheckoutWidget(props: CheckoutWidgetProps) {
+ const localeQuery = useConnectLocale(props.locale || "en_US");
+ const theme = props.theme || "dark";
+
+ const bridgeDataQuery = useQuery({
+ queryKey: ["bridgeData", stringify(props)],
+ queryFn: async (): Promise => {
+ const token = await getToken(
+ props.client,
+ checksumAddress(props.tokenAddress || NATIVE_TOKEN_ADDRESS),
+ props.chain.id,
+ ).catch((err) =>
+ err.message.includes("not supported") ? undefined : Promise.reject(err),
+ );
+ if (!token) {
+ return {
+ type: "unsupported_token",
+ tokenAddress: checksumAddress(
+ props.tokenAddress || NATIVE_TOKEN_ADDRESS,
+ ),
+ chain: props.chain,
+ };
+ }
+ return {
+ type: "success",
+ data: {
+ mode: "direct_payment",
+ metadata: {
+ title: props.name,
+ image: props.image,
+ description: props.description,
+ },
+ paymentInfo: {
+ token,
+ amount: toTokens(props.amount, token.decimals),
+ sellerAddress: props.seller,
+ feePayer: props.feePayer === "seller" ? "receiver" : "sender", // User is sender, seller is receiver
+ },
+ },
+ };
+ },
+ });
+
+ let content = null;
+ if (!localeQuery.data || bridgeDataQuery.isLoading) {
+ content = (
+
+
+
+ );
+ } else if (bridgeDataQuery.data?.type === "unsupported_token") {
+ // Show unsupported token screen
+ content = ;
+ } else if (bridgeDataQuery.data?.type === "success") {
+ // Show normal bridge orchestrator
+ content = (
+ {
+ props.onSuccess?.();
+ }}
+ onError={(err: Error) => {
+ props.onError?.(err);
+ }}
+ onCancel={() => {
+ props.onCancel?.();
+ }}
+ presetOptions={props.presetOptions}
+ receiverAddress={props.seller}
+ />
+ );
+ }
+
+ return (
+
+
+ {content}
+
+
+ );
+}
+
+/**
+ * Connection options for the `CheckoutWidget` component
+ *
+ * @example
+ * ```tsx
+ *
+ * ```
+ */
+type CheckoutWidgetConnectOptions = {
+ /**
+ * Configurations for the `ConnectButton`'s Modal that is shown for connecting a wallet
+ * Refer to the [`ConnectButton_connectModalOptions`](https://portal.thirdweb.com/references/typescript/v5/ConnectButton_connectModalOptions) type for more details
+ */
+ connectModal?: ConnectButton_connectModalOptions;
+
+ /**
+ * Configure options for WalletConnect
+ *
+ * By default WalletConnect uses the thirdweb's default project id.
+ * Setting your own project id is recommended.
+ *
+ * You can create a project id by signing up on [walletconnect.com](https://walletconnect.com/)
+ */
+ walletConnect?: {
+ projectId?: string;
+ };
+
+ /**
+ * Enable Account abstraction for all wallets. This will connect to the users's smart account based on the connected personal wallet and the given options.
+ *
+ * This allows to sponsor gas fees for your user's transaction using the thirdweb account abstraction infrastructure.
+ *
+ */
+ accountAbstraction?: SmartWalletOptions;
+
+ /**
+ * Array of wallets to show in Connect Modal. If not provided, default wallets will be used.
+ */
+ wallets?: Wallet[];
+ /**
+ * When the user has connected their wallet to your site, this configuration determines whether or not you want to automatically connect to the last connected wallet when user visits your site again in the future.
+ *
+ * By default it is set to `{ timeout: 15000 }` meaning that autoConnect is enabled and if the autoConnection does not succeed within 15 seconds, it will be cancelled.
+ *
+ * If you want to disable autoConnect, set this prop to `false`.
+ *
+ * If you want to customize the timeout, you can assign an object with a `timeout` key to this prop.
+ * ```
+ */
+ autoConnect?:
+ | {
+ timeout: number;
+ }
+ | boolean;
+
+ /**
+ * Metadata of the app that will be passed to connected wallet. Setting this is highly recommended.
+ */
+ appMetadata?: AppMetadata;
+
+ /**
+ * The [`Chain`](https://portal.thirdweb.com/references/typescript/v5/Chain) object of the blockchain you want the wallet to connect to
+ *
+ * If a `chain` is not specified, Wallet will be connected to whatever is the default set in the wallet.
+ *
+ * If a `chain` is specified, Wallet will be prompted to switch to given chain after connection if it is not already connected to it.
+ * This ensures that the wallet is connected to the correct blockchain before interacting with your app.
+ *
+ * The `ConnectButton` also shows a "Switch Network" button until the wallet is connected to the specified chain. Clicking on the "Switch Network" button triggers the wallet to switch to the specified chain.
+ *
+ * You can create a `Chain` object using the [`defineChain`](https://portal.thirdweb.com/references/typescript/v5/defineChain) function.
+ * At minimum, you need to pass the `id` of the blockchain to `defineChain` function to create a `Chain` object.
+ * ```
+ */
+ chain?: Chain;
+
+ /**
+ * Array of chains that your app supports.
+ *
+ * This is only relevant if your app is a multi-chain app and works across multiple blockchains.
+ * If your app only works on a single blockchain, you should only specify the `chain` prop.
+ *
+ * Given list of chains will used in various ways:
+ * - They will be displayed in the network selector in the `ConnectButton`'s details modal post connection
+ * - They will be sent to wallet at the time of connection if the wallet supports requesting multiple chains ( example: WalletConnect ) so that users can switch between the chains post connection easily
+ *
+ * You can create a `Chain` object using the [`defineChain`](https://portal.thirdweb.com/references/typescript/v5/defineChain) function.
+ * At minimum, you need to pass the `id` of the blockchain to `defineChain` function to create a `Chain` object.
+ *
+ * ```tsx
+ * import { defineChain } from "thirdweb/react";
+ *
+ * const polygon = defineChain({
+ * id: 137,
+ * });
+ * ```
+ */
+ chains?: Chain[];
+
+ /**
+ * Wallets to show as recommended in the `ConnectButton`'s Modal
+ */
+ recommendedWallets?: Wallet[];
+
+ /**
+ * By default, ConnectButton modal shows a "All Wallets" button that shows a list of 500+ wallets.
+ *
+ * You can disable this button by setting `showAllWallets` prop to `false`
+ */
+ showAllWallets?: boolean;
+
+ /**
+ * Enable SIWE (Sign in with Ethererum) by passing an object of type `SiweAuthOptions` to
+ * enforce the users to sign a message after connecting their wallet to authenticate themselves.
+ *
+ * Refer to the [`SiweAuthOptions`](https://portal.thirdweb.com/references/typescript/v5/SiweAuthOptions) for more details
+ */
+ auth?: SiweAuthOptions;
+};
diff --git a/packages/thirdweb/src/react/web/ui/Bridge/DirectPayment.tsx b/packages/thirdweb/src/react/web/ui/Bridge/DirectPayment.tsx
new file mode 100644
index 00000000000..427c0573463
--- /dev/null
+++ b/packages/thirdweb/src/react/web/ui/Bridge/DirectPayment.tsx
@@ -0,0 +1,234 @@
+"use client";
+import type { Token } from "../../../../bridge/types/Token.js";
+import { defineChain } from "../../../../chains/utils.js";
+import type { ThirdwebClient } from "../../../../client/client.js";
+import { type Address, shortenAddress } from "../../../../utils/address.js";
+import { useCustomTheme } from "../../../core/design-system/CustomThemeProvider.js";
+import { useActiveAccount } from "../../../core/hooks/wallets/useActiveAccount.js";
+import { useEnsName } from "../../../core/utils/wallet.js";
+import { ConnectButton } from "../ConnectWallet/ConnectButton.js";
+import { PoweredByThirdweb } from "../ConnectWallet/PoweredByTW.js";
+import { FiatValue } from "../ConnectWallet/screens/Buy/swap/FiatValue.js";
+import type { PayEmbedConnectOptions } from "../PayEmbed.js";
+import { ChainName } from "../components/ChainName.js";
+import { Spacer } from "../components/Spacer.js";
+import { Container, Line } from "../components/basic.js";
+import { Button } from "../components/buttons.js";
+import { Text } from "../components/text.js";
+import type { UIOptions } from "./BridgeOrchestrator.js";
+import { ChainIcon } from "./common/TokenAndChain.js";
+import { WithHeader } from "./common/WithHeader.js";
+
+export interface DirectPaymentProps {
+ /**
+ * Payment information for the direct payment
+ */
+ uiOptions: Extract;
+
+ /**
+ * ThirdwebClient for blockchain interactions
+ */
+ client: ThirdwebClient;
+
+ /**
+ * Called when user continues with the payment
+ */
+ onContinue: (amount: string, token: Token, receiverAddress: Address) => void;
+
+ /**
+ * Connect options for wallet connection
+ */
+ connectOptions?: PayEmbedConnectOptions;
+}
+
+export function DirectPayment({
+ uiOptions,
+ client,
+ onContinue,
+ connectOptions,
+}: DirectPaymentProps) {
+ const activeAccount = useActiveAccount();
+ const chain = defineChain(uiOptions.paymentInfo.token.chainId);
+ const theme = useCustomTheme();
+ const handleContinue = () => {
+ onContinue(
+ uiOptions.paymentInfo.amount,
+ uiOptions.paymentInfo.token,
+ uiOptions.paymentInfo.sellerAddress,
+ );
+ };
+ const ensName = useEnsName({
+ address: uiOptions.paymentInfo.sellerAddress,
+ client,
+ });
+ const sellerAddress =
+ ensName.data || shortenAddress(uiOptions.paymentInfo.sellerAddress);
+
+ const buyNow = (
+
+
+ Buy Now ·
+
+
+
+ );
+
+ return (
+
+ {/* Price section */}
+
+
+
+
+ One-time payment
+
+
+
+
+
+
+
+
+
+
+ {/* Seller section */}
+
+
+ Sold by
+
+
+ {sellerAddress}
+
+
+
+
+
+
+
+ Price
+
+
+ {`${uiOptions.paymentInfo.amount} ${uiOptions.paymentInfo.token.symbol}`}
+
+
+
+
+
+ {/* Network section */}
+
+
+ Network
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Action button */}
+
+ {activeAccount ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+ );
+}
diff --git a/packages/thirdweb/src/react/web/ui/Bridge/ErrorBanner.tsx b/packages/thirdweb/src/react/web/ui/Bridge/ErrorBanner.tsx
new file mode 100644
index 00000000000..25ee7648dee
--- /dev/null
+++ b/packages/thirdweb/src/react/web/ui/Bridge/ErrorBanner.tsx
@@ -0,0 +1,86 @@
+"use client";
+import { CrossCircledIcon } from "@radix-ui/react-icons";
+import { useCustomTheme } from "../../../core/design-system/CustomThemeProvider.js";
+import { iconSize } from "../../../core/design-system/index.js";
+import { useBridgeError } from "../../../core/hooks/useBridgeError.js";
+import { Container } from "../components/basic.js";
+import { Button } from "../components/buttons.js";
+import { Text } from "../components/text.js";
+
+interface ErrorBannerProps {
+ /**
+ * The error to display
+ */
+ error: Error;
+
+ /**
+ * Called when user wants to retry
+ */
+ onRetry: () => void;
+
+ /**
+ * Called when user wants to cancel
+ */
+ onCancel?: () => void;
+}
+
+export function ErrorBanner({ error, onRetry, onCancel }: ErrorBannerProps) {
+ const theme = useCustomTheme();
+
+ const { userMessage } = useBridgeError({ error });
+
+ return (
+
+ {/* Error Icon and Message */}
+
+
+
+
+
+
+
+ Error
+
+
+
+
+ {userMessage}
+
+
+
+
+ {/* Action Buttons */}
+
+
+ {onCancel && (
+
+ )}
+
+
+
+
+ );
+}
diff --git a/packages/thirdweb/src/react/web/ui/Bridge/FundWallet.tsx b/packages/thirdweb/src/react/web/ui/Bridge/FundWallet.tsx
new file mode 100644
index 00000000000..5591e316f17
--- /dev/null
+++ b/packages/thirdweb/src/react/web/ui/Bridge/FundWallet.tsx
@@ -0,0 +1,341 @@
+"use client";
+import { useState } from "react";
+import type { Token } from "../../../../bridge/types/Token.js";
+import type { ThirdwebClient } from "../../../../client/client.js";
+import { type Address, getAddress } from "../../../../utils/address.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 { ConnectButton } from "../ConnectWallet/ConnectButton.js";
+import { PoweredByThirdweb } from "../ConnectWallet/PoweredByTW.js";
+import { OutlineWalletIcon } from "../ConnectWallet/icons/OutlineWalletIcon.js";
+import { WalletRow } from "../ConnectWallet/screens/Buy/swap/WalletRow.js";
+import type { PayEmbedConnectOptions } from "../PayEmbed.js";
+import { Spacer } from "../components/Spacer.js";
+import { Container } from "../components/basic.js";
+import { Button } from "../components/buttons.js";
+import { Input } from "../components/formElements.js";
+import { Text } from "../components/text.js";
+import type { UIOptions } from "./BridgeOrchestrator.js";
+import { TokenAndChain } from "./common/TokenAndChain.js";
+import { WithHeader } from "./common/WithHeader.js";
+
+export interface FundWalletProps {
+ /**
+ * UI configuration and mode
+ */
+ uiOptions: Extract;
+
+ /**
+ * The receiver address, defaults to the connected wallet address
+ */
+ receiverAddress?: Address;
+ /**
+ * ThirdwebClient for price fetching
+ */
+ client: ThirdwebClient;
+
+ /**
+ * Called when continue is clicked with the resolved requirements
+ */
+ onContinue: (amount: string, token: Token, receiverAddress: Address) => void;
+
+ /**
+ * Quick buy amounts
+ */
+ presetOptions?: [number, number, number];
+
+ /**
+ * Connect options for wallet connection
+ */
+ connectOptions?: PayEmbedConnectOptions;
+}
+
+export function FundWallet({
+ client,
+ receiverAddress,
+ uiOptions,
+ onContinue,
+ presetOptions = [5, 10, 20],
+ connectOptions,
+}: FundWalletProps) {
+ const [amount, setAmount] = useState(uiOptions.initialAmount ?? "");
+ const theme = useCustomTheme();
+ const account = useActiveAccount();
+ const receiver = receiverAddress ?? account?.address;
+ const handleAmountChange = (inputValue: string) => {
+ let processedValue = inputValue;
+
+ // Replace comma with period if it exists
+ processedValue = processedValue.replace(",", ".");
+
+ if (processedValue.startsWith(".")) {
+ processedValue = `0${processedValue}`;
+ }
+
+ const numValue = Number(processedValue);
+ if (Number.isNaN(numValue)) {
+ return;
+ }
+
+ if (processedValue.startsWith("0") && !processedValue.startsWith("0.")) {
+ setAmount(processedValue.slice(1));
+ } else {
+ setAmount(processedValue);
+ }
+ };
+
+ const getAmountFontSize = () => {
+ const length = amount.length;
+ if (length > 12) return fontSize.md;
+ if (length > 8) return fontSize.lg;
+ return fontSize.xl;
+ };
+
+ const isValidAmount = amount && Number(amount) > 0;
+
+ const focusInput = () => {
+ const input = document.querySelector("#amount-input") as HTMLInputElement;
+ input?.focus();
+ };
+
+ const handleQuickAmount = (usdAmount: number) => {
+ if (uiOptions.destinationToken.priceUsd === 0) {
+ return;
+ }
+ // Convert USD amount to token amount using token price
+ const tokenAmount = usdAmount / uiOptions.destinationToken.priceUsd;
+ // Format to reasonable decimal places (up to 6 decimals, remove trailing zeros)
+ const formattedAmount = Number.parseFloat(
+ tokenAmount.toFixed(6),
+ ).toString();
+ setAmount(formattedAmount);
+ };
+
+ return (
+
+
+ {/* Token Info */}
+
+
+ {/* Amount Input */}
+
+