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
242 changes: 14 additions & 228 deletions packages/thirdweb/src/x402/common.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,18 @@
import type { Abi } from "abitype";
import { toFunctionSelector } from "viem/utils";
import { ChainIdToNetwork, type Money, moneySchema } from "x402/types";
import { getCachedChain } from "../chains/utils.js";
import type { ThirdwebClient } from "../client/client.js";
import { resolveContractAbi } from "../contract/actions/resolve-abi.js";
import { getContract } from "../contract/contract.js";
import { isPermitSupported } from "../extensions/erc20/__generated__/IERC20Permit/write/permit.js";
import { isTransferWithAuthorizationSupported } from "../extensions/erc20/__generated__/USDC/write/transferWithAuthorization.js";
import { getAddress } from "../utils/address.js";
import { toUnits } from "../utils/units.js";
import { decodePayment } from "./encode.js";
import type { ThirdwebX402Facilitator } from "./facilitator.js";
import {
networkToChainId,
networkToCaip2ChainId,
type RequestedPaymentPayload,
type RequestedPaymentRequirements,
} from "./schemas.js";
import {
type DefaultAsset,
type ERC20TokenAmount,
type PaymentArgs,
type PaymentRequiredResult,
Expand Down Expand Up @@ -50,95 +45,24 @@ export async function decodePaymentRequest(
method,
paymentData,
} = args;
const {
description,
mimeType,
maxTimeoutSeconds,
inputSchema,
outputSchema,
errorMessages,
discoverable,
} = routeConfig;
const { errorMessages } = 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(
const paymentRequirementsResult = await facilitator.accepts({
resourceUrl,
method,
network,
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: getAddress(facilitator.address), // always pay to the facilitator address first
maxTimeoutSeconds: maxTimeoutSeconds ?? 86400,
asset: getAddress(asset.address),
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 ?? {}),
},
routeConfig,
payTo,
});

// Check for payment header
// Check for payment header, if none, return the payment requirements
if (!paymentData) {
return {
status: 402,
responseHeaders: {
"Content-Type": "application/json",
},
responseBody: {
x402Version,
error: errorMessages?.paymentRequired || "X-PAYMENT header is required",
accepts: paymentRequirements,
},
};
return paymentRequirementsResult;
}

const paymentRequirements = paymentRequirementsResult.responseBody.accepts;

// decode b64 payment
let decodedPayment: RequestedPaymentPayload;
try {
Expand All @@ -163,8 +87,8 @@ export async function decodePaymentRequest(
const selectedPaymentRequirements = paymentRequirements.find(
(value) =>
value.scheme === decodedPayment.scheme &&
networkToChainId(value.network) ===
networkToChainId(decodedPayment.network),
networkToCaip2ChainId(value.network) ===
networkToCaip2ChainId(decodedPayment.network),
);
Comment on lines 87 to 92
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

Prevent invalid network strings from crashing decodePaymentRequest.
Right now networkToCaip2ChainId(decodedPayment.network) can throw (for example when a client tampers with the payment header and sends an unknown slug). That ZodError bubbles out of decodePaymentRequest, turning what should be a 402 “invalid payment” response into an unhandled 500. Please normalize the decoded network inside a try/catch and short-circuit with the existing 402 pathway before attempting the find, so malformed input can’t take down the handler.

@@
-  const selectedPaymentRequirements = paymentRequirements.find(
-    (value) =>
-      value.scheme === decodedPayment.scheme &&
-      networkToCaip2ChainId(value.network) ===
-        networkToCaip2ChainId(decodedPayment.network),
-  );
+  let decodedNetwork: string;
+  try {
+    decodedNetwork = networkToCaip2ChainId(decodedPayment.network);
+  } 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) => {
+    try {
+      return (
+        value.scheme === decodedPayment.scheme &&
+        networkToCaip2ChainId(value.network) === decodedNetwork
+      );
+    } catch {
+      return false;
+    }
+  });
🤖 Prompt for AI Agents
In packages/thirdweb/src/x402/common.ts around lines 87 to 92, calling
networkToCaip2ChainId(decodedPayment.network) can throw on malformed/unknown
network strings and currently bubbles up; wrap the normalization of
decodedPayment.network in a try/catch before running the
paymentRequirements.find so that any error from networkToCaip2ChainId is caught
and you short-circuit to the existing 402 "invalid payment" path (e.g., treat
the decoded network as invalid, do not perform the find, and return or set
selectedPaymentRequirements to undefined so the existing 402 handling runs).

if (!selectedPaymentRequirements) {
return {
Expand All @@ -190,86 +114,6 @@ export async function decodePaymentRequest(
};
}

/**
* 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;
}

export async function getSupportedSignatureType(args: {
client: ThirdwebClient;
asset: string;
Expand Down Expand Up @@ -309,61 +153,3 @@ export async function getSupportedSignatureType(args: {
}
return undefined;
}

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,
};
}
53 changes: 47 additions & 6 deletions packages/thirdweb/src/x402/facilitator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ import type { VerifyResponse } from "x402/types";
import type { ThirdwebClient } from "../client/client.js";
import { stringify } from "../utils/json.js";
import { withCache } from "../utils/promise/withCache.js";
import type {
FacilitatorSettleResponse,
FacilitatorSupportedResponse,
FacilitatorVerifyResponse,
RequestedPaymentPayload,
RequestedPaymentRequirements,
import {
type FacilitatorSettleResponse,
type FacilitatorSupportedResponse,
type FacilitatorVerifyResponse,
networkToCaip2ChainId,
type RequestedPaymentPayload,
type RequestedPaymentRequirements,
} from "./schemas.js";
import type { PaymentArgs, PaymentRequiredResult } from "./types.js";

export type WaitUntil = "simulated" | "submitted" | "confirmed";

Expand Down Expand Up @@ -46,6 +48,9 @@ export type ThirdwebX402Facilitator = {
chainId: number;
tokenAddress?: string;
}) => Promise<FacilitatorSupportedResponse>;
accepts: (
args: Omit<PaymentArgs, "facilitator">,
) => Promise<PaymentRequiredResult>;
};

const DEFAULT_BASE_URL = "https://api.thirdweb.com/v1/payments/x402";
Expand Down Expand Up @@ -256,6 +261,42 @@ export function facilitator(
},
);
},

async accepts(
args: Omit<PaymentArgs, "facilitator">,
): Promise<PaymentRequiredResult> {
const url = config.baseUrl ?? DEFAULT_BASE_URL;
let headers = { "Content-Type": "application/json" };
const authHeaders = await facilitator.createAuthHeaders();
headers = { ...headers, ...authHeaders.verify }; // same as verify
const caip2ChainId = networkToCaip2ChainId(args.network);
const res = await fetch(`${url}/accepts`, {
method: "POST",
headers,
body: stringify({
resourceUrl: args.resourceUrl,
method: args.method,
network: caip2ChainId,
price: args.price,
routeConfig: args.routeConfig,
serverWalletAddress: facilitator.address,
recipientAddress: args.payTo,
}),
});
if (res.status !== 402) {
throw new Error(
`Failed to construct payment requirements: ${res.statusText} - ${await res.text()}`,
);
}
return {
status: res.status as 402,
responseBody:
(await res.json()) as PaymentRequiredResult["responseBody"],
responseHeaders: {
"Content-Type": "application/json",
},
};
},
};

return facilitator;
Expand Down
Loading
Loading