From 797573ae38b50d76b2fdaf03bafc45a80655bf7f Mon Sep 17 00:00:00 2001 From: Joaquim Verges Date: Wed, 26 Nov 2025 11:36:46 +1300 Subject: [PATCH] [SDK] Add payment scheme and improve type documentation for x402 --- .changeset/chilly-taxis-search.md | 38 ++++ apps/portal/src/app/x402/page.mdx | 5 - apps/portal/src/app/x402/server/page.mdx | 173 ++++++++++++------ .../Document/Cards/GithubTemplateCard.tsx | 14 +- packages/thirdweb/src/x402/common.ts | 22 +-- packages/thirdweb/src/x402/facilitator.ts | 1 + .../thirdweb/src/x402/fetchWithPayment.ts | 16 +- packages/thirdweb/src/x402/schemas.ts | 5 +- packages/thirdweb/src/x402/settle-payment.ts | 13 +- packages/thirdweb/src/x402/types.ts | 17 +- packages/thirdweb/src/x402/verify-payment.ts | 12 +- 11 files changed, 213 insertions(+), 103 deletions(-) create mode 100644 .changeset/chilly-taxis-search.md diff --git a/.changeset/chilly-taxis-search.md b/.changeset/chilly-taxis-search.md new file mode 100644 index 00000000000..d699f7ad0a0 --- /dev/null +++ b/.changeset/chilly-taxis-search.md @@ -0,0 +1,38 @@ +--- +"thirdweb": minor +--- + +Add "upto" payment scheme option for x402 verify and settle + +```typescript +const paymentArgs = { + resourceUrl: "https://api.example.com/premium-content", + method: "GET", + paymentData, + payTo: "0x1234567890123456789012345678901234567890", + network: arbitrum, + scheme: "upto", // enables dynamic pricing + price: "$0.10", // max payable amount + facilitator: thirdwebFacilitator, +}; + +// First verify the payment is valid for the max amount +const verifyResult = await verifyPayment(paymentArgs); + +if (verifyResult.status !== 200) { + return Response.json(verifyResult.responseBody, { + status: verifyResult.status, + headers: verifyResult.responseHeaders, + }); +} + +// Do the expensive work that requires payment +const { tokensUsed } = await doExpensiveWork(); +const pricePerTokenUsed = 0.00001; + +// Now settle the payment based on actual usage +const settleResult = await settlePayment({ + ...paymentArgs, + price: tokensUsed * pricePerTokenUsed, // adjust final price based on usage +}); +``` diff --git a/apps/portal/src/app/x402/page.mdx b/apps/portal/src/app/x402/page.mdx index c0be637314b..f0714a98f54 100644 --- a/apps/portal/src/app/x402/page.mdx +++ b/apps/portal/src/app/x402/page.mdx @@ -123,11 +123,6 @@ export async function GET(request: Request) { network: arbitrumSepolia, price: "$0.01", facilitator: thirdwebX402Facilitator, - routeConfig: { - description: "Access to premium API content", - mimeType: "application/json", - maxTimeoutSeconds: 300, - }, }); if (result.status === 200) { diff --git a/apps/portal/src/app/x402/server/page.mdx b/apps/portal/src/app/x402/server/page.mdx index efa3f42109d..93ab2934250 100644 --- a/apps/portal/src/app/x402/server/page.mdx +++ b/apps/portal/src/app/x402/server/page.mdx @@ -1,14 +1,24 @@ -import { Tabs, TabsList, TabsTrigger, TabsContent, DocImage, createMetadata } from "@doc"; +import { + Tabs, + TabsList, + TabsTrigger, + TabsContent, + DocImage, + createMetadata, + Stack, + GithubTemplateCard +} from "@doc"; import { Steps, Step } from "@doc"; import PaymentFlow from "./x402-protocol-flow.png"; export const metadata = createMetadata({ - image: { - title: "x402 Server", - icon: "payments", - }, - title: "x402 Server", - description: "Accept x402 payments in your APIs from any x402-compatible client.", + image: { + title: "x402 Server", + icon: "payments", + }, + title: "x402 Server", + description: + "Accept x402 payments in your APIs from any x402-compatible client.", }); # Server Side @@ -28,53 +38,123 @@ The x402 protocol follows this flow: 5. **Verify & Settle** - Server verifies and settles the payment 6. **Success** - Server returns the protected content -## Verify vs Settle +## Exact vs Upto Payment Schemes -You have two options for handling payments: +The thirdweb x402 client/server stack supports two payment schemes: `exact` and `upto`. -### Option 1: Settle Payment Directly +- `exact` - The client pays the exact amount specified in the payment requirements. +- `upto` - The client pays any amount up to the specified maximum amount. -Use `settlePayment()` to verify and settle the payment in one step. This is the simplest approach: +By default, the payment scheme is `exact`. You can specify the payment scheme in the `settlePayment()` or `verifyPayment()` arguments. + +### Exact Payment Scheme + +Use `settlePayment()` to verify and settle the payment in one step. This is the default and simplest approach: ```typescript -const result = await settlePayment(paymentArgs); +const result = await settlePayment({ + resourceUrl: "https://api.example.com/premium-content", + method: "GET", + paymentData, + payTo: "0x1234567890123456789012345678901234567890", + network: arbitrum, + price: "$0.10", + facilitator: thirdwebFacilitator, +}); if (result.status === 200) { - // Payment settled, do the paid work - return Response.json({ data: "premium content" }); + // Payment settled, do the paid work + return Response.json({ data: "premium content" }); } ``` -### Option 2: Verify First, Then Settle +### Upto Payment Scheme + +For dynamic pricing, use `verifyPayment()` first, do the work, then `settlePayment()`: + +- The final price can be dynamic based on the work performed +- Ensures the payment is valid before doing the expensive work -Use `verifyPayment()` first, do the work, then `settlePayment()`. This is useful when: -- The final price might be dynamic based on the work performed -- You want to ensure the payment is valid before doing expensive work -- You need to calculate resource usage before charging +This is great for AI apis that need to charge based on the token usage for example. Check out a fully working example check out [this x402 ai inference example](https://github.com/thirdweb-example/x402-ai-inference). + + + + + +Here's a high level example of how to use the `upto` payment scheme with a dynamic price based on the token usage. First we verify the payment is valid for the max payable amount and then settle the payment based on the actual usage. ```typescript -// First verify the payment is valid +const paymentArgs = { + resourceUrl: "https://api.example.com/premium-content", + method: "GET", + paymentData, + payTo: "0x1234567890123456789012345678901234567890", + network: arbitrum, + scheme: "upto", // enables dynamic pricing + price: "$0.10", // max payable amount + facilitator: thirdwebFacilitator, +}; + +// First verify the payment is valid for the max amount const verifyResult = await verifyPayment(paymentArgs); if (verifyResult.status !== 200) { - return Response.json(verifyResult.responseBody, { - status: verifyResult.status, - headers: verifyResult.responseHeaders, - }); + return Response.json(verifyResult.responseBody, { + status: verifyResult.status, + headers: verifyResult.responseHeaders, + }); } // Do the expensive work that requires payment -const result = await doExpensiveWork(); +const { tokensUsed } = await callExpensiveAIModel(); // Now settle the payment based on actual usage +const pricePerTokenUsed = 0.00001; // ex: $0.00001 per AI model token used const settleResult = await settlePayment({ - ...paymentArgs, - price: calculateDynamicPrice(result), // adjust price based on usage + ...paymentArgs, + price: tokensUsed * pricePerTokenUsed, // adjust final price based on usage }); return Response.json(result); ``` +## Price and Token Configuration + +You can specify prices in multiple ways: + +### USD String + +This will default to using USDC on the specified network. + +```typescript +network: polygon, // or any other EVM chain +price: "$0.10" // 10 cents in USDC +``` + +### ERC20 Token + +You can use any ERC20 token that supports the ERC-2612 permit or ERC-3009 sign with authorization. + +Simply pass the amount in base units and the token address. + +```typescript +network: arbitrum, +price: { + amount: "1000000000000000", // Amount in base units (0.001 tokens with 18 decimals) + asset: { + address: "0xf01E52B0BAC3E147f6CAf956a64586865A0aA928", // Token address + } +} +``` + +### Native Token + +Payments in native tokens are not currently supported. + ## Dedicated Endpoint Examples Protect individual API endpoints with x402 payments: @@ -117,7 +197,6 @@ Protect individual API endpoints with x402 payments: routeConfig: { description: "Access to premium API content", mimeType: "application/json", - maxTimeoutSeconds: 300, }, }); @@ -133,6 +212,7 @@ Protect individual API endpoints with x402 payments: } } ``` + @@ -185,6 +265,7 @@ Protect individual API endpoints with x402 payments: app.listen(3000); ``` + @@ -237,6 +318,7 @@ Protect individual API endpoints with x402 payments: export default app; ``` + @@ -311,6 +393,7 @@ Protect multiple endpoints with a shared middleware: matcher: ["/api/paid/:path*"], }; ``` + @@ -364,6 +447,7 @@ Protect multiple endpoints with a shared middleware: res.json({ message: "This is premium content!" }); }); ``` + @@ -418,38 +502,7 @@ Protect multiple endpoints with a shared middleware: return c.json({ message: "This is premium content!" }); }); ``` + -## Price and Token Configuration - -You can specify prices in multiple ways: - -### USD String - -This will default to using USDC on the specified network. - -```typescript -network: polygon, // or any other EVM chain -price: "$0.10" // 10 cents in USDC -``` - -### ERC20 Token - -You can use any ERC20 token that supports the ERC-2612 permit or ERC-3009 sign with authorization. - -Simply pass the amount in base units and the token address. - -```typescript -network: arbitrum, -price: { - amount: "1000000000000000", // Amount in base units (0.001 tokens with 18 decimals) - asset: { - address: "0xf01E52B0BAC3E147f6CAf956a64586865A0aA928", // Token address - } -} -``` - -### Native Token - -Payments in native tokens are not currently supported. diff --git a/apps/portal/src/components/Document/Cards/GithubTemplateCard.tsx b/apps/portal/src/components/Document/Cards/GithubTemplateCard.tsx index dd706bc47f1..ca1166e1be9 100644 --- a/apps/portal/src/components/Document/Cards/GithubTemplateCard.tsx +++ b/apps/portal/src/components/Document/Cards/GithubTemplateCard.tsx @@ -3,15 +3,23 @@ import { GithubIcon } from "../GithubButtonLink"; export function GithubTemplateCard(props: { title: string; + description?: string; href: string; tag?: string; }) { return ( - +
-
+
-

{props.title}

+
+

{props.title}

+ {props.description && ( +

+ {props.description} +

+ )} +
{props.tag && (
{props.tag} diff --git a/packages/thirdweb/src/x402/common.ts b/packages/thirdweb/src/x402/common.ts index f438409dad1..c15ba4873ee 100644 --- a/packages/thirdweb/src/x402/common.ts +++ b/packages/thirdweb/src/x402/common.ts @@ -35,28 +35,10 @@ type GetPaymentRequirementsResult = { export async function decodePaymentRequest( args: PaymentArgs, ): Promise { - const { - price, - network, - facilitator, - payTo, - resourceUrl, - routeConfig = {}, - method, - paymentData, - extraMetadata, - } = args; + const { facilitator, routeConfig = {}, paymentData } = args; const { errorMessages } = routeConfig; - const paymentRequirementsResult = await facilitator.accepts({ - resourceUrl, - method, - network, - price, - routeConfig, - payTo, - extraMetadata, - }); + const paymentRequirementsResult = await facilitator.accepts(args); // Check for payment header, if none, return the payment requirements if (!paymentData) { diff --git a/packages/thirdweb/src/x402/facilitator.ts b/packages/thirdweb/src/x402/facilitator.ts index 599c9dbefc7..5dce9727d5c 100644 --- a/packages/thirdweb/src/x402/facilitator.ts +++ b/packages/thirdweb/src/x402/facilitator.ts @@ -278,6 +278,7 @@ export function facilitator( method: args.method, network: caip2ChainId, price: args.price, + scheme: args.scheme, routeConfig: args.routeConfig, serverWalletAddress: facilitator.address, recipientAddress: args.payTo, diff --git a/packages/thirdweb/src/x402/fetchWithPayment.ts b/packages/thirdweb/src/x402/fetchWithPayment.ts index 207cfed5fec..8ab1d7b1b23 100644 --- a/packages/thirdweb/src/x402/fetchWithPayment.ts +++ b/packages/thirdweb/src/x402/fetchWithPayment.ts @@ -71,9 +71,9 @@ export function wrapFetchWithPayment( accepts: unknown[]; error?: string; }; - const parsedPaymentRequirements = accepts - .map((x) => RequestedPaymentRequirementsSchema.parse(x)) - .filter((x) => x.scheme === "exact"); // TODO (402): accept other schemes + const parsedPaymentRequirements = accepts.map((x) => + RequestedPaymentRequirementsSchema.parse(x), + ); const account = wallet.getAccount(); let chain = wallet.getChain(); @@ -88,7 +88,6 @@ export function wrapFetchWithPayment( : defaultPaymentRequirementsSelector( parsedPaymentRequirements, chain.id, - "exact", error, ); @@ -158,7 +157,6 @@ export function wrapFetchWithPayment( function defaultPaymentRequirementsSelector( paymentRequirements: RequestedPaymentRequirements[], chainId: number, - scheme: "exact", error?: string, ) { if (!paymentRequirements.length) { @@ -168,9 +166,7 @@ function defaultPaymentRequirementsSelector( } // find the payment requirements matching the connected wallet chain const matchingPaymentRequirements = paymentRequirements.find( - (x) => - extractEvmChainId(networkToCaip2ChainId(x.network)) === chainId && - x.scheme === scheme, + (x) => extractEvmChainId(networkToCaip2ChainId(x.network)) === chainId, ); if (matchingPaymentRequirements) { @@ -178,9 +174,7 @@ function defaultPaymentRequirementsSelector( } else { // if no matching payment requirements, use the first payment requirement // and switch the wallet to that chain - const firstPaymentRequirement = paymentRequirements.find( - (x) => x.scheme === scheme, - ); + const firstPaymentRequirement = paymentRequirements[0]; return firstPaymentRequirement; } } diff --git a/packages/thirdweb/src/x402/schemas.ts b/packages/thirdweb/src/x402/schemas.ts index 3f5d112c133..0886023e155 100644 --- a/packages/thirdweb/src/x402/schemas.ts +++ b/packages/thirdweb/src/x402/schemas.ts @@ -10,6 +10,7 @@ import { } from "x402/types"; import { z } from "zod"; import type { Chain } from "../chains/types.js"; +import { PaymentSchemeSchema } from "./types.js"; const FacilitatorNetworkSchema = z.string(); @@ -17,6 +18,7 @@ export type FacilitatorNetwork = z.infer; const RequestedPaymentPayloadSchema = PaymentPayloadSchema.extend({ network: FacilitatorNetworkSchema, + scheme: PaymentSchemeSchema, }); export type RequestedPaymentPayload = z.infer< @@ -32,6 +34,7 @@ export type UnsignedPaymentPayload = Omit< export const RequestedPaymentRequirementsSchema = PaymentRequirementsSchema.extend({ network: FacilitatorNetworkSchema, + scheme: PaymentSchemeSchema, }); export type RequestedPaymentRequirements = z.infer< @@ -76,7 +79,7 @@ const FacilitatorSupportedResponseSchema = kinds: z.array( z.object({ x402Version: z.literal(1), - scheme: z.literal("exact"), + scheme: PaymentSchemeSchema, network: FacilitatorNetworkSchema, extra: z .object({ diff --git a/packages/thirdweb/src/x402/settle-payment.ts b/packages/thirdweb/src/x402/settle-payment.ts index 8c2df77ebcf..50cb2b61fa2 100644 --- a/packages/thirdweb/src/x402/settle-payment.ts +++ b/packages/thirdweb/src/x402/settle-payment.ts @@ -51,7 +51,6 @@ import { * routeConfig: { * description: "Access to premium API content", * mimeType: "application/json", - * maxTimeoutSeconds: 300, * }, * }); * @@ -68,6 +67,18 @@ import { * } * ``` * + * ### Upto Payment Scheme + * + * You can also use the `upto` payment scheme to settle the payment dynamically based on the usage. + * + * ```ts + * const result = await settlePayment({ + * ...paymentArgs, + * scheme: "upto", + * price: "$0.10", // max payable amount + * }); + * ``` + * * ### Express middleware example * * ```ts diff --git a/packages/thirdweb/src/x402/types.ts b/packages/thirdweb/src/x402/types.ts index 91b7c66ded2..7837812d9a7 100644 --- a/packages/thirdweb/src/x402/types.ts +++ b/packages/thirdweb/src/x402/types.ts @@ -1,5 +1,5 @@ import type { Money, PaymentMiddlewareConfig } from "x402/types"; -import type z from "zod"; +import z from "zod"; import type { Chain } from "../chains/types.js"; import type { Prettify } from "../utils/type-utils.js"; import type { ThirdwebX402Facilitator, WaitUntil } from "./facilitator.js"; @@ -31,6 +31,8 @@ export type PaymentArgs = { price: Money | ERC20TokenAmount; /** The payment facilitator instance used to verify and settle payments */ facilitator: ThirdwebX402Facilitator; + /** The scheme of the payment, either "exact" or "upto", defaults to "exact" */ + scheme?: PaymentScheme; /** Optional configuration for the payment middleware route */ routeConfig?: PaymentMiddlewareConfig; /** Optional recipient address to receive the payment if different from your facilitator address */ @@ -91,7 +93,9 @@ export type VerifyPaymentResult = Prettify< | { /** HTTP 200 - Payment was successfully verified */ status: 200; + /** The decoded payment payload */ decodedPayment: RequestedPaymentPayload; + /** The selected payment requirements */ selectedPaymentRequirements: RequestedPaymentRequirements; } | PaymentRequiredResult @@ -101,8 +105,19 @@ export type SupportedSignatureType = z.infer< typeof SupportedSignatureTypeSchema >; +export const PaymentSchemeSchema = z.union([ + z.literal("exact"), + z.literal("upto"), +]); +type PaymentScheme = z.infer; + +/** + * The asset, scheme and amount for the payment in base units + */ export type ERC20TokenAmount = { + /** The amount of the payment in base units */ amount: string; + /** The asset of the payment, decimals and eip712 data are optional and will be inferred from the address if not provided */ asset: { address: `0x${string}`; decimals?: number; diff --git a/packages/thirdweb/src/x402/verify-payment.ts b/packages/thirdweb/src/x402/verify-payment.ts index 73d7abeefc0..94e9f663d1e 100644 --- a/packages/thirdweb/src/x402/verify-payment.ts +++ b/packages/thirdweb/src/x402/verify-payment.ts @@ -42,7 +42,6 @@ import { * routeConfig: { * description: "Access to premium API content", * mimeType: "application/json", - * maxTimeoutSeconds: 300, * }, * }; * @@ -68,6 +67,17 @@ import { * } * ``` * + * ### Upto Payment Scheme + * + * You can also use the `upto` payment scheme to verify a payment where the final price is dynamically calculated based on the usage. + * + * ```ts + * const result = await verifyPayment({ + * ...paymentArgs, + * scheme: "upto", + * price: "$0.10", // max payable amount + * }); + * ``` * @public * @beta * @x402