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/warm-clouds-judge.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"thirdweb": patch
---

Automatically store and re-use permit x402 signatures for upto schemes
25 changes: 1 addition & 24 deletions apps/portal/src/app/x402/server/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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: {
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import type { ThirdwebClient } from "../../../../client/client.js";
import { nativeLocalStorage } from "../../../../utils/storage/nativeStorage.js";
import {
type UseFetchWithPaymentOptions,
useFetchWithPaymentCore,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
});
}
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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,
Comment on lines +259 to 271
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Fix spreading of possibly-undefined options (runtime TypeError risk)

options is optional, but this block:

const resolvedOptions = useMemo(
  () => ({
    ...options,
    storage: options?.storage ?? webLocalStorage,
  }),
  [options],
);

will throw at runtime when options is undefined because object spread cannot spread undefined.

Use a safe fallback object when options is not provided:

-  const resolvedOptions = useMemo(
-    () => ({
-      ...options,
-      storage: options?.storage ?? webLocalStorage,
-    }),
-    [options],
-  );
+  const resolvedOptions = useMemo(
+    () => ({
+      ...(options ?? {}),
+      storage: options?.storage ?? webLocalStorage,
+    }),
+    [options],
+  );

Additionally, consider updating the hook JSDoc above to document the new options.storage parameter and its default (webLocalStorage), for parity with the native hook docs.

🤖 Prompt for AI Agents
In packages/thirdweb/src/react/web/hooks/x402/useFetchWithPayment.tsx around
lines 259 to 271, the code spreads the possibly-undefined options object which
will throw if options is undefined; change the memo to spread a safe fallback
(e.g., options ?? {}) and set storage with options?.storage ?? webLocalStorage
so the spread never receives undefined, then pass that resolvedOptions into
useFetchWithPaymentCore; also update the hook JSDoc above to document the
options.storage parameter and its default (webLocalStorage).

showConnectModal,
);
Expand Down
21 changes: 21 additions & 0 deletions packages/thirdweb/src/x402/fetchWithPayment.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -131,6 +140,7 @@ export function wrapFetchWithPayment(
account,
selectedPaymentRequirements,
x402Version,
options?.storage ?? webLocalStorage,
);

const initParams = init || {};
Expand All @@ -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;
};
}
Expand Down
99 changes: 99 additions & 0 deletions packages/thirdweb/src/x402/permitSignatureStorage.ts
Original file line number Diff line number Diff line change
@@ -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<CachedPermitSignature | null> {
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<void> {
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<void> {
try {
const key = getPermitCacheKey(params);
await storage.removeItem(key);
} catch {
// Silently fail
}
}
Loading
Loading