setup @thirdweb-dev/nexus package (#8332)

This commit is contained in:
Jonas Daniels
2025-10-29 15:55:12 -07:00
committed by GitHub
parent 6c318f83d6
commit 8e357b3cb3
24 changed files with 3009 additions and 1328 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@thirdweb-dev/nexus": minor
---
initial release
+5
View File
@@ -0,0 +1,5 @@
---
"thirdweb": patch
---
expose some useful erc20 extensions
+9 -2
View File
@@ -188,9 +188,16 @@ jobs:
- name: Build Packages
run: pnpm build
- name: Report bundle size
- name: Report bundle size (thirdweb)
uses: andresz1/size-limit-action@94bc357df29c36c8f8d50ea497c3e225c3c95d1d # v1.8.0
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
package_manager: pnpm
directory: packages/thirdweb
directory: packages/thirdweb
- name: Report bundle size (nexus)
uses: andresz1/size-limit-action@94bc357df29c36c8f8d50ea497c3e225c3c95d1d # v1.8.0
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
package_manager: pnpm
directory: packages/nexus
+13
View File
@@ -0,0 +1,13 @@
[
{
"import": "*",
"limit": "110 kB",
"name": "@thirdweb-dev/nexus (esm)",
"path": "./dist/esm/exports/nexus.js"
},
{
"limit": "350 kB",
"name": "@thirdweb-dev/nexus (cjs)",
"path": "./dist/cjs/exports/nexus.js"
}
]
+16
View File
@@ -0,0 +1,16 @@
{
"$schema": "https://biomejs.dev/schemas/2.0.6/schema.json",
"extends": "//",
"overrides": [
{
"assist": {
"actions": {
"source": {
"useSortedKeys": "off"
}
}
},
"includes": ["package.json"]
}
]
}
+12
View File
@@ -0,0 +1,12 @@
{
"$schema": "https://unpkg.com/knip@5/schema.json",
"entry": ["src/exports/**"],
"ignore": ["src/**/__generated__/**", "**/*.bench.ts"],
"ignoreBinaries": ["printf"],
"ignoreDependencies": ["tslib"],
"project": ["src/**/*.{ts,tsx}"],
"rules": {
"enumMembers": "off",
"optionalPeerDependencies": "off"
}
}
+79
View File
@@ -0,0 +1,79 @@
{
"author": "thirdweb eng <[email protected]>",
"browser": {
"crypto": false
},
"bugs": {
"url": "https://github.com/thirdweb-dev/js/issues"
},
"dependencies": {
"x402": "0.7.0",
"zod": "3.25.75"
},
"devDependencies": {
"@biomejs/biome": "2.0.6",
"@size-limit/preset-small-lib": "11.2.0",
"knip": "5.60.2",
"rimraf": "6.0.1",
"size-limit": "11.2.0",
"typescript": "5.8.3"
},
"engines": {
"node": ">=22"
},
"exports": {
".": {
"types": "./dist/types/exports/nexus.d.ts",
"import": "./dist/esm/exports/nexus.js",
"default": "./dist/cjs/exports/nexus.js"
}
},
"files": [
"dist/*",
"src/*",
"!**/*.tsbuildinfo",
"!**/*.test.ts",
"!**/*.test.tsx",
"!**/*.test.ts.snap",
"!**/*.test-d.ts",
"!**/*.bench.ts",
"!tsconfig.build.json"
],
"license": "Apache-2.0",
"main": "./dist/cjs/exports/nexus.js",
"module": "./dist/esm/exports/nexus.js",
"name": "@thirdweb-dev/nexus",
"peerDependencies": {
"typescript": ">=5.0.4"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
},
"repository": {
"type": "git",
"url": "git+https://github.com/thirdweb-dev/js.git#main"
},
"scripts": {
"build": "pnpm clean && pnpm build:types && pnpm build:cjs && pnpm build:esm",
"build:cjs": "tsc --noCheck --project ./tsconfig.build.json --module commonjs --outDir ./dist/cjs --verbatimModuleSyntax false && printf '{\"type\":\"commonjs\"}' > ./dist/cjs/package.json",
"build:esm": "tsc --noCheck --project ./tsconfig.build.json --module es2020 --outDir ./dist/esm && printf '{\"type\": \"module\",\"sideEffects\":false}' > ./dist/esm/package.json",
"build:types": "tsc --project ./tsconfig.build.json --module nodenext --moduleResolution nodenext --declarationDir ./dist/types --emitDeclarationOnly --declaration --declarationMap",
"clean": "rimraf dist",
"dev": "tsc --project ./tsconfig.build.json --module nodenext --moduleResolution nodenext --outDir ./dist/esm --watch",
"dev:cjs": "printf '{\"type\":\"commonjs\"}' > ./dist/cjs/package.json && tsc --noCheck --project ./tsconfig.build.json --module commonjs --outDir ./dist/cjs --verbatimModuleSyntax false --watch",
"dev:esm": "printf '{\"type\": \"module\",\"sideEffects\":false}' > ./dist/esm/package.json && tsc --noCheck --project ./tsconfig.build.json --module es2020 --outDir ./dist/esm --watch",
"fix": "biome check ./src --fix",
"format": "biome format ./src --write",
"knip": "knip",
"lint": "knip && biome check ./src && tsc --project ./tsconfig.build.json --module nodenext --moduleResolution nodenext --noEmit",
"size": "size-limit",
"typecheck": "tsc --project ./tsconfig.build.json --module nodenext --moduleResolution nodenext --noEmit"
},
"sideEffects": false,
"type": "module",
"types": "./dist/types/exports/nexus.d.ts",
"typings": "./dist/types/exports/nexus.d.ts",
"version": "0.0.0"
}
+320
View File
@@ -0,0 +1,320 @@
import { ChainIdToNetwork, type Money, moneySchema } from "x402/types";
import { decodePayment } from "./encode.js";
import type { ThirdwebX402Facilitator } from "./facilitator.js";
import {
networkToChainId,
type RequestedPaymentPayload,
type RequestedPaymentRequirements,
} from "./schemas.js";
import {
type DefaultAsset,
type ERC20TokenAmount,
type PaymentArgs,
type PaymentRequiredResult,
type SupportedSignatureType,
x402Version,
} from "./types.js";
import { toUnits } from "./utils.js";
type GetPaymentRequirementsResult = {
status: 200;
paymentRequirements: RequestedPaymentRequirements[];
selectedPaymentRequirements: RequestedPaymentRequirements;
decodedPayment: RequestedPaymentPayload;
};
/**
* Decodes a payment request and returns the payment requirements, selected payment requirements, and decoded payment
* @param args
* @returns The payment requirements, selected payment requirements, and decoded payment
*/
export async function decodePaymentRequest(
args: PaymentArgs,
): Promise<GetPaymentRequirementsResult | PaymentRequiredResult> {
const {
price,
network,
facilitator,
payTo,
resourceUrl,
routeConfig = {},
method,
paymentData,
} = args;
const {
description,
mimeType,
maxTimeoutSeconds,
inputSchema,
outputSchema,
errorMessages,
discoverable,
} = routeConfig;
let chainId: number;
try {
chainId = networkToChainId(network);
} catch (error) {
return {
status: 402,
responseHeaders: { "Content-Type": "application/json" },
responseBody: {
x402Version,
error:
error instanceof Error
? error.message
: `Invalid network: ${network}`,
accepts: [],
},
};
}
const atomicAmountForAsset = await processPriceToAtomicAmount(
price,
chainId,
facilitator,
);
if ("error" in atomicAmountForAsset) {
return {
status: 402,
responseHeaders: { "Content-Type": "application/json" },
responseBody: {
x402Version,
error: atomicAmountForAsset.error,
accepts: [],
},
};
}
const { maxAmountRequired, asset } = atomicAmountForAsset;
const paymentRequirements: RequestedPaymentRequirements[] = [];
const mappedNetwork = ChainIdToNetwork[chainId];
paymentRequirements.push({
scheme: "exact",
network: mappedNetwork ? mappedNetwork : `eip155:${chainId}`,
maxAmountRequired,
resource: resourceUrl,
description: description ?? "",
mimeType: mimeType ?? "application/json",
payTo: facilitator.address as `0x${string}`, // always pay to the facilitator address first
maxTimeoutSeconds: maxTimeoutSeconds ?? 86400,
asset: asset.address as `0x${string}`,
outputSchema: {
input: {
type: "http",
method,
discoverable: discoverable ?? true,
...inputSchema,
},
output: outputSchema,
},
extra: {
recipientAddress: payTo, // input payTo is the final recipient address
...((asset as ERC20TokenAmount["asset"]).eip712 ?? {}),
},
});
// Check for payment header
if (!paymentData) {
return {
status: 402,
responseHeaders: {
"Content-Type": "application/json",
},
responseBody: {
x402Version,
error: errorMessages?.paymentRequired || "X-PAYMENT header is required",
accepts: paymentRequirements,
},
};
}
// decode b64 payment
let decodedPayment: RequestedPaymentPayload;
try {
decodedPayment = decodePayment(paymentData);
decodedPayment.x402Version = x402Version;
} catch (error) {
return {
status: 402,
responseHeaders: {
"Content-Type": "application/json",
},
responseBody: {
x402Version,
error:
errorMessages?.invalidPayment ||
(error instanceof Error ? error.message : "Invalid payment"),
accepts: paymentRequirements,
},
};
}
const selectedPaymentRequirements = paymentRequirements.find(
(value) =>
value.scheme === decodedPayment.scheme &&
networkToChainId(value.network) ===
networkToChainId(decodedPayment.network),
);
if (!selectedPaymentRequirements) {
return {
status: 402,
responseHeaders: {
"Content-Type": "application/json",
},
responseBody: {
x402Version,
error:
errorMessages?.noMatchingRequirements ||
"Unable to find matching payment requirements",
accepts: paymentRequirements,
},
};
}
return {
status: 200,
paymentRequirements,
decodedPayment,
selectedPaymentRequirements,
};
}
/**
* Parses the amount from the given price
*
* @param price - The price to parse
* @param network - The network to get the default asset for
* @returns The parsed amount or an error message
*/
async function processPriceToAtomicAmount(
price: Money | ERC20TokenAmount,
chainId: number,
facilitator: ThirdwebX402Facilitator,
): Promise<
{ maxAmountRequired: string; asset: DefaultAsset } | { error: string }
> {
// Handle USDC amount (string) or token amount (ERC20TokenAmount)
let maxAmountRequired: string;
let asset: DefaultAsset;
if (typeof price === "string" || typeof price === "number") {
// USDC amount in dollars
const parsedAmount = moneySchema.safeParse(price);
if (!parsedAmount.success) {
return {
error: `Invalid price (price: ${price}). Must be in the form "$3.10", 0.10, "0.001", ${parsedAmount.error}`,
};
}
const parsedUsdAmount = parsedAmount.data;
const defaultAsset = await getDefaultAsset(chainId, facilitator);
if (!defaultAsset) {
return {
error: `Unable to get default asset on chain ${chainId}. Please specify an asset in the payment requirements.`,
};
}
asset = defaultAsset;
maxAmountRequired = toUnits(
parsedUsdAmount.toString(),
defaultAsset.decimals,
).toString();
} else {
// Token amount in atomic units
maxAmountRequired = price.amount;
const tokenExtras = await getOrDetectTokenExtras({
facilitator,
partialAsset: price.asset,
chainId,
});
if (!tokenExtras) {
return {
error: `Unable to find token information for ${price.asset.address} on chain ${chainId}. Please specify the asset decimals and eip712 information in the asset options.`,
};
}
asset = {
address: price.asset.address,
decimals: tokenExtras.decimals,
eip712: {
name: tokenExtras.name,
version: tokenExtras.version,
primaryType: tokenExtras.primaryType,
},
};
}
return {
maxAmountRequired,
asset,
};
}
async function getDefaultAsset(
chainId: number,
facilitator: ThirdwebX402Facilitator,
): Promise<DefaultAsset | undefined> {
const supportedAssets = await facilitator.supported();
const matchingAsset = supportedAssets.kinds.find(
(supported) => networkToChainId(supported.network) === chainId,
);
const assetConfig = matchingAsset?.extra?.defaultAsset as DefaultAsset;
return assetConfig;
}
async function getOrDetectTokenExtras(args: {
facilitator: ThirdwebX402Facilitator;
partialAsset: ERC20TokenAmount["asset"];
chainId: number;
}): Promise<
| {
name: string;
version: string;
decimals: number;
primaryType: SupportedSignatureType;
}
| undefined
> {
const { facilitator, partialAsset, chainId } = args;
if (
partialAsset.eip712?.name &&
partialAsset.eip712?.version &&
partialAsset.decimals !== undefined
) {
return {
name: partialAsset.eip712.name,
version: partialAsset.eip712.version,
decimals: partialAsset.decimals,
primaryType: partialAsset.eip712.primaryType,
};
}
// read from facilitator
const response = await facilitator
.supported({
chainId,
tokenAddress: partialAsset.address,
})
.catch(() => {
return {
kinds: [],
};
});
const exactScheme = response.kinds?.find((kind) => kind.scheme === "exact");
if (!exactScheme) {
return undefined;
}
const supportedAsset = exactScheme.extra?.supportedAssets?.find(
(asset) =>
asset.address.toLowerCase() === partialAsset.address.toLowerCase(),
);
if (!supportedAsset) {
return undefined;
}
return {
name: supportedAsset.eip712.name,
version: supportedAsset.eip712.version,
decimals: supportedAsset.decimals,
primaryType: supportedAsset.eip712.primaryType as SupportedSignatureType,
};
}
+81
View File
@@ -0,0 +1,81 @@
import type { ExactEvmPayload } from "x402/types";
import {
type RequestedPaymentPayload,
RequestedPaymentPayloadSchema,
} from "./schemas.js";
/**
* Encodes a payment payload into a base64 string, ensuring bigint values are properly stringified
*
* @param payment - The payment payload to encode
* @returns A base64 encoded string representation of the payment payload
*/
export function encodePayment(payment: RequestedPaymentPayload): string {
let safe: RequestedPaymentPayload;
// evm
const evmPayload = payment.payload as ExactEvmPayload;
safe = {
...payment,
payload: {
...evmPayload,
authorization: Object.fromEntries(
Object.entries(evmPayload.authorization).map(([key, value]) => [
key,
typeof value === "bigint" ? (value as bigint).toString() : value,
]),
) as ExactEvmPayload["authorization"],
},
};
return safeBase64Encode(JSON.stringify(safe));
}
/**
* Decodes a base64 encoded payment string back into a PaymentPayload object
*
* @param payment - The base64 encoded payment string to decode
* @returns The decoded and validated PaymentPayload object
*/
export function decodePayment(payment: string): RequestedPaymentPayload {
const decoded = safeBase64Decode(payment);
const parsed = JSON.parse(decoded);
const obj: RequestedPaymentPayload = {
...parsed,
payload: parsed.payload as ExactEvmPayload,
};
const validated = RequestedPaymentPayloadSchema.parse(obj);
return validated;
}
/**
* Encodes a string to base64 format
*
* @param data - The string to be encoded to base64
* @returns The base64 encoded string
*/
export function safeBase64Encode(data: string): string {
if (
typeof globalThis !== "undefined" &&
typeof globalThis.btoa === "function"
) {
return globalThis.btoa(data);
}
return Buffer.from(data).toString("base64");
}
/**
* Decodes a base64 string back to its original format
*
* @param data - The base64 encoded string to be decoded
* @returns The decoded string in UTF-8 format
*/
function safeBase64Decode(data: string): string {
if (
typeof globalThis !== "undefined" &&
typeof globalThis.atob === "function"
) {
return globalThis.atob(data);
}
return Buffer.from(data, "base64").toString("utf-8");
}
+24
View File
@@ -0,0 +1,24 @@
export type {
HTTPRequestStructure,
Money,
PaymentMiddlewareConfig,
Resource,
} from "x402/types";
export { decodePayment, encodePayment } from "../encode.js";
export {
createFacilitator,
type ThirdwebX402Facilitator,
type ThirdwebX402FacilitatorConfig,
type WaitUntil,
} from "../facilitator.js";
export { settlePayment } from "../settle-payment.js";
export type {
ERC20TokenAmount,
PaymentArgs,
PaymentRequiredResult,
SettlePaymentArgs,
SettlePaymentResult,
SupportedSignatureType,
VerifyPaymentResult,
} from "../types.js";
export { verifyPayment } from "../verify-payment.js";
+233
View File
@@ -0,0 +1,233 @@
import type { VerifyResponse } from "x402/types";
import type {
FacilitatorSettleResponse,
FacilitatorSupportedResponse,
FacilitatorVerifyResponse,
RequestedPaymentPayload,
RequestedPaymentRequirements,
} from "./schemas.js";
import { stringify } from "./utils.js";
export type WaitUntil = "simulated" | "submitted" | "confirmed";
export type ThirdwebX402FacilitatorConfig = {
walletSecret: string;
walletAddress: string;
waitUntil?: WaitUntil;
baseUrl?: string;
};
/**
* facilitator for the x402 payment protocol.
* @public
*/
export type ThirdwebX402Facilitator = {
url: `${string}://${string}`;
address: string;
createAuthHeaders: () => Promise<{
verify: Record<string, string>;
settle: Record<string, string>;
supported: Record<string, string>;
list: Record<string, string>;
}>;
verify: (
payload: RequestedPaymentPayload,
paymentRequirements: RequestedPaymentRequirements,
) => Promise<FacilitatorVerifyResponse>;
settle: (
payload: RequestedPaymentPayload,
paymentRequirements: RequestedPaymentRequirements,
waitUntil?: WaitUntil,
) => Promise<FacilitatorSettleResponse>;
supported: (filters?: {
chainId: number;
tokenAddress?: string;
}) => Promise<FacilitatorSupportedResponse>;
};
const DEFAULT_BASE_URL = "https://nexus-api.thirdweb.com";
/**
* Creates a facilitator for the x402 payment protocol.
* You can use this with `settlePayment` or with any x402 middleware to enable settling transactions with your thirdweb server wallet.
*
* @param config - The configuration for the facilitator
* @returns a x402 compatible FacilitatorConfig
*
* @example
* ```ts
* import { createFacilitator } from "@thirdweb-dev/nexus";
* import { paymentMiddleware } from 'x402-hono'
*
* const facilitator = createFacilitator({
* walletSecret: <your-wallet-secret>,
* walletAddress: <your-wallet-address>,
* });
*
* // add the facilitator to any x402 payment middleware
* const middleware = paymentMiddleware(
* facilitator.address,
* {
* "/api/paywall": {
* price: "$0.01",
* network: "base-sepolia",
* config: {
* description: "Access to paid content",
* },
* },
* },
* facilitator,
* );
* ```
*
* #### Configuration Options
*
* ```ts
* const thirdwebX402Facilitator = createFacilitator({
* walletSecret: <your-wallet-secret>,
* walletAddress: <your-wallet-address>,
* // Optional: Wait behavior for settlements
* // - "simulated": Only simulate the transaction (fastest)
* // - "submitted": Wait until transaction is submitted
* // - "confirmed": Wait for full on-chain confirmation (slowest, default)
* waitUntil: "confirmed",
* });
* ```
*
*/
export function createFacilitator(
config: ThirdwebX402FacilitatorConfig,
): ThirdwebX402Facilitator {
if (!config.walletSecret) {
throw new Error("Wallet secret is required for the x402 facilitator");
}
if (!config.walletAddress) {
throw new Error("Wallet address is required for the x402 facilitator");
}
const BASE_URL = config.baseUrl ?? DEFAULT_BASE_URL;
const AUTH_HEADERS = {
verify: {
authorization: `Bearer ${config.walletSecret}`,
},
settle: {
authorization: `Bearer ${config.walletSecret}`,
},
supported: {
authorization: `Bearer ${config.walletSecret}`,
},
list: {
authorization: `Bearer ${config.walletSecret}`,
},
} as const;
return {
url: BASE_URL as `${string}://${string}`,
address: config.walletAddress,
createAuthHeaders: async () => AUTH_HEADERS,
/**
* Verifies a payment payload with the facilitator service
*
* @param payload - The payment payload to verify
* @param paymentRequirements - The payment requirements to verify against
* @returns A promise that resolves to the verification response
*/
async verify(
payload: RequestedPaymentPayload,
paymentRequirements: RequestedPaymentRequirements,
): Promise<FacilitatorVerifyResponse> {
let headers = { "Content-Type": "application/json" };
headers = { ...headers, ...AUTH_HEADERS.verify };
const res = await fetch(new URL("/verify", BASE_URL), {
method: "POST",
headers,
body: stringify({
x402Version: payload.x402Version,
paymentPayload: payload,
paymentRequirements: paymentRequirements,
}),
});
if (res.status !== 200) {
const text = `${res.statusText} ${await res.text()}`;
throw new Error(`Failed to verify payment: ${res.status} ${text}`);
}
const data = await res.json();
return data as VerifyResponse;
},
/**
* Settles a payment with the facilitator service
*
* @param payload - The payment payload to settle
* @param paymentRequirements - The payment requirements for the settlement
* @returns A promise that resolves to the settlement response
*/
async settle(
payload: RequestedPaymentPayload,
paymentRequirements: RequestedPaymentRequirements,
waitUntil?: WaitUntil,
): Promise<FacilitatorSettleResponse> {
let headers = { "Content-Type": "application/json" };
headers = { ...headers, ...AUTH_HEADERS.settle };
const waitUntilParam = waitUntil || config.waitUntil;
const res = await fetch(new URL("/settle", BASE_URL), {
method: "POST",
headers,
body: stringify({
x402Version: payload.x402Version,
paymentPayload: payload,
paymentRequirements: paymentRequirements,
...(waitUntilParam ? { waitUntil: waitUntilParam } : {}),
}),
});
if (res.status !== 200) {
const text = `${res.statusText} ${await res.text()}`;
throw new Error(`Failed to settle payment: ${res.status} ${text}`);
}
const data = await res.json();
return data as FacilitatorSettleResponse;
},
/**
* Gets the supported payment kinds from the facilitator service.
*
* @returns A promise that resolves to the supported payment kinds
*/
async supported(filters?: {
chainId: number;
tokenAddress?: string;
}): Promise<FacilitatorSupportedResponse> {
const headers = {
"Content-Type": "application/json",
...AUTH_HEADERS.supported,
};
const supportedUrl = new URL("/supported", BASE_URL);
if (filters?.chainId) {
supportedUrl.searchParams.set("chainId", filters.chainId.toString());
}
if (filters?.tokenAddress) {
supportedUrl.searchParams.set("tokenAddress", filters.tokenAddress);
}
const res = await fetch(supportedUrl, { headers });
if (res.status !== 200) {
throw new Error(
`Failed to get supported payment kinds: ${res.statusText}`,
);
}
const data = await res.json();
return data as FacilitatorSupportedResponse;
},
} as const satisfies ThirdwebX402Facilitator;
}
+109
View File
@@ -0,0 +1,109 @@
import {
EvmNetworkToChainId,
type Network,
PaymentPayloadSchema,
PaymentRequirementsSchema,
SettleResponseSchema,
SupportedPaymentKindsResponseSchema,
VerifyResponseSchema,
} from "x402/types";
import { z } from "zod";
const FacilitatorNetworkSchema = z.string();
export type FacilitatorNetwork = z.infer<typeof FacilitatorNetworkSchema>;
export const RequestedPaymentPayloadSchema = PaymentPayloadSchema.extend({
network: FacilitatorNetworkSchema,
});
export type RequestedPaymentPayload = z.infer<
typeof RequestedPaymentPayloadSchema
>;
const RequestedPaymentRequirementsSchema = PaymentRequirementsSchema.extend({
network: FacilitatorNetworkSchema,
});
export type RequestedPaymentRequirements = z.infer<
typeof RequestedPaymentRequirementsSchema
>;
const FacilitatorSettleResponseSchema = SettleResponseSchema.extend({
network: FacilitatorNetworkSchema,
errorMessage: z.string().optional(),
});
export type FacilitatorSettleResponse = z.infer<
typeof FacilitatorSettleResponseSchema
>;
const FacilitatorVerifyResponseSchema = VerifyResponseSchema.extend({
errorMessage: z.string().optional(),
});
export type FacilitatorVerifyResponse = z.infer<
typeof FacilitatorVerifyResponseSchema
>;
export const SupportedSignatureTypeSchema = z.enum([
"TransferWithAuthorization",
"Permit",
]);
export const FacilitatorSupportedAssetSchema = z.object({
address: z.string(),
decimals: z.number(),
eip712: z.object({
name: z.string(),
version: z.string(),
primaryType: SupportedSignatureTypeSchema,
}),
});
const FacilitatorSupportedResponseSchema =
SupportedPaymentKindsResponseSchema.extend({
kinds: z.array(
z.object({
x402Version: z.literal(1),
scheme: z.literal("exact"),
network: FacilitatorNetworkSchema,
extra: z
.object({
defaultAsset: FacilitatorSupportedAssetSchema.optional(),
supportedAssets: z
.array(FacilitatorSupportedAssetSchema)
.optional(),
})
.optional(),
}),
),
}).describe("Supported payment kinds for this facilitator");
export type FacilitatorSupportedResponse = z.infer<
typeof FacilitatorSupportedResponseSchema
>;
export function networkToChainId(network: string): number {
if (network.startsWith("eip155:")) {
const chainId = parseInt(network.split(":")[1] ?? "0");
if (!Number.isNaN(chainId) && chainId > 0) {
return chainId;
} else {
throw new Error(`Invalid network: ${network}`);
}
}
// attempt to parse it as just an integer
const maybeChainId = parseInt(network);
if (!Number.isNaN(maybeChainId) && maybeChainId > 0) {
return maybeChainId;
}
const mappedChainId = EvmNetworkToChainId.get(network as Network);
if (!mappedChainId) {
throw new Error(`Invalid network: ${network}`);
}
// TODO (402): support solana networks
if (mappedChainId === 101 || mappedChainId === 103) {
throw new Error("Solana networks not supported yet.");
}
return mappedChainId;
}
+177
View File
@@ -0,0 +1,177 @@
import { decodePaymentRequest } from "./common.js";
import { safeBase64Encode } from "./encode.js";
import {
type SettlePaymentArgs,
type SettlePaymentResult,
x402Version,
} from "./types.js";
import { stringify } from "./utils.js";
/**
* Verifies and processes X402 payments for protected resources.
*
* This function implements the X402 payment protocol, verifying payment proofs
* and settling payments through a facilitator service. It handles the complete
* payment flow from validation to settlement.
*
* @param args - Configuration object containing payment verification parameters
* @returns A promise that resolves to either a successful payment result (200) or payment required error (402)
*
* @example
*
* ### Next.js API route example
*
* ```ts
* // Usage in a Next.js API route
* import { settlePayment, createFacilitator } from "@thirdweb-dev/nexus";
* import { arbitrumSepolia } from "thirdweb/chains";
*
* const facilitator = createFacilitator({
* walletSecret: <your-wallet-secret>,
* walletAddress: <your-wallet-address>,
* });
*
* export async function GET(request: Request) {
* const paymentData = request.headers.get("x-payment");
*
* // verify and process the payment
* const result = await settlePayment({
* resourceUrl: "https://api.example.com/premium-content",
* method: "GET",
* paymentData,
* network: arbitrumSepolia, // or any other chain
* price: "$0.10", // or { amount: "100000", asset: { address: "0x...", decimals: 6 } }
* facilitator,
* routeConfig: {
* description: "Access to premium API content",
* mimeType: "application/json",
* maxTimeoutSeconds: 300,
* },
* });
*
* if (result.status === 200) {
* // Payment verified and settled successfully
* return Response.json({ data: "premium content" });
* } else {
* // Payment required
* return Response.json(result.responseBody, {
* status: result.status,
* headers: result.responseHeaders,
* });
* }
* }
* ```
*
* ### Express middleware example
*
* ```ts
* // Usage in Express middleware
* import express from "express";
* import { settlePayment, createFacilitator } from "@thirdweb-dev/nexus";
* import { arbitrumSepolia } from "thirdweb/chains";
*
* const facilitator = createFacilitator({
* walletSecret: <your-wallet-secret>,
* walletAddress: <your-wallet-address>,
* });
*
* const app = express();
*
* async function paymentMiddleware(req, res, next) {
* // verify and process the payment
* const result = await settlePayment({
* resourceUrl: `${req.protocol}://${req.get('host')}${req.originalUrl}`,
* method: req.method,
* paymentData: req.headers["x-payment"],
* network: arbitrumSepolia, // or any other chain
* price: "$0.05",
* waitUntil: "submitted",
* facilitator,
* });
*
* if (result.status === 200) {
* // Set payment receipt headers and continue
* Object.entries(result.responseHeaders).forEach(([key, value]) => {
* res.setHeader(key, value);
* });
* next();
* } else {
* // Return payment required response
* res.status(result.status)
* .set(result.responseHeaders)
* .json(result.responseBody);
* }
* }
*
* app.get("/api/premium", paymentMiddleware, (req, res) => {
* res.json({ message: "This is premium content!" });
* });
* ```
*
* @public
* @beta
*/
export async function settlePayment(
args: SettlePaymentArgs,
): Promise<SettlePaymentResult> {
const { routeConfig = {}, facilitator } = args;
const { errorMessages } = routeConfig;
const decodePaymentResult = await decodePaymentRequest(args);
if (decodePaymentResult.status !== 200) {
return decodePaymentResult;
}
const { selectedPaymentRequirements, decodedPayment, paymentRequirements } =
decodePaymentResult;
try {
const settlement = await facilitator.settle(
decodedPayment,
selectedPaymentRequirements,
args.waitUntil,
);
if (settlement.success) {
return {
status: 200,
paymentReceipt: settlement,
responseHeaders: {
"Access-Control-Expose-Headers": "X-PAYMENT-RESPONSE",
"X-PAYMENT-RESPONSE": safeBase64Encode(stringify(settlement)),
},
};
} else {
const error = settlement.errorReason || "Settlement error";
return {
status: 402,
responseHeaders: {
"Content-Type": "application/json",
},
responseBody: {
x402Version,
error,
errorMessage:
errorMessages?.settlementFailed || settlement.errorMessage,
accepts: paymentRequirements,
},
};
}
} catch (error) {
return {
status: 402,
responseHeaders: {
"Content-Type": "application/json",
},
responseBody: {
x402Version,
error: "Settlement error",
errorMessage:
errorMessages?.settlementFailed ||
(error instanceof Error ? error.message : undefined),
accepts: paymentRequirements,
},
};
}
}
+110
View File
@@ -0,0 +1,110 @@
import type { Money, PaymentMiddlewareConfig } from "x402/types";
import type z from "zod";
import type { ThirdwebX402Facilitator, WaitUntil } from "./facilitator.js";
import type {
FacilitatorNetwork,
FacilitatorSettleResponse,
FacilitatorSupportedAssetSchema,
RequestedPaymentPayload,
RequestedPaymentRequirements,
SupportedSignatureTypeSchema,
} from "./schemas.js";
export const x402Version = 1;
/**
* Configuration object for verifying or processing X402 payments.
*
* @public
*/
export type PaymentArgs = {
/** The URL of the resource being protected by the payment */
resourceUrl: string;
/** The HTTP method used to access the resource */
method: "GET" | "POST" | ({} & string);
/** The payment data/proof provided by the client, typically from the X-PAYMENT header */
paymentData?: string | null;
/** The blockchain network where the payment should be processed */
network: FacilitatorNetwork;
/** The price for accessing the resource - either a USD amount (e.g., "$0.10") or a specific token amount */
price: Money | ERC20TokenAmount;
/** The payment facilitator instance used to verify and settle payments */
facilitator: ThirdwebX402Facilitator;
/** Optional configuration for the payment middleware route */
routeConfig?: PaymentMiddlewareConfig;
/** Optional recipient address to receive the payment if different from your facilitator address */
payTo?: string;
};
export type SettlePaymentArgs = PaymentArgs & {
waitUntil?: WaitUntil;
};
export type PaymentRequiredResult = {
/** HTTP 402 - Payment Required, verification or processing failed or payment missing */
status: 402;
/** The error response body containing payment requirements */
responseBody: {
/** The X402 protocol version */
x402Version: number;
/** error code */
error: string;
/** Human-readable error message */
errorMessage?: string;
/** Array of acceptable payment methods and requirements */
accepts: RequestedPaymentRequirements[];
/** Optional payer address if verification partially succeeded */
payer?: string;
};
/** Response headers for the error response */
responseHeaders: Record<string, string>;
};
/**
* The result of a payment settlement operation.
*
* @public
*/
export type SettlePaymentResult =
| {
/** HTTP 200 - Payment was successfully processed */
status: 200;
/** Response headers including payment receipt information */
responseHeaders: Record<string, string>;
/** The payment receipt from the payment facilitator */
paymentReceipt: FacilitatorSettleResponse;
}
| PaymentRequiredResult;
/**
* The result of a payment verification operation.
*
* @public
*/
export type VerifyPaymentResult =
| {
/** HTTP 200 - Payment was successfully verified */
status: 200;
decodedPayment: RequestedPaymentPayload;
selectedPaymentRequirements: RequestedPaymentRequirements;
}
| PaymentRequiredResult;
export type SupportedSignatureType = z.infer<
typeof SupportedSignatureTypeSchema
>;
export type ERC20TokenAmount = {
amount: string;
asset: {
address: `0x${string}`;
decimals?: number;
eip712?: {
name: string;
version: string;
primaryType: SupportedSignatureType;
};
};
};
export type DefaultAsset = z.infer<typeof FacilitatorSupportedAssetSchema>;
+93
View File
@@ -0,0 +1,93 @@
/**
* Stringify a JSON object and convert all bigint values to string
*
* If you are getting this error: "Exception: Do not know how to serialize a BigInt",
* you probably can use this function to parse the data.
* Because bigint is not an accepted value of the JSON format.
*
* @returns An object with all bigint values converted to string
* @example
* ```ts
* import { stringify } from "thirdweb/utils";
* const obj = { tokenId: 0n };
* const str = stringify(obj); // "{"tokenId":"0"}"
* ```
* @utils
*/
export function stringify(
// biome-ignore lint/suspicious/noExplicitAny: JSON.stringify signature
value: any,
// biome-ignore lint/suspicious/noExplicitAny: JSON.stringify signature
replacer?: ((this: any, key: string, value: any) => any) | null,
space?: string | number,
) {
const res = JSON.stringify(
value,
(key, value_) => {
const value__ = typeof value_ === "bigint" ? value_.toString() : value_;
return typeof replacer === "function" ? replacer(key, value__) : value__;
},
space,
);
return res;
}
/**
* Converts a string representation of a number with decimal places to a BigInt representation.
* @param tokens - The string representation of the number, including the integer and fraction parts.
* @param decimals - The number of decimal places to include in the BigInt representation.
* @returns The BigInt representation of the number.
* @example
* ```ts
* import { toUnits } from "thirdweb/utils";
* toUnits('1', 18)
* // 1000000000000000000n
* ```
* @utils
*/
export function toUnits(tokens: string, decimals: number): bigint {
if (tokens.includes("e")) {
tokens = Number(tokens).toFixed(decimals);
}
let [integerPart, fractionPart = ""] = tokens.split(".") as [string, string];
const prefix = integerPart.startsWith("-") ? "-" : "";
if (prefix) {
integerPart = integerPart.slice(1);
}
fractionPart = fractionPart.padEnd(decimals, "0"); // Ensure fraction part is at least 'decimals' long.
if (decimals === 0) {
// Check if there's any fraction part that would necessitate rounding up the integer part.
if (fractionPart[0] && Number.parseInt(fractionPart[0]) >= 5) {
integerPart = (BigInt(integerPart) + 1n).toString();
}
fractionPart = ""; // No fraction part is needed when decimals === 0.
} else {
// When decimals > 0, handle potential rounding based on the digit right after the specified decimal places.
if (fractionPart.length > decimals) {
const roundingDigit = fractionPart[decimals];
if (roundingDigit && Number.parseInt(roundingDigit, 10) >= 5) {
// If rounding is needed, add 1 to the last included digit of the fraction part.
const roundedFraction =
BigInt(fractionPart.substring(0, decimals)) + 1n;
fractionPart = roundedFraction.toString().padStart(decimals, "0");
if (fractionPart.length > decimals) {
// If rounding the fraction results in a length increase (e.g., .999 -> 1.000), increment the integer part.
integerPart = (BigInt(integerPart) + 1n).toString();
// Adjust the fraction part if it's longer than the specified decimals due to rounding up.
fractionPart = fractionPart.substring(fractionPart.length - decimals);
}
} else {
// If no rounding is necessary, just truncate the fraction part to the specified number of decimals.
fractionPart = fractionPart.substring(0, decimals);
}
}
// If the fraction part is shorter than the specified decimals, it's already handled by padEnd() above.
}
// Combine the integer and fraction parts into the final BigInt representation.
return BigInt(`${prefix}${integerPart}${fractionPart}`);
}
+128
View File
@@ -0,0 +1,128 @@
import { decodePaymentRequest } from "./common.js";
import {
type PaymentArgs,
type VerifyPaymentResult,
x402Version,
} from "./types.js";
/**
* Verifies X402 payments for protected resources. This function only verifies the payment,
* you should use `settlePayment` to settle the payment.
*
* @param args - Configuration object containing payment verification parameters
* @returns A promise that resolves to either a successful verification result (200) or payment required error (402)
*
* @example
* ```ts
* // Usage in a Next.js API route
* import { verifyPayment, createFacilitator } from "@thirdweb-dev/nexus";
* import { arbitrumSepolia } from "thirdweb/chains";
*
* const facilitator = createFacilitator({
* walletSecret: <your-wallet-secret>,
* walletAddress: <your-wallet-address>,
* });
*
* export async function GET(request: Request) {
* const paymentData = request.headers.get("x-payment");
*
* const paymentArgs = {
* resourceUrl: "https://api.example.com/premium-content",
* method: "GET",
* paymentData,
* network: arbitrumSepolia, // or any other chain
* price: "$0.10", // or { amount: "100000", asset: { address: "0x...", decimals: 6 } }
* facilitator,
* routeConfig: {
* description: "Access to premium API content",
* mimeType: "application/json",
* maxTimeoutSeconds: 300,
* },
* };
*
* // verify the payment
* const result = await verifyPayment(paymentArgs);
*
* if (result.status === 200) {
* // Payment verified, but not settled yet
* // you can do the work that requires payment first
* const result = await doSomething();
* // then settle the payment
* const settleResult = await settlePayment(paymentArgs);
*
* // then return the result
* return Response.json(result);
* } else {
* // verification failed, return payment required
* return Response.json(result.responseBody, {
* status: result.status,
* headers: result.responseHeaders,
* });
* }
* }
* ```
*
* @public
* @beta
*/
export async function verifyPayment(
args: PaymentArgs,
): Promise<VerifyPaymentResult> {
const { routeConfig = {}, facilitator } = args;
const { errorMessages } = routeConfig;
const decodePaymentResult = await decodePaymentRequest(args);
if (decodePaymentResult.status !== 200) {
return decodePaymentResult;
}
const { selectedPaymentRequirements, decodedPayment, paymentRequirements } =
decodePaymentResult;
// Verify payment
try {
const verification = await facilitator.verify(
decodedPayment,
selectedPaymentRequirements,
);
if (verification.isValid) {
return {
status: 200,
decodedPayment,
selectedPaymentRequirements,
};
} else {
const error = verification.invalidReason || "Verification failed";
return {
status: 402,
responseHeaders: {
"Content-Type": "application/json",
},
responseBody: {
x402Version,
error: error,
errorMessage:
errorMessages?.verificationFailed || verification.errorMessage,
accepts: paymentRequirements,
},
};
}
} catch (error) {
return {
status: 402,
responseHeaders: {
"Content-Type": "application/json",
},
responseBody: {
x402Version,
error: "Verification error",
errorMessage:
errorMessages?.verificationFailed ||
(error instanceof Error ? error.message : undefined),
accepts: paymentRequirements,
},
};
}
}
+48
View File
@@ -0,0 +1,48 @@
{
// This tsconfig file contains the shared config for the build (tsconfig.build.json) and type checking (tsconfig.json) config.
"compilerOptions": {
// Incremental builds
// NOTE: Enabling incremental builds speeds up `tsc`. Keep in mind though that it does not reliably bust the cache when the `tsconfig.json` file changes.
"allowJs": false,
"allowSyntheticDefaultImports": true,
"checkJs": false,
// Interop constraints
"esModuleInterop": false,
"exactOptionalPropertyTypes": false,
"forceConsistentCasingInFileNames": true,
"importHelpers": true,
// Incremental builds
// NOTE: Enabling incremental builds speeds up `tsc`. Keep in mind though that it does not reliably bust the cache when the `tsconfig.json` file changes.
"incremental": false,
// jsx for "/react" portion
"jsx": "react-jsx",
"lib": [
"ES2022", // By using ES2022 we get access to the `.cause` property on `Error` instances.
"DOM" // We are adding `DOM` here to get the `fetch`, etc. types. This should be removed once these types are available via DefinitelyTyped.
],
"module": "NodeNext",
// Language and environment
"moduleResolution": "NodeNext",
"noFallthroughCasesInSwitch": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noUncheckedIndexedAccess": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
// Skip type checking for node modules
"skipLibCheck": true,
// Type checking
"strict": true,
"target": "ES2021",
"useDefineForClassFields": true,
"useUnknownInCatchVariables": true,
"verbatimModuleSyntax": true
},
// This tsconfig file contains the shared config for the build (tsconfig.build.json) and type checking (tsconfig.json) config.
"include": []
}
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"moduleResolution": "node",
"rootDir": "./src",
"sourceMap": true
},
"exclude": [
"src/**/*.test.ts",
"src/**/*.test.tsx",
"src/**/*.test-d.ts",
"src/**/*.bench.ts",
"src/**/*.macro.ts"
],
"extends": "./tsconfig.base.json",
"include": ["src"]
}
+13
View File
@@ -0,0 +1,13 @@
{
// This configuration is used for local development and type checking.
"compilerOptions": {
"baseUrl": ".",
"outDir": "./dist",
"paths": {
"~test/*": ["./test/src/*"]
}
},
"exclude": [],
"extends": "./tsconfig.base.json",
"include": ["src", "test"]
}
+1 -1
View File
@@ -6,7 +6,7 @@
"path": "./dist/esm/exports/thirdweb.js"
},
{
"limit": "375 kB",
"limit": "380 kB",
"name": "thirdweb (cjs)",
"path": "./dist/cjs/exports/thirdweb.js"
},
@@ -1,3 +1,5 @@
export type { Abi } from "abitype";
export { formatCompilerMetadata } from "../contract/actions/compiler-metadata.js";
export { getBytecode } from "../contract/actions/get-bytecode.js";
export { getCompilerMetadata } from "../contract/actions/get-compiler-metadata.js";
@@ -17,10 +17,6 @@ export {
getActiveClaimConditionId,
isGetActiveClaimConditionIdSupported,
} from "../../extensions/erc20/__generated__/IDropERC20/read/getActiveClaimConditionId.js";
/**
* DROPS extension for ERC20
*/
// READ
export {
getClaimConditionById,
isGetClaimConditionByIdSupported,
@@ -43,6 +39,15 @@ export {
balanceOf,
} from "../../extensions/erc20/__generated__/IERC20/read/balanceOf.js";
export { totalSupply } from "../../extensions/erc20/__generated__/IERC20/read/totalSupply.js";
/**
* PERMIT extension for ERC20
*/
export { nonces } from "../../extensions/erc20/__generated__/IERC20Permit/read/nonces.js";
export { isPermitSupported } from "../../extensions/erc20/__generated__/IERC20Permit/write/permit.js";
/**
* DROPS extension for ERC20
*/
// READ
export {
type TokensMintedEventFilters,
tokensMintedEvent,
@@ -63,6 +68,7 @@ export {
type WithdrawParams,
withdraw,
} from "../../extensions/erc20/__generated__/IWETH/write/withdraw.js";
export { isTransferWithAuthorizationSupported } from "../../extensions/erc20/__generated__/USDC/write/transferWithAuthorization.js";
export {
type CanClaimParams,
type CanClaimResult,
+1504 -1320
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -96,7 +96,7 @@
"thirdweb-dashboard#dev": {
"dependsOn": ["^build"]
},
"thirdweb#update-version": {
"update-version": {
"inputs": ["$TURBO_DEFAULT$", "package.json"],
"outputs": ["src/version.ts"]
},