diff --git a/.changeset/wet-maps-play.md b/.changeset/wet-maps-play.md new file mode 100644 index 00000000000..faae07ea7d6 --- /dev/null +++ b/.changeset/wet-maps-play.md @@ -0,0 +1,111 @@ +--- +"thirdweb": minor +--- + +# New `useFetchWithPayment()` React Hook + +Added a new React hook that wraps the native fetch API to automatically handle 402 Payment Required responses using the x402 payment protocol. + +## Features + +- **Automatic Payment Handling**: Automatically detects 402 responses, creates payment headers, and retries requests +- **Built-in UI**: Shows an error modal with retry and fund wallet options when payment fails +- **Sign In Flow**: Prompts users to connect their wallet if not connected, then automatically retries the payment +- **Insufficient Funds Flow**: Integrates BuyWidget to help users top up their wallet directly in the modal +- **Customizable**: Supports theming, custom payment selectors, BuyWidget customization, and ConnectModal customization +- **Opt-out Modal**: Can disable the modal to handle errors manually + +## Basic Usage + +The hook automatically parses JSON responses by default. + +```tsx +import { useFetchWithPayment } from "thirdweb/react"; +import { createThirdwebClient } from "thirdweb"; + +const client = createThirdwebClient({ clientId: "your-client-id" }); + +function MyComponent() { + const { fetchWithPayment, isPending } = useFetchWithPayment(client); + + const handleApiCall = async () => { + // Response is automatically parsed as JSON by default + const data = await fetchWithPayment( + "https://api.example.com/paid-endpoint" + ); + console.log(data); + }; + + return ( + + ); +} +``` + +## Customize Response Parsing + +By default, responses are parsed as JSON. You can customize this with the `parseAs` option: + +```tsx +// Parse as text instead of JSON +const { fetchWithPayment } = useFetchWithPayment(client, { + parseAs: "text", +}); + +// Or get the raw Response object +const { fetchWithPayment } = useFetchWithPayment(client, { + parseAs: "raw", +}); +``` + +## Customize Theme & Payment Options + +```tsx +const { fetchWithPayment } = useFetchWithPayment(client, { + maxValue: 5000000n, // 5 USDC in base units + theme: "light", + paymentRequirementsSelector: (requirements) => { + // Custom logic to select preferred payment method + return requirements[0]; + }, +}); +``` + +## Customize Fund Wallet Widget + +```tsx +const { fetchWithPayment } = useFetchWithPayment(client, { + fundWalletOptions: { + title: "Add Funds", + description: "You need more tokens to complete this payment", + buttonLabel: "Get Tokens", + }, +}); +``` + +## Customize Connect Modal + +```tsx +const { fetchWithPayment } = useFetchWithPayment(client, { + connectOptions: { + wallets: [inAppWallet(), createWallet("io.metamask")], + title: "Sign in to continue", + showThirdwebBranding: false, + }, +}); +``` + +## Disable Modal (Handle Errors Manually) + +```tsx +const { fetchWithPayment, error } = useFetchWithPayment(client, { + showErrorModal: false, +}); + +// Handle the error manually +if (error) { + console.error("Payment failed:", error); +} +``` diff --git a/apps/playground-web/src/app/x402/components/X402RightSection.tsx b/apps/playground-web/src/app/x402/components/X402RightSection.tsx index 0c8efc375a6..432891e576a 100644 --- a/apps/playground-web/src/app/x402/components/X402RightSection.tsx +++ b/apps/playground-web/src/app/x402/components/X402RightSection.tsx @@ -1,17 +1,11 @@ "use client"; -import { useMutation } from "@tanstack/react-query"; import { Badge } from "@workspace/ui/components/badge"; import { CodeClient } from "@workspace/ui/components/code/code.client"; import { CodeIcon, LockIcon } from "lucide-react"; import { usePathname } from "next/navigation"; import { useState } from "react"; -import { - ConnectButton, - useActiveAccount, - useActiveWallet, -} from "thirdweb/react"; -import { wrapFetchWithPayment } from "thirdweb/x402"; +import { ConnectButton, useFetchWithPayment } from "thirdweb/react"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { THIRDWEB_CLIENT } from "@/lib/client"; @@ -31,55 +25,42 @@ export function X402RightSection(props: { options: X402PlaygroundOptions }) { window.history.replaceState({}, "", `${pathname}?tab=${tab}`); } - const activeWallet = useActiveWallet(); - const activeAccount = useActiveAccount(); + const { fetchWithPayment, isPending, data, error, isError } = + useFetchWithPayment(THIRDWEB_CLIENT); - const paidApiCall = useMutation({ - mutationFn: async () => { - if (!activeWallet) { - throw new Error("No active wallet"); - } - const fetchWithPay = wrapFetchWithPayment( - fetch, - THIRDWEB_CLIENT, - activeWallet, - ); - const searchParams = new URLSearchParams(); - searchParams.set("chainId", props.options.chain.id.toString()); - searchParams.set("payTo", props.options.payTo); - searchParams.set("amount", props.options.amount); - searchParams.set("tokenAddress", props.options.tokenAddress); - searchParams.set("decimals", props.options.tokenDecimals.toString()); - searchParams.set("waitUntil", props.options.waitUntil); + const handlePayClick = async () => { + const searchParams = new URLSearchParams(); + searchParams.set("chainId", props.options.chain.id.toString()); + searchParams.set("payTo", props.options.payTo); + searchParams.set("amount", props.options.amount); + searchParams.set("tokenAddress", props.options.tokenAddress); + searchParams.set("decimals", props.options.tokenDecimals.toString()); + searchParams.set("waitUntil", props.options.waitUntil); - const url = - "/api/paywall" + - (searchParams.size > 0 ? `?${searchParams.toString()}` : ""); - const response = await fetchWithPay(url.toString()); - return response.json(); - }, - }); + const url = + "/api/paywall" + + (searchParams.size > 0 ? `?${searchParams.toString()}` : ""); - const handlePayClick = async () => { - paidApiCall.mutate(); + await fetchWithPayment(url); }; const clientCode = `import { createThirdwebClient } from "thirdweb"; -import { wrapFetchWithPayment } from "thirdweb/x402"; -import { useActiveWallet } from "thirdweb/react"; +import { useFetchWithPayment } from "thirdweb/react"; const client = createThirdwebClient({ clientId: "your-client-id" }); export default function Page() { - const wallet = useActiveWallet(); + const { fetchWithPayment, isPending } = useFetchWithPayment(client); const onClick = async () => { - const fetchWithPay = wrapFetchWithPayment(fetch, client, wallet); - const response = await fetchWithPay('/api/paid-endpoint'); + const data = await fetchWithPayment('/api/paid-endpoint'); + console.log(data); } return ( - + ); }`; @@ -204,7 +185,7 @@ export async function POST(request: Request) { onClick={handlePayClick} className="w-full mb-4" size="lg" - disabled={paidApiCall.isPending || !activeAccount} + disabled={isPending} > Access Premium Content @@ -218,19 +199,12 @@ export async function POST(request: Request) { API Call Response - {paidApiCall.isPending && ( -
Loading...
- )} - {paidApiCall.isError && ( -
- Error: {paidApiCall.error.message} -
+ {isPending &&
Loading...
} + {isError && ( +
Error: {error?.message}
)} - {paidApiCall.data && ( - + {!!data && ( + )} diff --git a/apps/portal/src/app/references/components/TDoc/utils/getSidebarLinkgroups.ts b/apps/portal/src/app/references/components/TDoc/utils/getSidebarLinkgroups.ts index 8350ca208d4..69d9f50cb1e 100644 --- a/apps/portal/src/app/references/components/TDoc/utils/getSidebarLinkgroups.ts +++ b/apps/portal/src/app/references/components/TDoc/utils/getSidebarLinkgroups.ts @@ -13,6 +13,7 @@ const tagsToGroup = { "@appURI": "App URI", "@auth": "Auth", "@bridge": "Payments", + "@x402": "x402", "@chain": "Chain", "@claimConditions": "Claim Conditions", "@client": "Client", @@ -64,6 +65,7 @@ const sidebarGroupOrder: TagKey[] = [ "@transaction", "@insight", "@engine", + "@x402", "@bridge", "@nebula", "@social", diff --git a/apps/portal/src/app/wallets/server/send-transactions/page.mdx b/apps/portal/src/app/wallets/server/send-transactions/page.mdx index 4fbcf8ee1ec..0aef431b012 100644 --- a/apps/portal/src/app/wallets/server/send-transactions/page.mdx +++ b/apps/portal/src/app/wallets/server/send-transactions/page.mdx @@ -80,7 +80,7 @@ Send, monitor, and manage transactions. Send transactions from user or server wa - For user wallets in React applications that use the SDK, you can obtain the user wallet auth token (JWT) with the [`useAuthToken()`](/references/typescript/v5/useAuthToken) hook. - For user wallets in TypeScript applications, you can get it by calling `wallet.getAuthToken()` on a connected [`inAppWallet()`](/references/typescript/v5/inAppWallet) or [`ecosystemWallet()`](/references/typescript/v5/ecosystemWallet). - + diff --git a/apps/portal/src/app/x402/client/page.mdx b/apps/portal/src/app/x402/client/page.mdx index dc26407de0d..193e31271ae 100644 --- a/apps/portal/src/app/x402/client/page.mdx +++ b/apps/portal/src/app/x402/client/page.mdx @@ -1,5 +1,5 @@ import { Tabs, TabsList, TabsTrigger, TabsContent, OpenApiEndpoint, createMetadata } from "@doc"; -import { TypeScriptIcon, EngineIcon } from "@/icons"; +import { TypeScriptIcon, ReactIcon, EngineIcon } from "@/icons"; export const metadata = createMetadata({ image: { @@ -27,6 +27,10 @@ The client library wraps the native `fetch` API and handles: TypeScript + + + React + HTTP API @@ -48,12 +52,10 @@ The client library wraps the native `fetch` API and handles: await wallet.connect({ client }) // Wrap fetch with payment handling - // maxValue is the maximum payment amount in base units (defaults to 1 USDC = 1_000_000) const fetchWithPay = wrapFetchWithPayment( fetch, client, wallet, - BigInt(1 * 10 ** 6) // max 1 USDC ); // Make a request that may require payment @@ -66,7 +68,9 @@ The client library wraps the native `fetch` API and handles: - `fetch` - The fetch function to wrap (typically `globalThis.fetch`) - `client` - The thirdweb client used to access RPC infrastructure - `wallet` - The wallet used to sign payment messages - - `maxValue` - (Optional) The maximum allowed payment amount in base units (defaults to 1 USDC = 1,000,000) + - `options` - (Optional) Configuration object: + - `maxValue` - Maximum allowed payment amount in base units + - `paymentRequirementsSelector` - Custom function to select payment requirements from available options ### Reference @@ -74,6 +78,60 @@ The client library wraps the native `fetch` API and handles: + + ## Using `useFetchWithPayment` + + The `useFetchWithPayment` hook is a React-specific wrapper that automatically handles 402 Payment Required responses with built-in UI for payment errors, insufficient funds, and wallet connection. + + ```tsx + import { useFetchWithPayment } from "thirdweb/react"; + import { createThirdwebClient } from "thirdweb"; + + const client = createThirdwebClient({ clientId: "your-client-id" }); + + function MyComponent() { + const { fetchWithPayment, isPending } = useFetchWithPayment(client); + + const handleApiCall = async () => { + // Handle wallet connection, funding, and payment errors automatically + // Response is parsed as JSON by default + const data = await fetchWithPayment('https://api.example.com/paid-endpoint'); + console.log(data); + }; + + return ( + + ); + } + ``` + + ### Features + + - **Automatic Payment Handling**: Detects 402 responses and creates payment headers automatically + - **Wallet Connection**: Prompts users to connect their wallet if not connected + - **Funding**: Integrates BuyWidget to help users top up their wallet directly when needed + - **Built-in error handling UI**: Shows error modals with retry and fund wallet options + - **Response Parsing**: Automatically parses responses as JSON by default + + ### Parameters + + - `client` - The thirdweb client used to access RPC infrastructure + - `options` - (Optional) Configuration object: + - `maxValue` - Maximum allowed payment amount in base units + - `parseAs` - How to parse the response: "json" (default), "text", or "raw" + - `theme` - Theme for the payment error modal: "dark" (default) or "light" + - `uiEnabled` - Whether to show the UI for connection, funding or payment retries (defaults to true) + - `fundWalletOptions` - Customize the BuyWidget for topping up + - `connectOptions` - Customize the ConnectModal for wallet connection + + ### Reference + + For full API documentation, see the [TypeScript Reference](/references/typescript/v5/useFetchWithPayment). + + + ## Fetch with Payment diff --git a/apps/portal/src/app/x402/page.mdx b/apps/portal/src/app/x402/page.mdx index 06fdffebdcb..c0be637314b 100644 --- a/apps/portal/src/app/x402/page.mdx +++ b/apps/portal/src/app/x402/page.mdx @@ -1,4 +1,4 @@ -import { ArticleIconCard, createMetadata } from "@doc"; +import { ArticleIconCard, createMetadata, Tabs, TabsList, TabsTrigger, TabsContent } from "@doc"; import { ReactIcon, TypeScriptIcon, EngineIcon } from "@/icons"; export const metadata = createMetadata({ @@ -33,24 +33,69 @@ The payment token must support either: ## Client Side -`wrapFetchWithPayment` wraps the native fetch API to automatically handle `402 Payment Required` responses from any x402-compatible API: - -```typescript -import { wrapFetchWithPayment } from "thirdweb/x402"; -import { createThirdwebClient } from "thirdweb"; -import { createWallet } from "thirdweb/wallets"; - -const client = createThirdwebClient({ clientId: "your-client-id" }); -const wallet = createWallet("io.metamask"); -await wallet.connect({ client }) - -const fetchWithPay = wrapFetchWithPayment(fetch, client, wallet); - -// Make a request that may require payment -const response = await fetchWithPay('https://api.example.com/paid-endpoint'); -``` - -You can also use the thirdweb API to fetch any x402 compatible endpoint and pay for it with the authenticated wallet. See the [client side docs](/x402/client) for more details. + + + + + TypeScript + + + + React + + + + + `wrapFetchWithPayment` wraps the native fetch API to automatically handle `402 Payment Required` responses from any x402-compatible API: + + ```typescript + import { wrapFetchWithPayment } from "thirdweb/x402"; + import { createThirdwebClient } from "thirdweb"; + import { createWallet } from "thirdweb/wallets"; + + const client = createThirdwebClient({ clientId: "your-client-id" }); + const wallet = createWallet("io.metamask"); + await wallet.connect({ client }) + + const fetchWithPay = wrapFetchWithPayment(fetch, client, wallet); + + // Make a request that may require payment + const response = await fetchWithPay('https://api.example.com/paid-endpoint'); + ``` + + You can also use the thirdweb API to fetch any x402 compatible endpoint and pay for it with the authenticated wallet. See the [client side docs](/x402/client) for more details. + + + + `useFetchWithPayment` is a React hook that automatically handles `402 Payment Required` responses with built-in UI for payment errors and wallet connection: + + ```tsx + import { useFetchWithPayment } from "thirdweb/react"; + import { createThirdwebClient } from "thirdweb"; + + const client = createThirdwebClient({ clientId: "your-client-id" }); + + function MyComponent() { + const { fetchWithPayment, isPending } = useFetchWithPayment(client); + + const handleApiCall = async () => { + // Handle wallet connection, funding, and payment errors automatically + // Response is parsed as JSON by default + const data = await fetchWithPayment('https://api.example.com/paid-endpoint'); + console.log(data); + }; + + return ( + + ); + } + ``` + + The hook automatically shows modals for wallet connection, funding and payment errors. See the [client side docs](/x402/client) for more details. + + ## Server Side diff --git a/packages/thirdweb/src/exports/react.native.ts b/packages/thirdweb/src/exports/react.native.ts index eeba1b18f07..9723c3a09c0 100644 --- a/packages/thirdweb/src/exports/react.native.ts +++ b/packages/thirdweb/src/exports/react.native.ts @@ -114,6 +114,11 @@ export { useAutoConnect } from "../react/native/hooks/wallets/useAutoConnect.js" export { useLinkProfile } from "../react/native/hooks/wallets/useLinkProfile.js"; export { useProfiles } from "../react/native/hooks/wallets/useProfiles.js"; export { useUnlinkProfile } from "../react/native/hooks/wallets/useUnlinkProfile.js"; +// x402 +export { + type UseFetchWithPaymentOptions, + useFetchWithPayment, +} from "../react/native/hooks/x402/useFetchWithPayment.js"; export { ThirdwebProvider } from "../react/native/providers/thirdweb-provider.js"; // Components export { AutoConnect } from "../react/native/ui/AutoConnect/AutoConnect.js"; diff --git a/packages/thirdweb/src/exports/react.ts b/packages/thirdweb/src/exports/react.ts index 48e07836492..47e57990081 100644 --- a/packages/thirdweb/src/exports/react.ts +++ b/packages/thirdweb/src/exports/react.ts @@ -131,6 +131,11 @@ export { useAutoConnect } from "../react/web/hooks/wallets/useAutoConnect.js"; export { useLinkProfile } from "../react/web/hooks/wallets/useLinkProfile.js"; export { useProfiles } from "../react/web/hooks/wallets/useProfiles.js"; export { useUnlinkProfile } from "../react/web/hooks/wallets/useUnlinkProfile.js"; +// x402 +export { + type UseFetchWithPaymentOptions, + useFetchWithPayment, +} from "../react/web/hooks/x402/useFetchWithPayment.js"; export { ThirdwebProvider } from "../react/web/providers/thirdweb-provider.js"; export { AutoConnect } from "../react/web/ui/AutoConnect/AutoConnect.js"; export type { BuyOrOnrampPrepareResult } from "../react/web/ui/Bridge/BuyWidget.js"; diff --git a/packages/thirdweb/src/react/core/hooks/x402/useFetchWithPaymentCore.ts b/packages/thirdweb/src/react/core/hooks/x402/useFetchWithPaymentCore.ts new file mode 100644 index 00000000000..e40812fd201 --- /dev/null +++ b/packages/thirdweb/src/react/core/hooks/x402/useFetchWithPaymentCore.ts @@ -0,0 +1,160 @@ +"use client"; + +import { useMutation } from "@tanstack/react-query"; +import type { ThirdwebClient } from "../../../../client/client.js"; +import type { Wallet } from "../../../../wallets/interfaces/wallet.js"; +import { wrapFetchWithPayment } from "../../../../x402/fetchWithPayment.js"; +import type { RequestedPaymentRequirements } from "../../../../x402/schemas.js"; +import type { PaymentRequiredResult } from "../../../../x402/types.js"; +import { useActiveWallet } from "../wallets/useActiveWallet.js"; + +export type UseFetchWithPaymentOptions = { + maxValue?: bigint; + paymentRequirementsSelector?: ( + paymentRequirements: RequestedPaymentRequirements[], + ) => RequestedPaymentRequirements | undefined; + parseAs?: "json" | "text" | "raw"; +}; + +type ShowErrorModalCallback = (data: { + errorData: PaymentRequiredResult["responseBody"]; + onRetry: () => void; + onCancel: () => void; +}) => void; + +type ShowConnectModalCallback = (data: { + onConnect: (wallet: Wallet) => void; + onCancel: () => void; +}) => void; + +/** + * Core hook for fetch with payment functionality. + * This is the platform-agnostic implementation used by both web and native versions. + * @internal + */ +export function useFetchWithPaymentCore( + client: ThirdwebClient, + options?: UseFetchWithPaymentOptions, + showErrorModal?: ShowErrorModalCallback, + showConnectModal?: ShowConnectModalCallback, +) { + const wallet = useActiveWallet(); + + const mutation = useMutation({ + mutationFn: async ({ + input, + init, + }: { + input: RequestInfo; + init?: RequestInit; + }) => { + // Recursive function that handles fetch + 402 error + retry + const executeFetch = async (currentWallet = wallet): Promise => { + if (!currentWallet) { + // If a connect modal handler is provided, show the connect modal + if (showConnectModal) { + return new Promise((resolve, reject) => { + showConnectModal({ + onConnect: async (newWallet) => { + // After connection, retry the fetch with the newly connected wallet + try { + const result = await executeFetch(newWallet); + resolve(result); + } catch (error) { + reject(error); + } + }, + onCancel: () => { + reject(new Error("Wallet connection cancelled by user")); + }, + }); + }); + } + + // If no connect modal handler, throw an error + throw new Error( + "No wallet connected. Please connect your wallet to make paid API calls.", + ); + } + + const wrappedFetch = wrapFetchWithPayment( + globalThis.fetch, + client, + currentWallet, + options, + ); + + const response = await wrappedFetch(input, init); + + // Check if we got a 402 response (payment error) + if (response.status === 402) { + try { + const errorBody = + (await response.json()) as PaymentRequiredResult["responseBody"]; + + // If a modal handler is provided, show the modal and handle retry/cancel + if (showErrorModal) { + return new Promise((resolve, reject) => { + showErrorModal({ + errorData: errorBody, + onRetry: async () => { + // Retry the entire fetch+error handling logic recursively + try { + const result = await executeFetch(); + resolve(result); + } catch (error) { + reject(error); + } + }, + onCancel: () => { + reject(new Error("Payment cancelled by user")); + }, + }); + }); + } + + // If no modal handler, throw the error with details + throw new Error( + errorBody.errorMessage || `Payment failed: ${errorBody.error}`, + ); + } catch (_parseError) { + // If we can't parse the error body, throw a generic error + throw new Error("Payment failed with status 402"); + } + } + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + `Payment failed with status ${response.status} ${response.statusText} - ${errorText || "Unknown error"}`, + ); + } + + const parseAs = options?.parseAs ?? "json"; + return parseResponse(response, parseAs); + }; + + // Start the fetch process + return executeFetch(); + }, + }); + + return { + fetchWithPayment: async (input: RequestInfo, init?: RequestInit) => { + return mutation.mutateAsync({ input, init }); + }, + ...mutation, + }; +} + +function parseResponse(response: Response, parseAs: "json" | "text" | "raw") { + if (parseAs === "json") { + return response.json(); + } else if (parseAs === "text") { + return response.text(); + } else if (parseAs === "raw") { + return response; + } else { + throw new Error(`Invalid parseAs option: ${parseAs}`); + } +} diff --git a/packages/thirdweb/src/react/native/hooks/x402/useFetchWithPayment.ts b/packages/thirdweb/src/react/native/hooks/x402/useFetchWithPayment.ts new file mode 100644 index 00000000000..d5436d0c35c --- /dev/null +++ b/packages/thirdweb/src/react/native/hooks/x402/useFetchWithPayment.ts @@ -0,0 +1,96 @@ +"use client"; + +import type { ThirdwebClient } from "../../../../client/client.js"; +import { + type UseFetchWithPaymentOptions, + useFetchWithPaymentCore, +} from "../../../core/hooks/x402/useFetchWithPaymentCore.js"; + +export type { UseFetchWithPaymentOptions }; + +/** + * A React hook that wraps the native fetch API to automatically handle 402 Payment Required responses + * using the x402 payment protocol with the currently connected wallet. + * + * This hook enables you to make API calls that require payment without manually handling the payment flow. + * Responses are automatically parsed as JSON by default (can be customized with `parseAs` option). + * + * When a 402 response is received, it will automatically: + * 1. Parse the payment requirements + * 2. Verify the payment amount is within the allowed maximum + * 3. Create a payment header using the connected wallet + * 4. Retry the request with the payment header + * + * Note: This is the React Native version which does not include modal UI. + * Payment errors will be thrown and should be handled by your application. + * + * @param client - The thirdweb client used to access RPC infrastructure + * @param options - Optional configuration for payment handling + * @param options.maxValue - The maximum allowed payment amount in base units + * @param options.paymentRequirementsSelector - Custom function to select payment requirements from available options + * @param options.parseAs - How to parse the response: "json" (default), "text", or "raw" + * @returns An object containing: + * - `fetchWithPayment`: Function to make fetch requests with automatic payment handling (returns parsed data) + * - `isPending`: Boolean indicating if a request is in progress + * - `error`: Any error that occurred during the request + * - `data`: The parsed response data (JSON by default, or based on `parseAs` option) + * - Other mutation properties from React Query + * + * @example + * ```tsx + * import { useFetchWithPayment } from "thirdweb/react"; + * import { createThirdwebClient } from "thirdweb"; + * + * const client = createThirdwebClient({ clientId: "your-client-id" }); + * + * function MyComponent() { + * const { fetchWithPayment, isPending, error } = useFetchWithPayment(client); + * + * const handleApiCall = async () => { + * try { + * // Response is automatically parsed as JSON + * const data = await fetchWithPayment('https://api.example.com/paid-endpoint'); + * console.log(data); + * } catch (err) { + * // Handle payment errors manually in React Native + * console.error("Payment failed:", err); + * } + * }; + * + * return ( + * + * ); + * } + * ``` + * + * ### Customize response parsing + * ```tsx + * const { fetchWithPayment } = useFetchWithPayment(client, { + * parseAs: "text", // Get response as text instead of JSON + * }); + * + * const textData = await fetchWithPayment('https://api.example.com/endpoint'); + * ``` + * + * ### Customize payment options + * ```tsx + * const { fetchWithPayment } = useFetchWithPayment(client, { + * maxValue: 5000000n, // 5 USDC in base units + * paymentRequirementsSelector: (requirements) => { + * // Custom logic to select preferred payment method + * return requirements[0]; + * } + * }); + * ``` + * + * @x402 + */ +export function useFetchWithPayment( + client: ThirdwebClient, + options?: UseFetchWithPaymentOptions, +) { + // Native version doesn't show modal, errors bubble up naturally + return useFetchWithPaymentCore(client, options); +} diff --git a/packages/thirdweb/src/react/web/hooks/x402/useFetchWithPayment.tsx b/packages/thirdweb/src/react/web/hooks/x402/useFetchWithPayment.tsx new file mode 100644 index 00000000000..1dcc2e116dd --- /dev/null +++ b/packages/thirdweb/src/react/web/hooks/x402/useFetchWithPayment.tsx @@ -0,0 +1,238 @@ +"use client"; + +import { useContext } from "react"; +import type { ThirdwebClient } from "../../../../client/client.js"; +import type { Wallet } from "../../../../wallets/interfaces/wallet.js"; +import type { Theme } from "../../../core/design-system/index.js"; +import { + type UseFetchWithPaymentOptions, + useFetchWithPaymentCore, +} from "../../../core/hooks/x402/useFetchWithPaymentCore.js"; +import { SetRootElementContext } from "../../../core/providers/RootElementContext.js"; +import type { BuyWidgetProps } from "../../ui/Bridge/BuyWidget.js"; +import { + type UseConnectModalOptions, + useConnectModal, +} from "../../ui/ConnectWallet/useConnectModal.js"; +import { PaymentErrorModal } from "../../ui/x402/PaymentErrorModal.js"; +import { SignInRequiredModal } from "../../ui/x402/SignInRequiredModal.js"; + +export type { UseFetchWithPaymentOptions }; + +type UseFetchWithPaymentConfig = UseFetchWithPaymentOptions & { + /** + * Whether to show the UI for connection, funding or payment retries. + * If false, no UI will be shown and errors will have to be handled manually. + * @default true + */ + uiEnabled?: boolean; + /** + * Theme for the payment error modal + * @default "dark" + */ + theme?: Theme | "light" | "dark"; + /** + * Options to customize the BuyWidget that appears when the user needs to fund their wallet. + * These options will be merged with default values. + */ + fundWalletOptions?: Partial< + Omit< + BuyWidgetProps, + "client" | "chain" | "tokenAddress" | "onSuccess" | "onCancel" | "theme" + > + >; + /** + * Options to customize the ConnectModal that appears when the user needs to sign in. + * These options will be merged with the client, theme, and chain from the hook. + */ + connectOptions?: Omit; +}; + +/** + * A React hook that wraps the native fetch API to automatically handle 402 Payment Required responses + * using the x402 payment protocol with the currently connected wallet. + * + * This hook enables you to make API calls that require payment without manually handling the payment flow. + * Responses are automatically parsed as JSON by default (can be customized with `parseAs` option). + * + * When a 402 response is received, it will automatically: + * 1. Parse the payment requirements + * 2. Verify the payment amount is within the allowed maximum + * 3. Create a payment header using the connected wallet + * 4. Retry the request with the payment header + * + * If payment fails (e.g. insufficient funds), a modal will be shown to help the user resolve the issue. + * If no wallet is connected, a sign-in modal will be shown to connect a wallet. + * + * @param client - The thirdweb client used to access RPC infrastructure + * @param options - Optional configuration for payment handling + * @param options.maxValue - The maximum allowed payment amount in base units + * @param options.paymentRequirementsSelector - Custom function to select payment requirements from available options + * @param options.parseAs - How to parse the response: "json" (default), "text", or "raw" + * @param options.uiEnabled - Whether to show the UI for connection, funding or payment retries (defaults to true). Set to false to handle errors yourself + * @param options.theme - Theme for the payment error modal (defaults to "dark") + * @param options.fundWalletOptions - Customize the BuyWidget shown when user needs to fund their wallet + * @param options.connectOptions - Customize the ConnectModal shown when user needs to sign in + * @returns An object containing: + * - `fetchWithPayment`: Function to make fetch requests with automatic payment handling (returns parsed data) + * - `isPending`: Boolean indicating if a request is in progress + * - `error`: Any error that occurred during the request + * - `data`: The parsed response data (JSON by default, or based on `parseAs` option) + * - Other mutation properties from React Query + * + * @example + * ```tsx + * import { useFetchWithPayment } from "thirdweb/react"; + * import { createThirdwebClient } from "thirdweb"; + * + * const client = createThirdwebClient({ clientId: "your-client-id" }); + * + * function MyComponent() { + * const { fetchWithPayment, isPending } = useFetchWithPayment(client); + * + * const handleApiCall = async () => { + * // Response is automatically parsed as JSON + * const data = await fetchWithPayment('https://api.example.com/paid-endpoint'); + * console.log(data); + * }; + * + * return ( + * + * ); + * } + * ``` + * + * ### Customize response parsing + * ```tsx + * const { fetchWithPayment } = useFetchWithPayment(client, { + * parseAs: "text", // Get response as text instead of JSON + * }); + * + * const textData = await fetchWithPayment('https://api.example.com/endpoint'); + * ``` + * + * ### Customize payment options + * ```tsx + * const { fetchWithPayment } = useFetchWithPayment(client, { + * maxValue: 5000000n, // 5 USDC in base units + * theme: "light", + * paymentRequirementsSelector: (requirements) => { + * // Custom logic to select preferred payment method + * return requirements[0]; + * } + * }); + * ``` + * + * ### Customize the fund wallet widget + * ```tsx + * const { fetchWithPayment } = useFetchWithPayment(client, { + * fundWalletOptions: { + * title: "Add Funds", + * description: "You need more tokens to complete this payment", + * buttonLabel: "Get Tokens", + * } + * }); + * ``` + * + * ### Customize the connect modal + * ```tsx + * const { fetchWithPayment } = useFetchWithPayment(client, { + * connectOptions: { + * wallets: [inAppWallet(), createWallet("io.metamask")], + * title: "Sign in to continue", + * } + * }); + * ``` + * + * ### Disable the UI and handle errors yourself + * ```tsx + * const { fetchWithPayment, error } = useFetchWithPayment(client, { + * uiEnabled: false, + * }); + * + * // Handle the error manually + * if (error) { + * console.error("Payment failed:", error); + * } + * ``` + * + * @x402 + */ +export function useFetchWithPayment( + client: ThirdwebClient, + options?: UseFetchWithPaymentConfig, +) { + const setRootEl = useContext(SetRootElementContext); + const { connect } = useConnectModal(); + const theme = options?.theme || "dark"; + const showModal = options?.uiEnabled !== false; // Default to true + + const showErrorModal = showModal + ? (data: { + errorData: Parameters[0]["errorData"]; + onRetry: () => void; + onCancel: () => void; + }) => { + setRootEl( + { + setRootEl(null); + data.onCancel(); + }} + onRetry={() => { + setRootEl(null); + data.onRetry(); + }} + theme={theme} + fundWalletOptions={options?.fundWalletOptions} + paymentRequirementsSelector={options?.paymentRequirementsSelector} + />, + ); + } + : undefined; + + const showConnectModal = showModal + ? (data: { onConnect: (wallet: Wallet) => void; onCancel: () => void }) => { + // First, show the SignInRequiredModal + setRootEl( + { + // Close the SignInRequiredModal + setRootEl(null); + + // Open the ConnectModal + try { + const connectedWallet = await connect({ + client, + theme, + ...options?.connectOptions, + }); + + // On successful connection, trigger onConnect callback with the wallet + data.onConnect(connectedWallet); + } catch (_error) { + // User cancelled the connection + data.onCancel(); + } + }} + onCancel={() => { + setRootEl(null); + data.onCancel(); + }} + />, + ); + } + : undefined; + + return useFetchWithPaymentCore( + client, + options, + showErrorModal, + showConnectModal, + ); +} diff --git a/packages/thirdweb/src/react/web/ui/components/basic.tsx b/packages/thirdweb/src/react/web/ui/components/basic.tsx index 1e282ca668c..7caa4593b91 100644 --- a/packages/thirdweb/src/react/web/ui/components/basic.tsx +++ b/packages/thirdweb/src/react/web/ui/components/basic.tsx @@ -17,7 +17,7 @@ export const ScreenBottomContainer = /* @__PURE__ */ StyledDiv((_) => { borderTop: `1px solid ${theme.colors.separatorLine}`, display: "flex", flexDirection: "column", - gap: spacing.lg, + gap: spacing.md, padding: spacing.lg, }; }); diff --git a/packages/thirdweb/src/react/web/ui/x402/PaymentErrorModal.tsx b/packages/thirdweb/src/react/web/ui/x402/PaymentErrorModal.tsx new file mode 100644 index 00000000000..4737ef27501 --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/x402/PaymentErrorModal.tsx @@ -0,0 +1,261 @@ +"use client"; +import { useState } from "react"; +import { getCachedChain } from "../../../../chains/utils.js"; +import type { ThirdwebClient } from "../../../../client/client.js"; +import { + extractEvmChainId, + networkToCaip2ChainId, + type RequestedPaymentRequirements, +} from "../../../../x402/schemas.js"; +import type { PaymentRequiredResult } from "../../../../x402/types.js"; +import { CustomThemeProvider } from "../../../core/design-system/CustomThemeProvider.js"; +import type { Theme } from "../../../core/design-system/index.js"; +import { spacing } from "../../../core/design-system/index.js"; +import { useActiveWallet } from "../../../core/hooks/wallets/useActiveWallet.js"; +import { BuyWidget, type BuyWidgetProps } from "../Bridge/BuyWidget.js"; +import { + Container, + ModalHeader, + ScreenBottomContainer, +} from "../components/basic.js"; +import { Button } from "../components/buttons.js"; +import { Modal } from "../components/Modal.js"; +import { Text } from "../components/text.js"; + +type PaymentErrorModalProps = { + client: ThirdwebClient; + errorData: PaymentRequiredResult["responseBody"]; + onRetry: () => void; + onCancel: () => void; + theme: Theme | "light" | "dark"; + fundWalletOptions?: Partial< + Omit< + BuyWidgetProps, + | "client" + | "chain" + | "tokenAddress" + | "amount" + | "onSuccess" + | "onCancel" + | "theme" + > + >; + paymentRequirementsSelector?: ( + paymentRequirements: RequestedPaymentRequirements[], + ) => RequestedPaymentRequirements | undefined; +}; + +type Screen = "error" | "buy-widget"; + +/** + * @internal + */ +export function PaymentErrorModal(props: PaymentErrorModalProps) { + const { + client, + errorData, + onRetry, + onCancel, + theme, + fundWalletOptions, + paymentRequirementsSelector, + } = props; + const [screen, setScreen] = useState("error"); + const isInsufficientFunds = errorData.error === "insufficient_funds"; + const wallet = useActiveWallet(); + + // Extract chain and token info from errorData for BuyWidget + const getBuyWidgetConfig = () => { + if (!errorData.accepts || errorData.accepts.length === 0) { + return null; + } + + // Get payment requirements from errorData + const parsedPaymentRequirements = errorData.accepts; + + // Get the current chain from wallet + const currentChain = wallet?.getChain(); + const currentChainId = currentChain?.id; + + // Select payment requirement using the same logic as wrapFetchWithPayment + const selectedRequirement = paymentRequirementsSelector + ? paymentRequirementsSelector(parsedPaymentRequirements) + : defaultPaymentRequirementsSelector( + parsedPaymentRequirements, + currentChainId, + "exact", + errorData.error, + ); + + if (!selectedRequirement) return null; + + const caip2ChainId = networkToCaip2ChainId(selectedRequirement.network); + const chainId = extractEvmChainId(caip2ChainId); + + if (!chainId) return null; + + const chain = getCachedChain(chainId); + const tokenAddress = selectedRequirement.asset as `0x${string}`; + + return { + chain, + tokenAddress, + amount: undefined, + }; + }; + + const buyWidgetConfig = isInsufficientFunds ? getBuyWidgetConfig() : null; + + if (screen === "buy-widget" && buyWidgetConfig) { + return ( + + { + if (!open) { + onCancel(); + } + }} + size="compact" + title="Top up your wallet" + crossContainerStyles={{ + position: "absolute", + right: spacing.lg, + top: spacing.lg, + zIndex: 1, + }} + > + {/* BuyWidget without padding */} + { + // Close modal and retry the payment + onRetry(); + }} + onCancel={() => { + // Go back to error screen + setScreen("error"); + }} + /> + + + ); + } + + // Error screen (default) + return ( + + { + if (!open) { + onCancel(); + } + }} + size="compact" + title="Payment Failed" + > + + + + + {/* Error Message */} + + {isInsufficientFunds + ? "Your wallet doesn't have enough funds to complete this payment. Please top up your wallet and try again." + : errorData.errorMessage || + "An error occurred while processing your payment."} + + + + + {/* Action Buttons */} + + {isInsufficientFunds && buyWidgetConfig ? ( + <> + + + + ) : ( + <> + + + + )} + + + + ); +} + +// Default payment requirement selector - same logic as in fetchWithPayment.ts +function defaultPaymentRequirementsSelector( + paymentRequirements: RequestedPaymentRequirements[], + chainId: number | undefined, + scheme: "exact", + _error?: string, +): RequestedPaymentRequirements | undefined { + if (!paymentRequirements.length) { + return undefined; + } + + // If we have a chainId, find matching payment requirements + if (chainId !== undefined) { + const matchingPaymentRequirements = paymentRequirements.find( + (x) => + extractEvmChainId(networkToCaip2ChainId(x.network)) === chainId && + x.scheme === scheme, + ); + + if (matchingPaymentRequirements) { + return matchingPaymentRequirements; + } + } + + // If no matching payment requirements, use the first payment requirement + const firstPaymentRequirement = paymentRequirements.find( + (x) => x.scheme === scheme, + ); + return firstPaymentRequirement; +} diff --git a/packages/thirdweb/src/react/web/ui/x402/SignInRequiredModal.tsx b/packages/thirdweb/src/react/web/ui/x402/SignInRequiredModal.tsx new file mode 100644 index 00000000000..3aa312d4c3f --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/x402/SignInRequiredModal.tsx @@ -0,0 +1,75 @@ +"use client"; +import { CustomThemeProvider } from "../../../core/design-system/CustomThemeProvider.js"; +import type { Theme } from "../../../core/design-system/index.js"; +import { spacing } from "../../../core/design-system/index.js"; +import { + Container, + ModalHeader, + ScreenBottomContainer, +} from "../components/basic.js"; +import { Button } from "../components/buttons.js"; +import { Modal } from "../components/Modal.js"; +import { Text } from "../components/text.js"; + +type SignInRequiredModalProps = { + theme: Theme | "light" | "dark"; + onSignIn: () => void; + onCancel: () => void; +}; + +/** + * @internal + */ +export function SignInRequiredModal(props: SignInRequiredModalProps) { + const { theme, onSignIn, onCancel } = props; + + return ( + + { + if (!open) { + onCancel(); + } + }} + size="compact" + title="Sign in required" + > + + + + + {/* Description */} + + Account required to complete payment, please sign in to continue. + + + + + {/* Action Buttons */} + + + + + + + ); +} diff --git a/packages/thirdweb/src/x402/facilitator.ts b/packages/thirdweb/src/x402/facilitator.ts index 31ad4c27faa..599c9dbefc7 100644 --- a/packages/thirdweb/src/x402/facilitator.ts +++ b/packages/thirdweb/src/x402/facilitator.ts @@ -107,7 +107,7 @@ const DEFAULT_BASE_URL = "https://api.thirdweb.com/v1/payments/x402"; * ``` * - * @bridge x402 + * @x402 */ export function facilitator( config: ThirdwebX402FacilitatorConfig, diff --git a/packages/thirdweb/src/x402/fetchWithPayment.ts b/packages/thirdweb/src/x402/fetchWithPayment.ts index 20a4ec58f9e..207cfed5fec 100644 --- a/packages/thirdweb/src/x402/fetchWithPayment.ts +++ b/packages/thirdweb/src/x402/fetchWithPayment.ts @@ -23,7 +23,7 @@ import { createPaymentHeader } from "./sign.js"; * @param fetch - The fetch function to wrap (typically globalThis.fetch) * @param client - The thirdweb client used to access RPC infrastructure * @param wallet - The wallet used to sign payment messages - * @param maxValue - The maximum allowed payment amount in base units (defaults to 1 USDC) + * @param maxValue - The maximum allowed payment amount in base units * @returns A wrapped fetch function that handles 402 responses automatically * * @example @@ -46,13 +46,18 @@ import { createPaymentHeader } from "./sign.js"; * @throws {Error} If a payment has already been attempted for this request * @throws {Error} If there's an error creating the payment header * - * @bridge x402 + * @x402 */ export function wrapFetchWithPayment( fetch: typeof globalThis.fetch, client: ThirdwebClient, wallet: Wallet, - maxValue?: bigint, + options?: { + maxValue?: bigint; + paymentRequirementsSelector?: ( + paymentRequirements: RequestedPaymentRequirements[], + ) => RequestedPaymentRequirements | undefined; + }, ) { return async (input: RequestInfo, init?: RequestInit) => { const response = await fetch(input, init); @@ -78,12 +83,14 @@ export function wrapFetchWithPayment( "Wallet not connected. Please connect your wallet to continue.", ); } - const selectedPaymentRequirements = defaultPaymentRequirementsSelector( - parsedPaymentRequirements, - chain.id, - "exact", - error, - ); + const selectedPaymentRequirements = options?.paymentRequirementsSelector + ? options.paymentRequirementsSelector(parsedPaymentRequirements) + : defaultPaymentRequirementsSelector( + parsedPaymentRequirements, + chain.id, + "exact", + error, + ); if (!selectedPaymentRequirements) { throw new Error( @@ -92,11 +99,11 @@ export function wrapFetchWithPayment( } if ( - maxValue && - BigInt(selectedPaymentRequirements.maxAmountRequired) > maxValue + options?.maxValue && + BigInt(selectedPaymentRequirements.maxAmountRequired) > options.maxValue ) { throw new Error( - `Payment amount exceeds maximum allowed (currently set to ${maxValue} in base units)`, + `Payment amount exceeds maximum allowed (currently set to ${options.maxValue} in base units)`, ); } diff --git a/packages/thirdweb/src/x402/settle-payment.ts b/packages/thirdweb/src/x402/settle-payment.ts index bd1e46aa238..8c2df77ebcf 100644 --- a/packages/thirdweb/src/x402/settle-payment.ts +++ b/packages/thirdweb/src/x402/settle-payment.ts @@ -122,7 +122,7 @@ import { * * @public * @beta - * @bridge x402 + * @x402 */ export async function settlePayment( args: SettlePaymentArgs, diff --git a/packages/thirdweb/src/x402/verify-payment.ts b/packages/thirdweb/src/x402/verify-payment.ts index 3feebdee6da..73d7abeefc0 100644 --- a/packages/thirdweb/src/x402/verify-payment.ts +++ b/packages/thirdweb/src/x402/verify-payment.ts @@ -70,7 +70,7 @@ import { * * @public * @beta - * @bridge x402 + * @x402 */ export async function verifyPayment( args: PaymentArgs, diff --git a/packages/thirdweb/tsdoc.json b/packages/thirdweb/tsdoc.json index d9524f9b198..3324b5da455 100644 --- a/packages/thirdweb/tsdoc.json +++ b/packages/thirdweb/tsdoc.json @@ -5,6 +5,7 @@ "@auth": true, "@beta": true, "@bridge": true, + "@x402": true, "@chain": true, "@client": true, "@component": true, @@ -132,6 +133,10 @@ { "syntaxKind": "block", "tagName": "@engine" + }, + { + "syntaxKind": "block", + "tagName": "@x402" } ] }