Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { createApprovedPaymentSelector, createCappedPaymentSelector } from "./utils";

const BASE_APPROVED = {
scheme: "exact",
network: "eip155:84532",
asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
maxAmountRequired: "10000", // 0.01 USDC
amount: null,
price: null,
payTo: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8",
};

const BASE_REQ = {
scheme: "exact",
network: "eip155:84532",
asset: "0x036cbd53842c5426634e7929541ec2318f3dcf7e", // lowercase on purpose
amount: "10000",
payTo: "0x70997970c51812dc3a010c7d01b50e0d17dc79c8",
};

describe("createApprovedPaymentSelector", () => {
it("selects the requirement that matches the approved option", () => {
const selector = createApprovedPaymentSelector(BASE_APPROVED);
const chosen = selector(2, [BASE_REQ]);
expect(chosen).toBe(BASE_REQ);
});

it("refuses to sign when the retry inflates the amount", () => {
const selector = createApprovedPaymentSelector(BASE_APPROVED);
const inflated = { ...BASE_REQ, amount: "100000000" }; // 100 USDC
expect(() => selector(2, [inflated])).toThrow(/do not match the approved/);
});

it("refuses to sign when the retry swaps the recipient", () => {
const selector = createApprovedPaymentSelector(BASE_APPROVED);
const swapped = { ...BASE_REQ, payTo: "0x000000000000000000000000000000000000dEaD" };
expect(() => selector(2, [swapped])).toThrow(/do not match the approved/);
});

it("refuses when no requirement matches the approved network", () => {
const selector = createApprovedPaymentSelector(BASE_APPROVED);
const other = { ...BASE_REQ, network: "eip155:1" };
expect(() => selector(2, [other])).toThrow(/do not match the approved/);
});

it("supports the v2 price field for the approved amount", () => {
const selector = createApprovedPaymentSelector({
...BASE_APPROVED,
maxAmountRequired: null,
price: "$0.01",
});
expect(selector(2, [BASE_REQ])).toBe(BASE_REQ);
expect(() => selector(2, [{ ...BASE_REQ, amount: "10001" }])).toThrow(
/do not match the approved/,
);
});
});

describe("createCappedPaymentSelector", () => {
it("accepts requirements within the configured limit", () => {
const selector = createCappedPaymentSelector(0.5);
const req = { ...BASE_REQ, amount: "500000" }; // 0.5 USDC
expect(selector(2, [req])).toBe(req);
});

it("refuses requirements above the configured limit", () => {
const selector = createCappedPaymentSelector(0.5);
const req = { ...BASE_REQ, amount: "500001" };
expect(() => selector(2, [req])).toThrow(/exceed the configured spending limit/);
});
});
91 changes: 91 additions & 0 deletions typescript/agentkit/src/action-providers/x402/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -722,3 +722,94 @@ export function validateFacilitator(

return { isAllowed: false, resolvedUrl: facilitator };
}

/**
* Subset of the user-approved x402 payment option used to bind the payment
* that actually gets signed to the option that was presented and approved.
*/
export interface ApprovedPaymentOption {
scheme?: string | null;
network: string;
asset: string;
maxAmountRequired?: string | null;
amount?: string | null;
price?: string | null;
payTo?: string | null;
}

/** Structural view of the x402 payment requirements the selector receives. */
interface PaymentRequirementLike {
scheme?: string;
network: string;
asset: string;
amount?: string;
maxAmountRequired?: string;
payTo?: string;
}

const USDC_DECIMALS = 6;

/**
* Resolves an approved option to an atomic USDC amount.
*
* @param option - The approved payment option
* @returns Atomic amount as bigint
*/
function approvedAtomicAmount(option: ApprovedPaymentOption): bigint {
const raw = option.maxAmountRequired ?? option.amount;
if (raw) return BigInt(raw);
if (option.price) return parseUnits(option.price.replace(/^\$/, "").trim(), USDC_DECIMALS);
return 0n;
}

/**
* Builds a payment-requirements selector that only accepts a requirement
* matching the approved option (network, asset, payTo when present) at an
* amount not exceeding the approved amount. Without this binding, the payment
* wrapper signs whatever the server returns on the retry's 402 response, so a
* service could present a cheap option and then demand a larger amount.
*
* @param approved - The payment option the user approved
* @returns Selector compatible with the x402Client constructor
*/
export function createApprovedPaymentSelector(approved: ApprovedPaymentOption) {
const approvedAmount = approvedAtomicAmount(approved);
return <T extends PaymentRequirementLike>(_x402Version: number, accepts: T[]): T => {
const match = accepts.find(
req =>
req.network === approved.network &&
req.asset.toLowerCase() === approved.asset.toLowerCase() &&
(!approved.scheme || !req.scheme || req.scheme === approved.scheme) &&
(!approved.payTo || (req.payTo ?? "").toLowerCase() === approved.payTo.toLowerCase()) &&
BigInt(req.maxAmountRequired ?? req.amount ?? "0") <= approvedAmount,
);
if (!match) {
throw new Error(
"x402 payment requirements returned by the server do not match the approved " +
`payment option (network/asset/payTo) or exceed the approved amount. Refusing to sign.`,
);
}
return match;
};
}

/**
* Builds a selector that caps automatically-signed payments at the configured
* USDC limit, for the direct (no user confirmation) request path.
*
* @param maxPaymentUsdc - Maximum USDC amount allowed per payment
* @returns Selector compatible with the x402Client constructor
*/
export function createCappedPaymentSelector(maxPaymentUsdc: number) {
const cap = parseUnits(maxPaymentUsdc.toString(), USDC_DECIMALS);
return <T extends PaymentRequirementLike>(_x402Version: number, accepts: T[]): T => {
const match = accepts.find(req => BigInt(req.maxAmountRequired ?? req.amount ?? "0") <= cap);
if (!match) {
throw new Error(
`x402 payment requirements exceed the configured spending limit of ` +
`${maxPaymentUsdc} USDC. Refusing to sign.`,
);
}
return match;
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ import {
isUsdcAsset,
isUrlAllowed,
validateFacilitator,
createApprovedPaymentSelector,
createCappedPaymentSelector,
} from "./utils";
import { SUPPORTED_NETWORKS, KNOWN_FACILITATORS } from "./constants";

Expand Down Expand Up @@ -460,8 +462,13 @@ DO NOT use this action directly without first trying make_http_request!`,
);
}

// Create x402 client with appropriate signer
const client = await this.createX402Client(walletProvider);
// Create x402 client with appropriate signer. Bind the signed payment to
// the option the user approved, so a service cannot return a larger
// amount or different recipient on the retry's 402.
const client = await this.createX402Client(
walletProvider,
createApprovedPaymentSelector(args.selectedPaymentOption),
);
const fetchWithPayment = wrapFetchWithPayment(fetch, client);

// Build URL with query params and determine if body is allowed
Expand Down Expand Up @@ -607,8 +614,12 @@ Unless specifically instructed otherwise, prefer the two-step approach with make
);
}

// Create x402 client with appropriate signer
const client = await this.createX402Client(walletProvider);
// Create x402 client with appropriate signer. This path skips user
// confirmation, so cap the signed amount at the configured limit.
const client = await this.createX402Client(
walletProvider,
createCappedPaymentSelector(this.config.maxPaymentUsdc),
);
const fetchWithPayment = wrapFetchWithPayment(fetch, client);

// Build URL with query params and determine if body is allowed
Expand Down Expand Up @@ -839,10 +850,15 @@ These are the only services that can be called using make_http_request or make_h
* Creates an x402 client configured for the given wallet provider.
*
* @param walletProvider - The wallet provider to configure the client for
* @param paymentRequirementsSelector - Optional selector constraining which
* server-returned payment requirements may be signed
* @returns Configured x402Client
*/
private async createX402Client(walletProvider: WalletProvider): Promise<x402Client> {
const client = new x402Client();
private async createX402Client(
walletProvider: WalletProvider,
paymentRequirementsSelector?: ConstructorParameters<typeof x402Client>[0],
): Promise<x402Client> {
const client = new x402Client(paymentRequirementsSelector);

if (walletProvider instanceof EvmWalletProvider) {
const account = walletProvider.toSigner();
Expand Down
Loading