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
14 changes: 12 additions & 2 deletions packages/agents/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,10 @@
"@types/react": "^19.2.17",
"@types/yargs": "^17.0.35",
"@vitest/browser-playwright": "^4.1.9",
"@x402/core": "^2.17.0",
"@x402/evm": "^2.17.0",
"@x402/core": "^2.21.0",
"@x402/evm": "^2.21.0",
"@x402/svm": "^2.21.0",
"@solana/kit": "^5.1.0",
"ai": "^6.0.202",
"chat": "^4.31.0",
"glob": "^13.0.6",
Expand All @@ -60,6 +62,8 @@
"@tanstack/ai": ">=0.10.2 <1.0.0",
"@x402/core": "^2.0.0",
"@x402/evm": "^2.0.0",
"@x402/svm": "^2.0.0",
"@solana/kit": ">=5.1.0",
"ai": "^6.0.0",
"chat": "^4.29.0",
"just-bash": "^3.0.0",
Expand All @@ -80,6 +84,12 @@
"@x402/evm": {
"optional": true
},
"@x402/svm": {
"optional": true
},
"@solana/kit": {
"optional": true
},
"ai": {
"optional": true
},
Expand Down
87 changes: 79 additions & 8 deletions packages/agents/src/mcp/x402.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,19 @@ import { registerExactEvmScheme as registerServerEvmScheme } from "@x402/evm/exa
import { registerExactEvmScheme as registerClientEvmScheme } from "@x402/evm/exact/client";
import type { ClientEvmSigner } from "@x402/evm";

// v2 imports from @x402/svm (optional peer dep — see package.json peerDependenciesMeta)
import { registerExactSvmScheme as registerServerSvmScheme } from "@x402/svm/exact/server";
import { registerExactSvmScheme as registerClientSvmScheme } from "@x402/svm/exact/client";
import type { ClientSvmSigner } from "@x402/svm";
// Canonical discriminator from @solana/kit (re-exported from @solana/signers)
// rather than a hand-rolled method check: an earlier duck-type here checked
// only signTransactions/modifyAndSignTransactions and missed the third
// TransactionSigner union member, TransactionSendingSigner, which exposes
// only signAndSendTransactions — that signer would have silently fallen
// through to the EVM branch and failed at runtime. isTransactionSigner()
// checks all three shapes correctly.
import { isTransactionSigner } from "@solana/kit";

// Re-export commonly used types for consumer convenience
export type {
PaymentRequirements,
Expand All @@ -46,6 +59,7 @@ export type {
} from "@x402/core/types";
export type { FacilitatorConfig } from "@x402/core/server";
export type { ClientEvmSigner } from "@x402/evm";
export type { ClientSvmSigner } from "@x402/svm";

/**
* Map of legacy v1 network names to CAIP-2 identifiers.
Expand All @@ -55,7 +69,9 @@ const LEGACY_NETWORK_MAP: Record<string, string> = {
"base-sepolia": "eip155:84532",
base: "eip155:8453",
ethereum: "eip155:1",
sepolia: "eip155:11155111"
sepolia: "eip155:11155111",
solana: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp",
"solana-devnet": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1"
};

/**
Expand All @@ -76,10 +92,23 @@ export type X402Config = {
* Accepts both legacy names ("base-sepolia") and CAIP-2 format ("eip155:84532").
*/
network: string;
/** Payment recipient address */
recipient: `0x${string}`;
/**
* Payment recipient address. EVM: `0x${string}`. SVM: base58-encoded
* Solana address (widened from the original EVM-only literal type in
* https://github.com/cloudflare/agents/issues/1863 — a Solana `payTo`
* never typechecked against `` `0x${string}` ``, only runtime-rejected
* once the facilitator saw an unregistered scheme).
*/
recipient: `0x${string}` | string;
/** Facilitator configuration. Defaults to https://x402.org/facilitator */
facilitator?: FacilitatorConfig;
/**
* Optional Solana RPC endpoint used to embed a recent blockhash in the SVM
* 402 challenge (`extra.recentBlockhash`). When omitted, the SVM scheme
* still registers but the client must fetch its own recent blockhash.
* No effect on EVM-network configs.
*/
svmRpcUrl?: string;
/** @deprecated No longer used in v2. The protocol version is determined automatically. */
version?: number;
};
Expand Down Expand Up @@ -108,6 +137,14 @@ export function withX402<T extends McpServer>(
const facilitatorClient = new HTTPFacilitatorClient(facilitatorConfig);
const resourceServer = new x402ResourceServer(facilitatorClient);
registerServerEvmScheme(resourceServer);
// Additive, same pattern as #1863's Stellar registration: registering the
// SVM scheme is inert for EVM-network configs (buildPaymentRequirements
// looks up the scheme matching the *requested* network) and requires no
// branch here — only server-side registration is unconditional; client-side
// signer selection is what actually decides which scheme is used per call.
registerServerSvmScheme(resourceServer, {
rpcUrl: cfg.svmRpcUrl
});

// Lazy initialization: fetch supported kinds from facilitator on first use
let initPromise: Promise<void> | null = null;
Expand Down Expand Up @@ -310,12 +347,38 @@ export interface X402AugmentedClient {
): Promise<CallToolResult>;
}

/**
* Distinguishes an SVM `TransactionSigner` (from `@solana/kit`) from an EVM
* `ClientEvmSigner` (from viem). `@x402/svm` does not currently export an
* `isClientSvmSigner` guard the way `@x402/stellar` exports
* `isClientStellarSigner` (see #1863), so this uses `@solana/kit`'s own
* `isTransactionSigner()` — which correctly covers all three
* `TransactionSigner` union shapes (`TransactionModifyingSigner`,
* `TransactionPartialSigner`, `TransactionSendingSigner`, exposing
* `modifyAndSignTransactions` / `signTransactions` / `signAndSendTransactions`
* respectively) rather than hand-checking method names, which would
* otherwise miss the `signAndSendTransactions`-only shape.
*/
function isSvmSigner(
account: ClientEvmSigner | ClientSvmSigner
): account is ClientSvmSigner {
// Cast through the function's own parameter type rather than a bare
// Record<string, unknown>, which drops the required `address` field
// isTransactionSigner's signature demands (TS2741) - both union members
// of `account` already carry an `address: string` at runtime, so this
// is a type-level formality, not a behavior change.
return isTransactionSigner(
account as unknown as Parameters<typeof isTransactionSigner>[0]
);
}

export type X402ClientConfig = {
/**
* EVM account/signer for signing payment authorizations.
* Use `privateKeyToAccount()` from viem/accounts to create one.
* Account/signer for signing payment authorizations. EVM: use
* `privateKeyToAccount()` from viem/accounts. SVM: a `TransactionSigner`
* from `@solana/kit` (e.g. via `createKeyPairSignerFromBytes`).
*/
account: ClientEvmSigner;
account: ClientEvmSigner | ClientSvmSigner;
/**
* Preferred network identifier (optional).
* Accepts both legacy names ("base-sepolia") and CAIP-2 format ("eip155:84532").
Expand All @@ -339,9 +402,17 @@ export function withX402Client<T extends MCPClient>(

const maxPaymentValue = x402Config.maxPaymentValue ?? BigInt(100_000); // 0.10 USDC

// Create v2 x402 payment client with EVM scheme support
// Create v2 x402 payment client. Branches on signer type — a single
// `account` can't be dual-registered, and registration is keyed by
// network, so exactly one scheme registers per client (same shape as
// #1863's `isClientStellarSigner` branch, using the local duck-type guard
// above since @x402/svm doesn't yet export an equivalent).
const paymentClient = new x402Client();
registerClientEvmScheme(paymentClient, { signer: account });
if (isSvmSigner(account)) {
registerClientSvmScheme(paymentClient, { signer: account });
} else {
registerClientEvmScheme(paymentClient, { signer: account });
}

// If a preferred network is specified, register a policy to prefer it
if (x402Config.network) {
Expand Down
113 changes: 113 additions & 0 deletions packages/agents/src/x402-tests/x402.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,22 @@ vi.mock("@x402/evm/exact/client", () => ({
registerExactEvmScheme: vi.fn()
}));

// @x402/svm ships the same registerExactSvmScheme(server|client, config)
// convenience function shape as @x402/evm (unlike @x402/stellar, which has
// no equivalent per #1863 — that's why this mock needs no extra `register:
// vi.fn()` scaffolding on mockResourceServer the way a Stellar patch would).
vi.mock("@x402/svm/exact/server", () => ({
registerExactSvmScheme: vi.fn()
}));

vi.mock("@x402/svm/exact/client", () => ({
registerExactSvmScheme: vi.fn()
}));

import { registerExactSvmScheme as mockRegisterServerSvmScheme } from "@x402/svm/exact/server";
import { registerExactSvmScheme as mockRegisterClientSvmScheme } from "@x402/svm/exact/client";
import { registerExactEvmScheme as mockRegisterClientEvmScheme } from "@x402/evm/exact/client";

import {
normalizeNetwork,
withX402,
Expand Down Expand Up @@ -107,6 +123,23 @@ const mockSigner = {
signTypedData: vi.fn()
};

// A TransactionModifyingSigner shape (one of @solana/kit's three
// TransactionSigner union members) — recognized by isSvmSigner() via the
// real @solana/kit isTransactionSigner(), not a hand-rolled check.
const mockSvmSigner = {
address: "9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM",
modifyAndSignTransactions: vi.fn()
};

// A TransactionSendingSigner shape — the union member a naive
// method-name-only duck-type guard would miss (it exposes neither
// signTransactions nor modifyAndSignTransactions). Regression coverage for
// exactly that gap.
const mockSvmSendingSigner = {
address: "5MvYuMzYxWAf7YvFV6vHFtQtb8p9BQm2h5cq1ovtqEwF",
signAndSendTransactions: vi.fn()
};

// =============================================================
// normalizeNetwork
// =============================================================
Expand Down Expand Up @@ -155,6 +188,39 @@ describe("withX402", () => {
expect(typeof augmented.paidTool).toBe("function");
});

it("registers both the EVM and SVM schemes unconditionally, and accepts a base58 SVM recipient", () => {
const server = createMockMcpServer();
withX402(server, {
network: "solana",
recipient: "9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM"
});

// Both schemes register regardless of the configured network — additive,
// same as #1863's Stellar registration. Only one is ever *used* per
// payment, decided by buildPaymentRequirements() matching the requested
// network at call time, not by anything gated here.
expect(mockRegisterServerSvmScheme).toHaveBeenCalledWith(
mockResourceServer,
expect.objectContaining({ rpcUrl: undefined })
);
});

it("passes svmRpcUrl through to the SVM scheme registration", () => {
const server = createMockMcpServer();
withX402(server, {
network: "solana",
recipient: "9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM",
svmRpcUrl: "https://api.mainnet-beta.solana.com"
});

expect(mockRegisterServerSvmScheme).toHaveBeenCalledWith(
mockResourceServer,
expect.objectContaining({
rpcUrl: "https://api.mainnet-beta.solana.com"
})
);
});

it("registers a tool with correct metadata via paidTool", () => {
const server = createMockMcpServer();
const augmented = withX402(server, defaultServerConfig);
Expand Down Expand Up @@ -662,6 +728,53 @@ describe("withX402Client", () => {
vi.clearAllMocks();
});

it("registers the EVM scheme for an EVM (0x-address) signer", () => {
const client = createMockMcpClient();
withX402Client(client, {
account: mockSigner as unknown as X402ClientConfig["account"]
});

expect(mockRegisterClientEvmScheme).toHaveBeenCalledWith(
mockPaymentClient,
{ signer: mockSigner }
);
expect(mockRegisterClientSvmScheme).not.toHaveBeenCalled();
});

it("registers the SVM scheme for an SVM (base58-address) signer", () => {
const client = createMockMcpClient();
withX402Client(client, {
account: mockSvmSigner as unknown as X402ClientConfig["account"]
});

expect(mockRegisterClientSvmScheme).toHaveBeenCalledWith(
mockPaymentClient,
{ signer: mockSvmSigner }
);
expect(mockRegisterClientEvmScheme).not.toHaveBeenCalled();
});

it("registers the SVM scheme for a TransactionSendingSigner (signAndSendTransactions-only shape)", () => {
// Regression coverage: a method-name-only duck-type that checks for
// signTransactions/modifyAndSignTransactions but not
// signAndSendTransactions would misclassify this signer as EVM, fall
// through to registerClientEvmScheme, and fail at runtime the first
// time it tried to sign (EVM signers don't implement any of the three
// SVM methods). isSvmSigner() must catch this via the real
// @solana/kit isTransactionSigner(), which covers all three
// TransactionSigner union shapes.
const client = createMockMcpClient();
withX402Client(client, {
account: mockSvmSendingSigner as unknown as X402ClientConfig["account"]
});

expect(mockRegisterClientSvmScheme).toHaveBeenCalledWith(
mockPaymentClient,
{ signer: mockSvmSendingSigner }
);
expect(mockRegisterClientEvmScheme).not.toHaveBeenCalled();
});

it("wraps listTools to append payment cost info", async () => {
const client = createMockMcpClient();
client.listTools.mockResolvedValue({
Expand Down
Loading