From a9ab1723a19625e1757bb236532d868904a9a38c Mon Sep 17 00:00:00 2001 From: Joaquim Verges Date: Thu, 11 Dec 2025 23:10:35 +1300 Subject: [PATCH] Cache and reuse x402 permit signatures for upto schemes --- .changeset/warm-clouds-judge.md | 5 + apps/portal/src/app/x402/server/page.mdx | 25 +---- .../hooks/x402/useFetchWithPaymentCore.ts | 12 ++- .../native/hooks/x402/useFetchWithPayment.ts | 7 +- .../web/hooks/x402/useFetchWithPayment.tsx | 14 ++- .../thirdweb/src/x402/fetchWithPayment.ts | 21 ++++ .../src/x402/permitSignatureStorage.ts | 99 +++++++++++++++++++ packages/thirdweb/src/x402/sign.ts | 77 ++++++++++++++- 8 files changed, 231 insertions(+), 29 deletions(-) create mode 100644 .changeset/warm-clouds-judge.md create mode 100644 packages/thirdweb/src/x402/permitSignatureStorage.ts diff --git a/.changeset/warm-clouds-judge.md b/.changeset/warm-clouds-judge.md new file mode 100644 index 00000000000..74bbed34379 --- /dev/null +++ b/.changeset/warm-clouds-judge.md @@ -0,0 +1,5 @@ +--- +"thirdweb": patch +--- + +Automatically store and re-use permit x402 signatures for upto schemes diff --git a/apps/portal/src/app/x402/server/page.mdx b/apps/portal/src/app/x402/server/page.mdx index 816c75fbaa6..e19c2b6597f 100644 --- a/apps/portal/src/app/x402/server/page.mdx +++ b/apps/portal/src/app/x402/server/page.mdx @@ -132,30 +132,7 @@ You can call verifyPayment() and settlePayment() multiple times using the same p If any of these checks fail, `verifyPayment()` will return a 402 response requiring a new payment authorization. -You can retrieve the previously signed paymentData from any storage mechanism, for example: - -```typescript -const paymentData = retrievePaymentDataFromStorage(userId, sessionId); // example implementation, can be any storage mechanism -const paymentArgs = { ...otherPaymentArgs, paymentData }; - -// verify paymentData is still valid -const verifyResult = await verifyPayment(paymentArgs); - -if (verifyResult.status !== 200) { - return Response.json(verifyResult.responseBody, { - status: verifyResult.status, - headers: verifyResult.responseHeaders, - }); -} - -// settle payment based on usage, re-using the previous paymentData -const settleResult = await settlePayment({ - ...paymentArgs, - price: tokensUsed * pricePerTokenUsed, // adjust final price based on usage -}); - -return Response.json(answer); -``` +`wrapFetchWithPayment()` and `useFetchWithPayment()` will automatically handle the caching and re-use of the payment data for you, so you don't need to have any additional state or storage on the backend. ## Signature expiration configuration diff --git a/packages/thirdweb/src/react/core/hooks/x402/useFetchWithPaymentCore.ts b/packages/thirdweb/src/react/core/hooks/x402/useFetchWithPaymentCore.ts index 6d3d567c152..fc51df92634 100644 --- a/packages/thirdweb/src/react/core/hooks/x402/useFetchWithPaymentCore.ts +++ b/packages/thirdweb/src/react/core/hooks/x402/useFetchWithPaymentCore.ts @@ -2,6 +2,7 @@ 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 type { RequestedPaymentRequirements } from "../../../../x402/schemas.js"; @@ -14,6 +15,11 @@ export type UseFetchWithPaymentOptions = { paymentRequirements: RequestedPaymentRequirements[], ) => RequestedPaymentRequirements | undefined; parseAs?: "json" | "text" | "raw"; + /** + * Storage for caching permit signatures (for "upto" scheme). + * When provided, permit signatures will be cached and reused if the on-chain allowance is sufficient. + */ + storage?: AsyncStorage; }; type ShowErrorModalCallback = (data: { @@ -81,7 +87,11 @@ export function useFetchWithPaymentCore( globalThis.fetch, client, currentWallet, - options, + { + maxValue: options?.maxValue, + paymentRequirementsSelector: options?.paymentRequirementsSelector, + storage: options?.storage, + }, ); const response = await wrappedFetch(input, init); diff --git a/packages/thirdweb/src/react/native/hooks/x402/useFetchWithPayment.ts b/packages/thirdweb/src/react/native/hooks/x402/useFetchWithPayment.ts index d5436d0c35c..c2f481b6df5 100644 --- a/packages/thirdweb/src/react/native/hooks/x402/useFetchWithPayment.ts +++ b/packages/thirdweb/src/react/native/hooks/x402/useFetchWithPayment.ts @@ -1,6 +1,7 @@ "use client"; import type { ThirdwebClient } from "../../../../client/client.js"; +import { nativeLocalStorage } from "../../../../utils/storage/nativeStorage.js"; import { type UseFetchWithPaymentOptions, useFetchWithPaymentCore, @@ -29,6 +30,7 @@ export type { UseFetchWithPaymentOptions }; * @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.storage - Storage for caching permit signatures (for "upto" scheme). Provide your own AsyncStorage implementation for React Native. * @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 @@ -92,5 +94,8 @@ export function useFetchWithPayment( options?: UseFetchWithPaymentOptions, ) { // Native version doesn't show modal, errors bubble up naturally - return useFetchWithPaymentCore(client, options); + return useFetchWithPaymentCore(client, { + ...options, + storage: options?.storage ?? nativeLocalStorage, + }); } diff --git a/packages/thirdweb/src/react/web/hooks/x402/useFetchWithPayment.tsx b/packages/thirdweb/src/react/web/hooks/x402/useFetchWithPayment.tsx index 02edb823aa1..01264dd5b81 100644 --- a/packages/thirdweb/src/react/web/hooks/x402/useFetchWithPayment.tsx +++ b/packages/thirdweb/src/react/web/hooks/x402/useFetchWithPayment.tsx @@ -1,7 +1,8 @@ "use client"; -import { useContext } from "react"; +import { useContext, useMemo } from "react"; import type { ThirdwebClient } from "../../../../client/client.js"; +import { webLocalStorage } from "../../../../utils/storage/webStorage.js"; import type { Wallet } from "../../../../wallets/interfaces/wallet.js"; import type { Theme } from "../../../core/design-system/index.js"; import { @@ -255,9 +256,18 @@ export function useFetchWithPayment( } : undefined; + // Default to webLocalStorage for permit signature caching + const resolvedOptions = useMemo( + () => ({ + ...options, + storage: options?.storage ?? webLocalStorage, + }), + [options], + ); + return useFetchWithPaymentCore( client, - options, + resolvedOptions, showErrorModal, showConnectModal, ); diff --git a/packages/thirdweb/src/x402/fetchWithPayment.ts b/packages/thirdweb/src/x402/fetchWithPayment.ts index 8ab1d7b1b23..589637c3bad 100644 --- a/packages/thirdweb/src/x402/fetchWithPayment.ts +++ b/packages/thirdweb/src/x402/fetchWithPayment.ts @@ -1,6 +1,10 @@ import { getCachedChain } from "../chains/utils.js"; import type { ThirdwebClient } from "../client/client.js"; +import { getAddress } from "../utils/address.js"; +import type { AsyncStorage } from "../utils/storage/AsyncStorage.js"; +import { webLocalStorage } from "../utils/storage/webStorage.js"; import type { Wallet } from "../wallets/interfaces/wallet.js"; +import { clearPermitSignatureFromCache } from "./permitSignatureStorage.js"; import { extractEvmChainId, networkToCaip2ChainId, @@ -57,6 +61,11 @@ export function wrapFetchWithPayment( paymentRequirementsSelector?: ( paymentRequirements: RequestedPaymentRequirements[], ) => RequestedPaymentRequirements | undefined; + /** + * Storage for caching permit signatures (for "upto" scheme). + * When provided, permit signatures will be cached and reused if the on-chain allowance is sufficient. + */ + storage?: AsyncStorage; }, ) { return async (input: RequestInfo, init?: RequestInit) => { @@ -131,6 +140,7 @@ export function wrapFetchWithPayment( account, selectedPaymentRequirements, x402Version, + options?.storage ?? webLocalStorage, ); const initParams = init || {}; @@ -150,6 +160,17 @@ export function wrapFetchWithPayment( }; const secondResponse = await fetch(input, newInit); + + // If payment was rejected (still 402), clear cached signature + if (secondResponse.status === 402 && options?.storage) { + await clearPermitSignatureFromCache(options.storage, { + chainId: paymentChainId, + asset: selectedPaymentRequirements.asset, + owner: getAddress(account.address), + spender: getAddress(selectedPaymentRequirements.payTo), + }); + } + return secondResponse; }; } diff --git a/packages/thirdweb/src/x402/permitSignatureStorage.ts b/packages/thirdweb/src/x402/permitSignatureStorage.ts new file mode 100644 index 00000000000..ff8e5704041 --- /dev/null +++ b/packages/thirdweb/src/x402/permitSignatureStorage.ts @@ -0,0 +1,99 @@ +import type { AsyncStorage } from "../utils/storage/AsyncStorage.js"; +import type { RequestedPaymentPayload } from "./schemas.js"; + +/** + * Cached permit signature data structure + */ +type CachedPermitSignature = { + payload: RequestedPaymentPayload; + deadline: string; + maxAmount: string; +}; + +/** + * Parameters for generating a permit cache key + */ +export type PermitCacheKeyParams = { + chainId: number; + asset: string; + owner: string; + spender: string; +}; + +const CACHE_KEY_PREFIX = "x402:permit"; + +/** + * Generates a cache key for permit signature storage + * @param params - The parameters to generate the cache key from + * @returns The cache key string + */ +function getPermitCacheKey(params: PermitCacheKeyParams): string { + return `${CACHE_KEY_PREFIX}:${params.chainId}:${params.asset.toLowerCase()}:${params.owner.toLowerCase()}:${params.spender.toLowerCase()}`; +} + +/** + * Retrieves a cached permit signature from storage + * @param storage - The AsyncStorage instance to use + * @param params - The parameters identifying the cached signature + * @returns The cached signature data or null if not found + */ +export async function getPermitSignatureFromCache( + storage: AsyncStorage, + params: PermitCacheKeyParams, +): Promise { + try { + const key = getPermitCacheKey(params); + const cached = await storage.getItem(key); + if (!cached) { + return null; + } + return JSON.parse(cached) as CachedPermitSignature; + } catch { + return null; + } +} + +/** + * Saves a permit signature to storage cache + * @param storage - The AsyncStorage instance to use + * @param params - The parameters identifying the signature + * @param payload - The signed payment payload to cache + * @param deadline - The deadline timestamp of the permit + * @param maxAmount - The maximum amount authorized + */ +export async function savePermitSignatureToCache( + storage: AsyncStorage, + params: PermitCacheKeyParams, + payload: RequestedPaymentPayload, + deadline: string, + maxAmount: string, +): Promise { + try { + const key = getPermitCacheKey(params); + const data: CachedPermitSignature = { + payload, + deadline, + maxAmount, + }; + await storage.setItem(key, JSON.stringify(data)); + } catch { + // Silently fail - caching is optional + } +} + +/** + * Clears a cached permit signature from storage + * @param storage - The AsyncStorage instance to use + * @param params - The parameters identifying the cached signature + */ +export async function clearPermitSignatureFromCache( + storage: AsyncStorage, + params: PermitCacheKeyParams, +): Promise { + try { + const key = getPermitCacheKey(params); + await storage.removeItem(key); + } catch { + // Silently fail + } +} diff --git a/packages/thirdweb/src/x402/sign.ts b/packages/thirdweb/src/x402/sign.ts index d9d9f294c8c..24ed29680f7 100644 --- a/packages/thirdweb/src/x402/sign.ts +++ b/packages/thirdweb/src/x402/sign.ts @@ -3,12 +3,19 @@ import type { ExactEvmPayloadAuthorization } from "x402/types"; import { getCachedChain } from "../chains/utils.js"; import type { ThirdwebClient } from "../client/client.js"; import { getContract } from "../contract/contract.js"; +import { allowance } from "../extensions/erc20/__generated__/IERC20/read/allowance.js"; import { nonces } from "../extensions/erc20/__generated__/IERC20Permit/read/nonces.js"; import { type Address, getAddress } from "../utils/address.js"; import { type Hex, toHex } from "../utils/encoding/hex.js"; +import type { AsyncStorage } from "../utils/storage/AsyncStorage.js"; import type { Account } from "../wallets/interfaces/wallet.js"; import { getSupportedSignatureType } from "./common.js"; import { encodePayment } from "./encode.js"; +import { + getPermitSignatureFromCache, + type PermitCacheKeyParams, + savePermitSignatureToCache, +} from "./permitSignatureStorage.js"; import { extractEvmChainId, networkToCaip2ChainId, @@ -63,6 +70,7 @@ function preparePaymentHeader( * @param client - The signer wallet instance used to sign the payment header * @param paymentRequirements - The payment requirements containing scheme and network information * @param unsignedPaymentHeader - The unsigned payment payload to be signed + * @param storage - Optional storage for caching permit signatures (for "upto" scheme) * @returns A promise that resolves to the signed payment payload */ async function signPaymentHeader( @@ -70,6 +78,7 @@ async function signPaymentHeader( account: Account, paymentRequirements: RequestedPaymentRequirements, x402Version: number, + storage?: AsyncStorage, ): Promise { const from = getAddress(account.address); const caip2ChainId = networkToCaip2ChainId(paymentRequirements.network); @@ -91,6 +100,55 @@ async function signPaymentHeader( switch (supportedSignatureType) { case "Permit": { + const shouldCache = + paymentRequirements.scheme === "upto" && storage !== undefined; + const spender = getAddress(paymentRequirements.payTo); + + const cacheParams: PermitCacheKeyParams = { + chainId, + asset: paymentRequirements.asset, + owner: from, + spender, + }; + + // Try to reuse cached signature for "upto" scheme + if (shouldCache && storage) { + const cached = await getPermitSignatureFromCache(storage, cacheParams); + + if (cached) { + // Validate deadline hasn't passed + const now = BigInt(Math.floor(Date.now() / 1000)); + if (BigInt(cached.deadline) > now) { + // Check on-chain allowance + const currentAllowance = await allowance({ + contract: getContract({ + address: paymentRequirements.asset, + chain: getCachedChain(chainId), + client, + }), + owner: from, + spender, + }); + + // Determine threshold - use minAmountRequired if present, else maxAmountRequired + const extra = paymentRequirements.extra as + | (ERC20TokenAmount["asset"]["eip712"] & { + minAmountRequired?: string; + }) + | undefined; + const threshold = extra?.minAmountRequired + ? BigInt(extra.minAmountRequired) + : BigInt(paymentRequirements.maxAmountRequired); + + // If allowance >= threshold, reuse signature + if (currentAllowance >= threshold) { + return cached.payload; + } + } + } + } + + // Generate new signature const nonce = await nonces({ contract: getContract({ address: paymentRequirements.asset, @@ -110,13 +168,27 @@ async function signPaymentHeader( unsignedPaymentHeader.payload.authorization, paymentRequirements, ); - return { + + const signedPayload: RequestedPaymentPayload = { ...unsignedPaymentHeader, payload: { ...unsignedPaymentHeader.payload, signature, }, }; + + // Cache the signature for "upto" scheme + if (shouldCache && storage) { + await savePermitSignatureToCache( + storage, + cacheParams, + signedPayload, + unsignedPaymentHeader.payload.authorization.validBefore, + paymentRequirements.maxAmountRequired, + ); + } + + return signedPayload; } case "TransferWithAuthorization": { // default to transfer with authorization @@ -153,6 +225,7 @@ async function signPaymentHeader( * @param client - The signer wallet instance used to create the payment header * @param x402Version - The version of the X402 protocol to use * @param paymentRequirements - The payment requirements containing scheme and network information + * @param storage - Optional storage for caching permit signatures (for "upto" scheme) * @returns A promise that resolves to the encoded payment header string */ export async function createPaymentHeader( @@ -160,12 +233,14 @@ export async function createPaymentHeader( account: Account, paymentRequirements: RequestedPaymentRequirements, x402Version: number, + storage?: AsyncStorage, ): Promise { const payment = await signPaymentHeader( client, account, paymentRequirements, x402Version, + storage, ); return encodePayment(payment); }