diff --git a/examples/with-mpp/aixyz-both.config.ts b/examples/with-mpp/aixyz-both.config.ts new file mode 100644 index 0000000..3460dbc --- /dev/null +++ b/examples/with-mpp/aixyz-both.config.ts @@ -0,0 +1,42 @@ +import type { AixyzConfig } from "aixyz/config"; + +/** + * Example: Both x402 and MPP configured simultaneously + * + * When both are present, aixyz serves both payment protocols at once. + * Clients that send `Authorization: Payment ...` → MPP path + * Clients that send `X-Payment: ...` → x402 path + * Clients with no credential → 402 with both challenges + * + * Useful when you want to maximise payment method compatibility. + */ +const config: AixyzConfig = { + name: "Unit Conversion Agent", + description: "AI agent that converts values between metric, imperial, and other measurement systems.", + version: "0.1.0", + + // x402: EVM/Base payments (existing clients) + x402: { + payTo: process.env.X402_PAY_TO ?? "0x0799872E07EA7a63c79357694504FE66EDfE4a0A", + network: process.env.NODE_ENV === "production" ? "eip155:8453" : "eip155:84532", + }, + + // mpp: Tempo / Stripe / Lightning (new clients) + mpp: { + recipient: process.env.MPP_RECIPIENT ?? "0x0799872E07EA7a63c79357694504FE66EDfE4a0A", + methods: ["tempo", "stripe"], + stripeSecretKey: process.env.MPP_STRIPE_SECRET_KEY, + }, + + skills: [ + { + id: "convert-length", + name: "Convert Length", + description: "Convert length and distance values between metric and imperial units", + tags: ["length", "distance", "metric", "imperial"], + examples: ["Convert 100 meters to feet"], + }, + ], +}; + +export default config; diff --git a/examples/with-mpp/aixyz.config.ts b/examples/with-mpp/aixyz.config.ts new file mode 100644 index 0000000..bc5fb13 --- /dev/null +++ b/examples/with-mpp/aixyz.config.ts @@ -0,0 +1,40 @@ +import type { AixyzConfig } from "aixyz/config"; + +/** + * Example: MPP-only payment configuration + * + * This agent accepts payments via the Machine Payments Protocol (MPP), + * supporting Tempo stablecoins, Stripe cards, and Lightning Bitcoin. + * + * Install mppx to enable: bun add mppx + * + * @see https://mpp.dev + */ +const config: AixyzConfig = { + name: "Unit Conversion Agent", + description: "AI agent that converts values between metric, imperial, and other measurement systems.", + version: "0.1.0", + + mpp: { + // EVM address to receive Tempo stablecoin payments + recipient: process.env.MPP_RECIPIENT ?? "0x0799872E07EA7a63c79357694504FE66EDfE4a0A", + // pathUSD on Tempo mainnet (default) + currency: "0x20c0000000000000000000000000000000000000", + // Accept Tempo stablecoins and Stripe cards + methods: ["tempo", "stripe"], + // Stripe secret key (required when "stripe" is in methods) + stripeSecretKey: process.env.MPP_STRIPE_SECRET_KEY, + }, + + skills: [ + { + id: "convert-length", + name: "Convert Length", + description: "Convert length and distance values between metric and imperial units", + tags: ["length", "distance", "metric", "imperial"], + examples: ["Convert 100 meters to feet", "How many miles is 10 kilometers?"], + }, + ], +}; + +export default config; diff --git a/packages/aixyz-cli/build/AixyzConfigPlugin.ts b/packages/aixyz-cli/build/AixyzConfigPlugin.ts index 1a0c3f7..9e3cb19 100644 --- a/packages/aixyz-cli/build/AixyzConfigPlugin.ts +++ b/packages/aixyz-cli/build/AixyzConfigPlugin.ts @@ -4,11 +4,13 @@ import boxen from "boxen"; import chalk from "chalk"; function label(text: string): string { - return chalk.dim(text.padEnd(14)); + return chalk.dim(text.padEnd(16)); } function logConfig(materialized: ReturnType): void { - const maxLen = Math.max(materialized.url.length, materialized.x402.payTo.length); + const refLen = materialized.url?.length ?? 0; + const payLen = materialized.x402?.payTo?.length ?? materialized.mpp?.recipient?.length ?? 0; + const maxLen = Math.max(refLen, payLen); const description = materialized.description.length > maxLen ? materialized.description.slice(0, maxLen - 1) + "…" @@ -19,9 +21,22 @@ function logConfig(materialized: ReturnType): void { `${label("Description")}${description}`, `${label("URL")}${materialized.url}`, `${label("Version")}${materialized.version}`, - `${label("x402 PayTo")}${materialized.x402.payTo}`, - `${label("x402 Network")}${materialized.x402.network}`, ]; + + if (materialized.x402) { + lines.push( + `${label("x402 PayTo")}${materialized.x402.payTo}`, + `${label("x402 Network")}${materialized.x402.network}`, + ); + } + + if (materialized.mpp) { + lines.push( + `${label("MPP Recipient")}${materialized.mpp.recipient}`, + `${label("MPP Methods")}${(materialized.mpp.methods ?? ["tempo"]).join(", ")}`, + ); + } + console.log( boxen(lines.join("\n"), { padding: { left: 1, right: 1, top: 0, bottom: 0 }, diff --git a/packages/aixyz-config/index.ts b/packages/aixyz-config/index.ts index be55407..9a2978c 100644 --- a/packages/aixyz-config/index.ts +++ b/packages/aixyz-config/index.ts @@ -4,6 +4,8 @@ import { z } from "zod"; export type Network = `${string}:${string}`; +export type MppMethod = "tempo" | "stripe" | "lightning"; + export type AixyzConfig = { /** * The name of the agent will be used in the agent card. @@ -23,7 +25,12 @@ export type AixyzConfig = { * Defaults to `process.env.VERCEL_URL` for Vercel deployments. */ url?: string; - x402: { + /** + * x402 payment configuration. + * When present, the agent will accept x402 payments (HTTP 402 with X-Payment headers). + * Can be configured alongside `mpp` to accept both protocols simultaneously. + */ + x402?: { /** * The address that will receive the payment from the agent. * Defaults to `process.env.X402_PAY_TO` if not set. @@ -35,6 +42,50 @@ export type AixyzConfig = { */ network: string; }; + /** + * MPP (Machine Payments Protocol) configuration. + * When present, the agent will accept MPP payments (HTTP 402 with WWW-Authenticate: Payment headers). + * Supports Tempo stablecoins, Stripe cards, and Lightning Bitcoin. + * Can be configured alongside `x402` to accept both protocols simultaneously. + * + * @see https://mpp.dev + */ + mpp?: { + /** + * The EVM address that will receive payments. + * Used as the `recipient` in Tempo payment methods. + * Defaults to `process.env.MPP_RECIPIENT` if not set. + */ + recipient: string; + /** + * The Tempo currency contract address. + * Defaults to pathUSD on Tempo mainnet: `process.env.MPP_CURRENCY` + * @default "0x20c0000000000000000000000000000000000000" + */ + currency?: string; + /** + * Payment methods to accept. + * @default ["tempo"] + */ + methods?: MppMethod[]; + /** + * Stripe secret key for Stripe payment method support. + * Required when "stripe" is included in methods. + * Defaults to `process.env.MPP_STRIPE_SECRET_KEY`. + */ + stripeSecretKey?: string; + /** + * Fee payer account private key for sponsoring gas on Tempo pull-mode transactions. + * Defaults to `process.env.MPP_FEE_PAYER_KEY`. + */ + feePayerKey?: string; + /** + * Use optimistic verification (do not wait for on-chain confirmation). + * Reduces latency at the cost of slightly higher risk. + * @default false + */ + optimistic?: boolean; + }; build?: { /** * Output format for `aixyz build`. @@ -116,10 +167,25 @@ const AixyzConfigSchema = z.object({ return `http://localhost:${port}/`; }) .pipe(z.url()), - x402: z.object({ - payTo: z.string(), - network: NetworkSchema, - }), + x402: z + .object({ + payTo: z.string(), + network: NetworkSchema, + }) + .optional(), + mpp: z + .object({ + recipient: z.string(), + currency: z.string().optional().default("0x20c0000000000000000000000000000000000000"), + methods: z + .array(z.enum(["tempo", "stripe", "lightning"])) + .optional() + .default(["tempo"]), + stripeSecretKey: z.string().optional(), + feePayerKey: z.string().optional(), + optimistic: z.boolean().optional().default(false), + }) + .optional(), build: z .object({ output: z.enum(["standalone", "vercel", "executable"]).optional(), @@ -158,7 +224,13 @@ const AixyzConfigSchema = z.object({ }), ) .default(defaultConfig.skills), -}); +}).refine( + (data) => data.x402 !== undefined || data.mpp !== undefined, + { + message: "At least one of `x402` or `mpp` must be configured", + path: ["x402"], + }, +); type InferredAixyzConfig = z.infer; diff --git a/packages/aixyz/app/index.ts b/packages/aixyz/app/index.ts index b17da5f..e4ecd16 100644 --- a/packages/aixyz/app/index.ts +++ b/packages/aixyz/app/index.ts @@ -1,7 +1,8 @@ import type { AcceptsX402 } from "../accepts"; import type { FacilitatorClient } from "@x402/core/server"; -import { type HttpMethod, type RouteHandler, type Middleware, type RouteEntry } from "./types"; +import { type HttpMethod, type RouteHandler, type Middleware, type RouteEntry, type RoutePaymentOptions } from "./types"; import { PaymentGateway } from "./payment/payment"; +import { MppPaymentGateway } from "./payment/mpp"; import { Network } from "@x402/core/types"; import { getAixyzConfig } from "@aixyz/config"; import { loadEnvConfig } from "@next/env"; @@ -18,40 +19,62 @@ export interface AixyzAppOptions { } /** - * Framework-agnostic route and middleware registry with optional x402 payment gating. - * Call `fetch()` to dispatch a web-standard Request through payment verification, middleware, and route handler. + * Framework-agnostic route and middleware registry with optional payment gating. + * + * Supports two payment protocols, independently or simultaneously: + * - **x402**: HTTP 402 with `X-Payment` headers (EVM/Base, existing behavior) + * - **MPP**: HTTP 402 with `WWW-Authenticate: Payment` headers (Tempo, Stripe, Lightning) + * + * Which protocols are active is determined by what's configured in `aixyz.config.ts`: + * - Only `x402` → x402 only + * - Only `mpp` → MPP only + * - Both → both; dispatched based on incoming request headers + * + * Call `fetch()` to dispatch a web-standard Request through payment verification, + * middleware, and route handler. */ export class AixyzApp { readonly routes = new Map(); readonly payment?: PaymentGateway; + readonly mppPayment?: MppPaymentGateway; private middlewares: Middleware[] = []; private plugins: BasePlugin[] = []; private readonly poweredByHeader: boolean; constructor(options?: AixyzAppOptions) { - // TODO(future): getAiXyzConfig will be materialized. - // this is internal, we control it so it's fine for us to use—but we changing it in the future. const config = getAixyzConfig(); this.poweredByHeader = config.build.poweredByHeader; - if (options?.facilitators) { - this.payment = new PaymentGateway(options.facilitators, config); - this.payment.register((config.x402.network as Network) ?? "eip155:8453"); + // Initialize x402 gateway if configured + if (config.x402) { + const facilitators = options?.facilitators; + if (facilitators) { + this.payment = new PaymentGateway(facilitators, config as any); + this.payment.register((config.x402.network as Network) ?? "eip155:8453"); + } + } + + // Initialize MPP gateway if configured + if (config.mpp) { + this.mppPayment = new MppPaymentGateway(config.mpp); } } - /** Initialize payment gateway and plugins. Must be called after all routes are registered. */ + /** Initialize payment gateways and plugins. Must be called after all routes are registered. */ async initialize(): Promise { if (this.payment) { - // Register payment routes with the gateway before initializing for (const [, entry] of this.routes) { - if (entry.payment) { - this.payment.addRoute(entry.method, entry.path, entry.payment); + if (entry.payment?.x402) { + this.payment.addRoute(entry.method, entry.path, entry.payment.x402); } } await this.payment.initialize(); } + if (this.mppPayment) { + await this.mppPayment.initialize(); + } + for (const plugin of this.plugins) { await plugin.initialize?.(this); } @@ -69,8 +92,22 @@ export class AixyzApp { return `${method} ${path}`; } - /** Register a route with an optional x402 payment requirement. */ - route(method: HttpMethod, path: string, handler: RouteHandler, options?: { payment?: AcceptsX402 }): void { + /** + * Register a route with optional payment requirements. + * + * @example + * // x402 only + * app.route("POST", "/agent", handler, { payment: { x402: { scheme: "exact", price: "$0.005" } } }); + * + * @example + * // MPP only + * app.route("POST", "/agent", handler, { payment: { mppAmount: "0.005" } }); + * + * @example + * // Both (when both x402 and mpp are configured) + * app.route("POST", "/agent", handler, { payment: { x402: { scheme: "exact", price: "$0.005" }, mppAmount: "0.005" } }); + */ + route(method: HttpMethod, path: string, handler: RouteHandler, options?: { payment?: RoutePaymentOptions }): void { const key = this.getRouteKey(method, path); this.routes.set(key, { method, @@ -113,9 +150,9 @@ export class AixyzApp { return new Response("Not Found", { status: 404 }); } - if (entry.payment && this.payment) { - const rejection = await this.payment.verify(request); - if (rejection) return rejection; + if (entry.payment) { + const paymentResponse = await this.verifyPayment(request, entry.payment); + if (paymentResponse) return paymentResponse; } let index = 0; @@ -132,16 +169,116 @@ export class AixyzApp { const response = await next(); - if (entry.payment && this.payment) { + if (entry.payment) { + return this.attachPaymentReceipts(request, response, entry.payment); + } + + return response; + }; + + /** + * Verify payment for a request against the configured protocol(s). + * + * When both x402 and mpp are configured: + * - `Authorization: Payment ...` → MPP path + * - `X-Payment: ...` / `PAYMENT-SIGNATURE: ...` → x402 path + * - Neither → return 402 with challenge(s) for all active protocols + */ + private async verifyPayment(request: Request, payment: RoutePaymentOptions): Promise { + const hasMppCredential = request.headers.get("Authorization")?.startsWith("Payment "); + const hasX402Credential = + request.headers.has("X-Payment") || request.headers.has("PAYMENT-SIGNATURE"); + + const bothConfigured = this.payment && payment.x402 && this.mppPayment && payment.mppAmount; + + if (bothConfigured) { + if (hasMppCredential) { + // Client is speaking MPP + return this.mppPayment!.verify(request, payment.mppAmount!); + } + if (hasX402Credential) { + // Client is speaking x402 + return this.payment!.verify(request); + } + // No credential — return a combined 402 with both challenges + return this.buildCombined402(request, payment); + } + + // Only MPP configured + if (this.mppPayment && payment.mppAmount) { + return this.mppPayment.verify(request, payment.mppAmount); + } + + // Only x402 configured (original behavior) + if (this.payment && payment.x402) { + return this.payment.verify(request); + } + + return null; + } + + /** + * Build a 402 response that includes challenge headers for all active payment protocols. + * Clients that speak either protocol will be able to proceed. + */ + private async buildCombined402(request: Request, payment: RoutePaymentOptions): Promise { + const headers = new Headers(); + headers.set("Content-Type", "application/json"); + + // Get the MPP 402 challenge (WWW-Authenticate: Payment ...) + if (this.mppPayment && payment.mppAmount) { + const mppChallenge = await this.mppPayment.verify(request, payment.mppAmount); + if (mppChallenge?.status === 402) { + const wwwAuth = mppChallenge.headers.get("WWW-Authenticate"); + if (wwwAuth) headers.set("WWW-Authenticate", wwwAuth); + } + } + + // Get the x402 402 challenge (X-Payment-Required: ...) + if (this.payment && payment.x402) { + const x402Challenge = await this.payment.verify(request); + if (x402Challenge?.status === 402) { + for (const [key, value] of x402Challenge.headers.entries()) { + if (key.toLowerCase().startsWith("x-payment")) { + headers.set(key, value); + } + } + } + } + + return new Response( + JSON.stringify({ error: "Payment Required", protocols: ["mpp", "x402"] }), + { status: 402, headers }, + ); + } + + /** + * Attach payment receipt headers to a successful response. + */ + private async attachPaymentReceipts( + request: Request, + response: Response, + payment: RoutePaymentOptions, + ): Promise { + const cloned = new Response(response.body, response); + + // Attach MPP receipt if present + if (this.mppPayment) { + const receipt = this.mppPayment.getReceipt(request); + if (receipt) { + cloned.headers.set("Payment-Receipt", receipt); + } + } + + // Attach x402 settlement header if present + if (this.payment && payment.x402) { const settlementResult = await this.payment.settle(request); if (settlementResult?.success) { const paymentResultHeader = settlementResult.headers["PAYMENT-RESPONSE"]; - const cloned = new Response(response.body, response); cloned.headers.set("PAYMENT-RESPONSE", paymentResultHeader); - return cloned; } } - return response; - }; + return cloned; + } } diff --git a/packages/aixyz/app/payment/mpp.ts b/packages/aixyz/app/payment/mpp.ts new file mode 100644 index 0000000..7f50ab1 --- /dev/null +++ b/packages/aixyz/app/payment/mpp.ts @@ -0,0 +1,117 @@ +import type { AixyzConfig } from "@aixyz/config"; + +/** + * MPP payment gateway for aixyz. + * + * Wraps `mppx/server` to provide an MPP-compatible payment gate. + * Handles the HTTP 402 Challenge/Credential flow using the + * WWW-Authenticate: Payment and Authorization: Payment headers. + * + * Protocol: https://mpp.dev + */ +export class MppPaymentGateway { + private mppx: any; // mppx/server Mppx instance + private readonly config: NonNullable; + + constructor(config: NonNullable) { + this.config = config; + } + + /** + * Initialize the MPP payment gateway. + * Dynamically imports mppx/server and builds payment methods from config. + */ + async initialize(): Promise { + // Dynamic import so mppx remains an optional peer dependency. + // If mpp is configured but mppx isn't installed, throw a clear error. + let mppxServer: any; + try { + mppxServer = await import("mppx/server"); + } catch { + throw new Error( + "[aixyz] MPP is configured but `mppx` is not installed. Run: bun add mppx", + ); + } + + const { Mppx, tempo, stripe, lightning } = mppxServer; + + const methods: any[] = []; + + for (const method of this.config.methods ?? ["tempo"]) { + switch (method) { + case "tempo": { + const feePayerKey = this.config.feePayerKey ?? process.env.MPP_FEE_PAYER_KEY; + methods.push( + tempo({ + currency: this.config.currency ?? process.env.MPP_CURRENCY ?? "0x20c0000000000000000000000000000000000000", + recipient: this.config.recipient ?? process.env.MPP_RECIPIENT, + waitForConfirmation: !(this.config.optimistic ?? false), + ...(feePayerKey ? { feePayer: feePayerKey } : {}), + }), + ); + break; + } + case "stripe": { + const stripeKey = this.config.stripeSecretKey ?? process.env.MPP_STRIPE_SECRET_KEY; + if (!stripeKey) { + throw new Error( + "[aixyz] MPP Stripe method requires a secret key. Set `mpp.stripeSecretKey` or `MPP_STRIPE_SECRET_KEY`.", + ); + } + methods.push(stripe({ secretKey: stripeKey })); + break; + } + case "lightning": { + methods.push(lightning()); + break; + } + } + } + + this.mppx = Mppx.create({ methods }); + } + + /** + * Verify MPP payment for a request. + * + * Returns a 402 Response (with WWW-Authenticate: Payment challenge) if payment + * is required or invalid, or null if the request is authorized to proceed. + */ + async verify(request: Request, amount: string): Promise { + if (!this.mppx) { + throw new Error("MppPaymentGateway not initialized. Call initialize() first."); + } + + // Check if an Authorization: Payment credential is present + const authHeader = request.headers.get("Authorization"); + if (authHeader?.startsWith("Payment ")) { + // Credential present — let mppx verify it + const result = await this.mppx.charge({ amount })(request); + if (result.status === 402) { + // Verification failed (bad credential) + return result; + } + // Store receipt for later attachment + this._pendingReceipts.set(request, result.headers.get("Payment-Receipt") ?? ""); + return null; + } + + // No credential — issue the 402 challenge + const result = await this.mppx.charge({ amount })(request); + if (result.status === 402) { + return result; + } + + return null; + } + + private _pendingReceipts = new WeakMap(); + + /** + * Returns the Payment-Receipt header value for a previously verified request, + * if one was produced by the MPP server. Returns null if not available. + */ + getReceipt(request: Request): string | null { + return this._pendingReceipts.get(request) ?? null; + } +} diff --git a/packages/aixyz/app/payment/payment.ts b/packages/aixyz/app/payment/payment.ts index c62afb8..319333b 100644 --- a/packages/aixyz/app/payment/payment.ts +++ b/packages/aixyz/app/payment/payment.ts @@ -79,6 +79,7 @@ export class PaymentGateway { * Add a payment-gated route. Must be called before initialize(). */ addRoute(method: string, path: string, accepts: AcceptsX402): void { + if (!this.config.x402) return; const pattern = this.getRouteKey(method, path); this.pendingRoutes.set(pattern, { accepts: { diff --git a/packages/aixyz/app/plugins/a2a.ts b/packages/aixyz/app/plugins/a2a.ts index 6172e96..722c513 100644 --- a/packages/aixyz/app/plugins/a2a.ts +++ b/packages/aixyz/app/plugins/a2a.ts @@ -240,7 +240,12 @@ export class A2APlugin extends BasePlugin { return Response.json(result); }, { - payment: this.exports.accepts.scheme === "exact" ? this.exports.accepts : undefined, + payment: this.exports.accepts.scheme === "exact" + ? { + x402: this.exports.accepts, + mppAmount: this.exports.accepts.price.replace(/^\$/, ""), + } + : undefined, }, ); } diff --git a/packages/aixyz/app/plugins/mcp.ts b/packages/aixyz/app/plugins/mcp.ts index 420ad75..e9f2d40 100644 --- a/packages/aixyz/app/plugins/mcp.ts +++ b/packages/aixyz/app/plugins/mcp.ts @@ -90,9 +90,9 @@ export class MCPPlugin extends BasePlugin { const reqs = await resourceServer.buildPaymentRequirements({ scheme: accepts.scheme, - payTo: accepts.payTo ?? config.x402.payTo, + payTo: accepts.payTo ?? config.x402?.payTo, price: accepts.price, - network: (accepts.network as Network) ?? (config.x402.network as Network), + network: (accepts.network as Network) ?? (config.x402?.network as Network), }); this.paymentWrappers.set( diff --git a/packages/aixyz/app/types.ts b/packages/aixyz/app/types.ts index d1edf22..ccb90d9 100644 --- a/packages/aixyz/app/types.ts +++ b/packages/aixyz/app/types.ts @@ -6,9 +6,23 @@ export type RouteHandler = (request: Request) => Response | Promise; export type Middleware = (request: Request, next: () => Promise) => Response | Promise; +export interface RoutePaymentOptions { + /** + * x402 payment requirement for this route. + * Used when `x402` is configured in `aixyz.config.ts`. + */ + x402?: AcceptsX402; + /** + * MPP payment amount for this route (USD string, e.g. "0.005"). + * Used when `mpp` is configured in `aixyz.config.ts`. + * The payment methods accepted are determined by `mpp.methods` in config. + */ + mppAmount?: string; +} + export interface RouteEntry { method: HttpMethod; path: string; handler: RouteHandler; - payment?: AcceptsX402; + payment?: RoutePaymentOptions; } diff --git a/packages/aixyz/package.json b/packages/aixyz/package.json index c86bc4f..ed8a67a 100644 --- a/packages/aixyz/package.json +++ b/packages/aixyz/package.json @@ -61,11 +61,15 @@ }, "peerDependencies": { "@ai-sdk/provider": "^3", - "ai": "^6" + "ai": "^6", + "mppx": "^1" }, "peerDependenciesMeta": { "ai": { "optional": true + }, + "mppx": { + "optional": true } }, "engines": {