Skip to content
Draft
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
60 changes: 60 additions & 0 deletions apps/agent/src/eip3009-authorization.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";

import {
InvalidEip3009AuthorizationError,
parseExactEip3009Authorization,
} from "./eip3009-authorization.js";

const agentAddress = "0x2222222222222222222222222222222222222222";
const nowSeconds = 1_784_271_300;
const maxAtomicUsdcAmount = 999_999_999_999_999_999_990_000n;

function authorization(value: bigint) {
return {
domain: {
chainId: 8453,
name: "USD Coin",
verifyingContract: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
version: "2",
},
message: {
from: agentAddress,
nonce: `0x${"12".repeat(32)}`,
to: "0x1111111111111111111111111111111111111111",
validAfter: 0n,
validBefore: BigInt(nowSeconds + 60),
value,
},
primaryType: "TransferWithAuthorization",
types: {
TransferWithAuthorization: [
{ name: "from", type: "address" },
{ name: "to", type: "address" },
{ name: "value", type: "uint256" },
{ name: "validAfter", type: "uint256" },
{ name: "validBefore", type: "uint256" },
{ name: "nonce", type: "bytes32" },
],
},
};
}

describe("EIP-3009 USDC amount bound", () => {
it("accepts the largest amount representable by the product cap domain", () => {
expect(
parseExactEip3009Authorization(authorization(maxAtomicUsdcAmount), {
address: agentAddress,
nowSeconds,
}).amount,
).toBe(maxAtomicUsdcAmount.toString());
});

it("rejects one atomic unit above the product cap domain", () => {
expect(() =>
parseExactEip3009Authorization(authorization(maxAtomicUsdcAmount + 1n), {
address: agentAddress,
nowSeconds,
}),
).toThrow(InvalidEip3009AuthorizationError);
});
});
5 changes: 5 additions & 0 deletions apps/agent/src/eip3009-authorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ import { getAddress, type TypedData } from "viem";
import { ARBITRUM_NETWORK, ARBITRUM_USDC, BASE_NETWORK, BASE_USDC } from "./routing.js";

const MAX_AUTHORIZATION_LIFETIME_SECONDS = 600;
// Mirrors numeric(20,0) cap cents at 10,000 atomic USDC units per cent.
const MAX_CAP_USD_CENTS = 10n ** 20n - 1n;
const ATOMIC_UNITS_PER_CENT = 10_000n;
export const MAX_USDC_AMOUNT_ATOMIC = MAX_CAP_USD_CENTS * ATOMIC_UNITS_PER_CENT;
const AUTHORIZATION_TYPES = [
{ name: "from", type: "address" },
{ name: "to", type: "address" },
Expand Down Expand Up @@ -103,6 +107,7 @@ export function parseExactEip3009Authorization(
if (
from !== getAddress(options.address) ||
amount === "0" ||
BigInt(amount) > MAX_USDC_AMOUNT_ATOMIC ||
validAfter !== "0" ||
typeof nonce !== "string" ||
!/^0x[0-9a-fA-F]{64}$/.test(nonce) ||
Expand Down
2 changes: 2 additions & 0 deletions apps/agent/src/fetch-wrapper.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ describe("Leash fetch wrapper with real x402 and HTTP wires", () => {
toolName: `GET ${origin}/protected`,
transport: "http",
},
resourceUrl: `${origin}/protected`,
});
expect(JSON.stringify(signRequests[0])).not.toMatch(/receipt-secret|client-fragment/);
await expect
Expand Down Expand Up @@ -186,6 +187,7 @@ describe("Leash fetch wrapper with real x402 and HTTP wires", () => {
expect(signRequests).toHaveLength(1);
expect(signRequests[0]).toMatchObject({
origin: { toolName: `POST ${origin}/protected` },
resourceUrl: `${origin}/protected`,
});
});
});
12 changes: 10 additions & 2 deletions apps/agent/src/fetch-wrapper.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { wrapFetchWithPayment } from "@x402/fetch";

import { currentPaymentOrigin, withPaymentOrigin } from "./origin-context.js";
import {
currentPaymentOrigin,
withPaymentOrigin,
withPaymentResourceUrl,
} from "./origin-context.js";
import { createLeashPaymentClient } from "./payment-client.js";
import { LeashRemoteSigner } from "./remote-signer.js";

Expand All @@ -14,6 +18,10 @@ interface LeashFetchOptions {

type FetchInput = Request | string | URL;

function requestUrl(input: FetchInput) {
return input instanceof Request ? input.url : input.toString();
}

function requestName(input: FetchInput, init?: RequestInit) {
const method = init?.method ?? (input instanceof Request ? input.method : "GET");
const rawUrl = input instanceof Request ? input.url : input.toString();
Expand Down Expand Up @@ -49,6 +57,6 @@ export function createLeashFetch(options: LeashFetchOptions) {
toolName: requestName(input, init),
transport: "http",
},
() => paidFetch(input, init),
() => withPaymentResourceUrl(requestUrl(input), () => paidFetch(input, init)),
);
}
9 changes: 9 additions & 0 deletions apps/agent/src/origin-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { AsyncLocalStorage } from "node:async_hooks";
import type { PaymentOrigin } from "./remote-signer.js";

const paymentOrigin = new AsyncLocalStorage<PaymentOrigin>();
const paymentResourceUrl = new AsyncLocalStorage<string>();

export function currentPaymentOrigin() {
return paymentOrigin.getStore();
Expand All @@ -11,3 +12,11 @@ export function currentPaymentOrigin() {
export function withPaymentOrigin<T>(origin: PaymentOrigin, action: () => T) {
return paymentOrigin.run(origin, action);
}

export function currentPaymentResourceUrl() {
return paymentResourceUrl.getStore();
}

export function withPaymentResourceUrl<T>(resourceUrl: string, action: () => T) {
return paymentResourceUrl.run(resourceUrl, action);
}
6 changes: 5 additions & 1 deletion apps/agent/src/proxy.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ const paymentRequired = {
scheme: "exact",
},
],
resource: { url: "mcp://tool/search" },
resource: {
url: "mcp://wire-user:wire-pass@Search.API.EXAMPLE.TEST:8443/tool/search?token=wire-secret#fragment",
},
x402Version: 2,
} satisfies PaymentRequired;

Expand Down Expand Up @@ -142,7 +144,9 @@ describe("Leash MCP proxy with real SDK transports", () => {
expect(paidMetadata[0]).toMatchObject({ traceId: "trace-1" });
expect(signBodies[0]).toMatchObject({
origin: { clientName: "Claude Code", toolName: "search", transport: "mcp" },
resourceUrl: "mcp://search.api.example.test:8443/tool/search",
});
expect(JSON.stringify(signBodies[0])).not.toMatch(/wire-user|wire-pass|wire-secret|fragment/);
expect(resultBodies).toEqual([
{
outcome: "observed",
Expand Down
11 changes: 7 additions & 4 deletions apps/agent/src/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { MCP_PAYMENT_META_KEY, MCP_PAYMENT_RESPONSE_META_KEY } from "@x402/mcp";

import { detectMcpPaymentRequired } from "./detect.js";
import { SignerNotConfiguredError } from "./errors.js";
import { withPaymentOrigin } from "./origin-context.js";
import { withPaymentOrigin, withPaymentResourceUrl } from "./origin-context.js";

interface LeashProxyOptions {
paymentClient?: x402Client;
Expand Down Expand Up @@ -65,9 +65,12 @@ export function createLeashProxyServer(options: LeashProxyOptions) {
if (!initialResult) throw new Error("Upstream tool returned no result");
return initialResult;
}
if (!options.paymentClient) throw new SignerNotConfiguredError();
const paymentClient = options.paymentClient;
if (!paymentClient) throw new SignerNotConfiguredError();

const paymentPayload = await options.paymentClient.createPaymentPayload(challenge);
const paymentPayload = await withPaymentResourceUrl(challenge.resource.url, () =>
paymentClient.createPaymentPayload(challenge),
);
const paidResult = await options.upstream.callTool(
{
...request.params,
Expand All @@ -81,7 +84,7 @@ export function createLeashProxyServer(options: LeashProxyOptions) {
);
const settlement = settlementFromResult(paidResult);
if (settlement) {
await options.paymentClient.handlePaymentResponse({
await paymentClient.handlePaymentResponse({
paymentPayload,
requirements: paymentPayload.accepted,
settleResponse: settlement,
Expand Down
15 changes: 11 additions & 4 deletions apps/agent/src/remote-signer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,11 +83,21 @@ function signerWithFetch(fetch: typeof globalThis.fetch) {
apiKey: "leash_sk_secret",
fetch,
nowSeconds: () => nowSeconds,
resourceUrl: credentialedResourceUrl,
reportRetryDelayMs: 1,
reportTimeoutMs: 10,
});
}

function credentialedResourceUrl() {
const url = new URL("https://TOOL.EXAMPLE.TEST:8443/search");
url.username = "wire-user";
url.password = "wire-pass";
url.searchParams.set("token", "wire-secret");
url.hash = "fragment";
return url.toString();
}

describe("Leash remote signer authorization gate", () => {
it("posts only an exact EIP-3009 native-USDC authority and verifies its signature", async () => {
const signerRequest = validSignerRequest();
Expand All @@ -99,6 +109,7 @@ describe("Leash remote signer authorization gate", () => {
asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
network: "eip155:8453",
payTo: "0x1111111111111111111111111111111111111111",
resourceUrl: "https://tool.example.test:8443/search",
signerRequest: {
...signerRequest,
message: {
Expand All @@ -112,10 +123,8 @@ describe("Leash remote signer authorization gate", () => {
return Response.json({ receiptId: "receipt-1", signature });
});
const signer = signerWithFetch(fetch);

await expect(signer.signTypedData(signerRequest)).resolves.toBe(signature);
expect(signer.receiptIdForSignature(signature)).toBe("receipt-1");
expect(signer.receiptIdForSignature(signature)).toBe("receipt-1");
});

it.each([
Expand Down Expand Up @@ -171,7 +180,6 @@ describe("Leash remote signer authorization gate", () => {
mutate(request);
const fetch = vi.fn(async () => Response.json({}));
const signer = signerWithFetch(fetch);

await expect(signer.signTypedData(request)).rejects.toMatchObject({
code: "INVALID_SIGNER_REQUEST",
});
Expand All @@ -184,7 +192,6 @@ describe("Leash remote signer authorization gate", () => {
const signer = signerWithFetch(async () =>
Response.json({ receiptId: "receipt-forged", signature: forgedSignature }),
);

await expect(signer.signTypedData(signerRequest)).rejects.toMatchObject({
code: "INVALID_SIGNER_RESPONSE",
status: 502,
Expand Down
10 changes: 9 additions & 1 deletion apps/agent/src/remote-signer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import {
parseExactEip3009Authorization,
type SignerRequest,
} from "./eip3009-authorization.js";
import { currentPaymentOrigin } from "./origin-context.js";
import { currentPaymentOrigin, currentPaymentResourceUrl } from "./origin-context.js";
import { redactPaymentResourceUrl } from "./resource-url.js";

export interface PaymentOrigin {
clientName: string;
Expand All @@ -22,6 +23,7 @@ interface RemoteSignerOptions {
fetch?: typeof globalThis.fetch;
nowSeconds?: () => number;
origin?: () => PaymentOrigin | undefined;
resourceUrl?: () => string | undefined;
reportAttempts?: number;
reportRetryDelayMs?: number;
reportTimeoutMs?: number;
Expand Down Expand Up @@ -71,6 +73,7 @@ export class LeashRemoteSigner implements ClientEvmSigner {
readonly #reportAttempts: number;
readonly #reportRetryDelayMs: number;
readonly #reportTimeoutMs: number;
readonly #resourceUrl: (() => string | undefined) | undefined;
readonly #resultEndpoint: URL;
readonly #receiptBySignature = new Map<string, string>();

Expand All @@ -83,6 +86,7 @@ export class LeashRemoteSigner implements ClientEvmSigner {
this.#fetch = options.fetch ?? globalThis.fetch;
this.#nowSeconds = options.nowSeconds ?? (() => Math.floor(Date.now() / 1_000));
this.#origin = options.origin ?? currentPaymentOrigin;
this.#resourceUrl = options.resourceUrl ?? currentPaymentResourceUrl;
this.#reportAttempts = options.reportAttempts ?? 3;
this.#reportRetryDelayMs = options.reportRetryDelayMs ?? 250;
this.#reportTimeoutMs = options.reportTimeoutMs ?? 2_000;
Expand Down Expand Up @@ -111,13 +115,17 @@ export class LeashRemoteSigner implements ClientEvmSigner {
throw new RemoteSignerError("INVALID_SIGNER_REQUEST", "The signer request is invalid.", 400);
}
const origin = this.#origin?.();
const rawResourceUrl = this.#resourceUrl?.();
const resourceUrl =
rawResourceUrl === undefined ? undefined : redactPaymentResourceUrl(rawResourceUrl);
const response = await this.#fetch(this.#endpoint, {
body: jsonBody({
amount: authorization.amount,
asset: authorization.asset,
network: authorization.network,
...(origin ? { origin } : {}),
payTo: authorization.payTo,
...(resourceUrl ? { resourceUrl } : {}),
signerRequest: authorization.typedData,
}),
headers: { authorization: `Bearer ${this.#apiKey}`, "content-type": "application/json" },
Expand Down
45 changes: 45 additions & 0 deletions apps/agent/src/resource-url.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";

import { InvalidPaymentResourceUrlError, redactPaymentResourceUrl } from "./resource-url.js";

function credentialedResourceUrl(value: string) {
const url = new URL(value);
url.username = "wire-user";
url.password = "wire-pass";
url.searchParams.set("token", "secret");
url.hash = "fragment";
return url.toString();
}

describe("payment resource URL redaction", () => {
it("keeps the canonical HTTP path and meaningful port but removes secrets", () => {
expect(
redactPaymentResourceUrl(
credentialedResourceUrl("https://PAYMENTS.EXAMPLE.TEST:8443/tool/pay"),
),
).toBe("https://payments.example.test:8443/tool/pay");
});

it("canonicalizes an MCP resource identifier without leaking its secrets", () => {
expect(
redactPaymentResourceUrl(credentialedResourceUrl("mcp://TOOL.EXAMPLE.TEST:8443/tool/pay")),
).toBe("mcp://tool.example.test:8443/tool/pay");
});

it.each([
"",
"/relative",
"not a URL",
"ftp://tool/pay",
"file:///tmp/payment",
])("rejects unsupported or non-absolute resource provenance: %s", (value) => {
expect(() => redactPaymentResourceUrl(value)).toThrow(InvalidPaymentResourceUrlError);
});

it.each([
["host", `https://${"a".repeat(254)}/pay`],
["URL", `https://tool.example/${"a".repeat(2_048)}`],
])("rejects an overlong resource %s", (_label, value) => {
expect(() => redactPaymentResourceUrl(value)).toThrow(InvalidPaymentResourceUrlError);
});
});
39 changes: 39 additions & 0 deletions apps/agent/src/resource-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
const MAX_RESOURCE_HOST_LENGTH = 253;
const MAX_RESOURCE_URL_LENGTH = 2_048;
const SUPPORTED_RESOURCE_PROTOCOLS = new Set(["http:", "https:", "mcp:"]);

export class InvalidPaymentResourceUrlError extends Error {
readonly code = "INVALID_PAYMENT_RESOURCE_URL";

constructor() {
super("Payment resource URL must be a supported absolute URL.");
this.name = "InvalidPaymentResourceUrlError";
}
}

export function redactPaymentResourceUrl(value: string) {
if (value.length < 1 || value.length > MAX_RESOURCE_URL_LENGTH) {
throw new InvalidPaymentResourceUrlError();
}
let url: URL;
try {
url = new URL(value);
} catch {
throw new InvalidPaymentResourceUrlError();
}
if (
!SUPPORTED_RESOURCE_PROTOCOLS.has(url.protocol) ||
url.hostname.length < 1 ||
url.hostname.length > MAX_RESOURCE_HOST_LENGTH
) {
throw new InvalidPaymentResourceUrlError();
}
url.hostname = url.hostname.toLowerCase();
url.username = "";
url.password = "";
url.search = "";
url.hash = "";
const redacted = url.toString();
if (redacted.length > MAX_RESOURCE_URL_LENGTH) throw new InvalidPaymentResourceUrlError();
return redacted;
}
Loading
Loading