Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/x402-v2-amount.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"thirdweb": patch
---

Accept x402 v2 payment requirements that specify `amount`, and enforce `maxValue: 0n` as a cap.
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,9 @@ export async function GET(request: Request) {
const result = await settlePayment({
resourceUrl: "https://api.example.com/premium-content",
method: "GET",
paymentData: request.headers.get("x-payment"),
paymentData:
request.headers.get("PAYMENT-SIGNATURE") ||
request.headers.get("X-PAYMENT"),
network: arbitrumSepolia,
price: "$0.01",
facilitator: thirdwebX402Facilitator,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,11 +94,13 @@ const client = createThirdwebClient({
const thirdwebFacilitator = facilitator({
client,
serverWalletAddress: "0xYourServerWalletAddress",
waitUtil: "${props.options.waitUntil}",
waitUntil: "${props.options.waitUntil}",
});

export async function POST(request: Request) {
const paymentData = request.headers.get("x-payment");
const paymentData =
request.headers.get("PAYMENT-SIGNATURE") ||
request.headers.get("X-PAYMENT");

// verify and process the payment
const result = await settlePayment({
Expand Down
8 changes: 4 additions & 4 deletions apps/portal/src/app/x402/server/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,7 @@ Protect individual API endpoints with x402 payments:
const result = await settlePayment({
resourceUrl: `${req.protocol}://${req.get('host')}${req.originalUrl}`,
method: req.method,
paymentData: req.headers["x-payment"],
paymentData: req.headers["payment-signature"] ?? req.headers["x-payment"],
payTo: "0x1234567890123456789012345678901234567890",
network: arbitrumSepolia,
price: "$0.05",
Expand Down Expand Up @@ -320,7 +320,7 @@ Protect individual API endpoints with x402 payments:
const result = await settlePayment({
resourceUrl: new URL(c.req.url).toString(),
method: c.req.method,
paymentData: c.req.header("x-payment"),
paymentData: c.req.header("payment-signature") ?? c.req.header("x-payment"),
payTo: "0x1234567890123456789012345678901234567890",
network: arbitrumSepolia,
price: "$0.05",
Expand Down Expand Up @@ -445,7 +445,7 @@ Protect multiple endpoints with a shared middleware:
const result = await settlePayment({
resourceUrl: `${req.protocol}://${req.get('host')}${req.originalUrl}`,
method: req.method,
paymentData: req.headers["x-payment"],
paymentData: req.headers["payment-signature"] ?? req.headers["x-payment"],
payTo: "0x1234567890123456789012345678901234567890",
network: arbitrumSepolia,
price: "$0.05",
Expand Down Expand Up @@ -500,7 +500,7 @@ Protect multiple endpoints with a shared middleware:
const result = await settlePayment({
resourceUrl: new URL(c.req.url).toString(),
method: c.req.method,
paymentData: c.req.header("x-payment"),
paymentData: c.req.header("payment-signature") ?? c.req.header("x-payment"),
payTo: "0x1234567890123456789012345678901234567890",
network: arbitrumSepolia,
price: "$0.05",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import { useMutation } from "@tanstack/react-query";
import type { ThirdwebClient } from "../../../../client/client.js";
import type { AsyncStorage } from "../../../../utils/storage/AsyncStorage.js";
import type { Wallet } from "../../../../wallets/interfaces/wallet.js";
import { wrapFetchWithPayment } from "../../../../x402/fetchWithPayment.js";
import {
getRequestUrl,
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";
Expand All @@ -24,6 +27,7 @@ export type UseFetchWithPaymentOptions = {

type ShowErrorModalCallback = (data: {
errorData: PaymentRequiredResult["responseBody"];
requestUrl?: string;
onRetry: () => void;
onCancel: () => void;
}) => void;
Expand Down Expand Up @@ -107,6 +111,7 @@ export function useFetchWithPaymentCore(
return new Promise<unknown>((resolve, reject) => {
showErrorModal({
errorData: errorBody,
requestUrl: getRequestUrl(input),
onRetry: async () => {
// Retry the entire fetch+error handling logic recursively
// Pass currentWallet to avoid re-showing connect modal with stale wallet state
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export type { UseFetchWithPaymentOptions };
*
* @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.maxValue - The maximum allowed payment amount in base units. `0n` only allows zero-amount payments
* @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.storage - Storage for caching permit signatures (for "upto" scheme). Provide your own AsyncStorage implementation for React Native.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ type UseFetchWithPaymentConfig = UseFetchWithPaymentOptions & {
*
* @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.maxValue - The maximum allowed payment amount in base units. `0n` only allows zero-amount payments
* @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
Expand Down Expand Up @@ -196,13 +196,15 @@ export function useFetchWithPayment(
const showErrorModal = showModal
? (data: {
errorData: Parameters<typeof PaymentErrorModal>[0]["errorData"];
requestUrl?: string;
onRetry: () => void;
onCancel: () => void;
}) => {
setRootEl(
<PaymentErrorModal
client={client}
errorData={data.errorData}
requestUrl={data.requestUrl}
onCancel={() => {
setRootEl(null);
data.onCancel();
Expand Down
13 changes: 9 additions & 4 deletions packages/thirdweb/src/react/web/ui/x402/PaymentErrorModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { ThirdwebClient } from "../../../../client/client.js";
import {
extractEvmChainId,
networkToCaip2ChainId,
parsePaymentRequirementsForDisplay,
type RequestedPaymentRequirements,
} from "../../../../x402/schemas.js";
import type { PaymentRequiredResult } from "../../../../x402/types.js";
Expand All @@ -25,6 +26,7 @@ import { Text } from "../components/text.js";
type PaymentErrorModalProps = {
client: ThirdwebClient;
errorData: PaymentRequiredResult["responseBody"];
requestUrl?: string;
onRetry: () => void;
onCancel: () => void;
theme: Theme | "light" | "dark";
Expand Down Expand Up @@ -54,6 +56,7 @@ export function PaymentErrorModal(props: PaymentErrorModalProps) {
const {
client,
errorData,
requestUrl,
onRetry,
onCancel,
theme,
Expand All @@ -66,13 +69,15 @@ export function PaymentErrorModal(props: PaymentErrorModalProps) {

// Extract chain and token info from errorData for BuyWidget
const getBuyWidgetConfig = () => {
if (!errorData.accepts || errorData.accepts.length === 0) {
// Get payment requirements from errorData
const parsedPaymentRequirements = parsePaymentRequirementsForDisplay(
errorData,
requestUrl,
);
if (parsedPaymentRequirements.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;
Expand Down
48 changes: 46 additions & 2 deletions packages/thirdweb/src/x402/encode.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import type { ExactEvmPayload } from "x402/types";
import { cachedTextDecoder } from "../utils/text-decoder.js";
import { cachedTextEncoder } from "../utils/text-encoder.js";
import type {
RequestedPaymentPayload,
RequestedPaymentRequirements,
Expand Down Expand Up @@ -27,7 +29,7 @@
) as ExactEvmPayload["authorization"],
},
};
return safeBase64Encode(JSON.stringify(safe));
return base64EncodeUtf8(JSON.stringify(safe));
}

/**
Expand All @@ -37,7 +39,7 @@
* @returns The decoded and validated PaymentPayload object
*/
export function decodePayment(payment: string): RequestedPaymentPayload {
const decoded = safeBase64Decode(payment);
const decoded = base64DecodeUtf8(payment);

Check warning on line 42 in packages/thirdweb/src/x402/encode.ts

View check run for this annotation

Codecov / codecov/patch

packages/thirdweb/src/x402/encode.ts#L42

Added line #L42 was not covered by tests
const parsed = JSON.parse(decoded);

const obj: RequestedPaymentPayload = {
Expand Down Expand Up @@ -93,3 +95,45 @@
}
return Buffer.from(data, "base64").toString("utf-8");
}

/**
* Encodes a string as UTF-8 and then to base64
*
* @param data - The string to encode
* @returns The base64 encoded UTF-8 bytes
*/
function base64EncodeUtf8(data: string): string {
if (
typeof globalThis !== "undefined" &&
typeof globalThis.btoa === "function"
) {
const bytes = cachedTextEncoder().encode(data);
let binary = "";
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
return globalThis.btoa(binary);
}
return Buffer.from(data, "utf-8").toString("base64");
}

Check warning on line 118 in packages/thirdweb/src/x402/encode.ts

View check run for this annotation

Codecov / codecov/patch

packages/thirdweb/src/x402/encode.ts#L117-L118

Added lines #L117 - L118 were not covered by tests

/**
* Decodes a base64 string and interprets the bytes as UTF-8
*
* @param data - The base64 encoded string
* @returns The decoded string
*/
export function base64DecodeUtf8(data: string): string {
if (
typeof globalThis !== "undefined" &&
typeof globalThis.atob === "function"
) {
const binary = globalThis.atob(data);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return cachedTextDecoder().decode(bytes);
}
return Buffer.from(data, "base64").toString("utf-8");
}

Check warning on line 139 in packages/thirdweb/src/x402/encode.ts

View check run for this annotation

Codecov / codecov/patch

packages/thirdweb/src/x402/encode.ts#L138-L139

Added lines #L138 - L139 were not covered by tests
Loading
Loading