diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index 973166f0b..936fbc2ba 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -2,6 +2,7 @@ import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { handleChatStream } from "@/lib/chat/handleChatStream"; +import { withMPP } from "@/lib/mpp/withMPP" /** * OPTIONS handler for CORS preflight requests. @@ -36,6 +37,8 @@ export async function OPTIONS() { * @param request - The request object * @returns A streaming response or error */ -export async function POST(request: NextRequest): Promise { +async function handler(request: NextRequest): Promise { return handleChatStream(request); } + +export const POST = withMPP(handler); diff --git a/app/api/content/create/route.ts b/app/api/content/create/route.ts index 94c2b5217..484541abc 100644 --- a/app/api/content/create/route.ts +++ b/app/api/content/create/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { createContentHandler } from "@/lib/content/createContentHandler"; +import { withMPP } from "@/lib/mpp/withMPP"; /** * OPTIONS handler for CORS preflight requests. @@ -22,10 +23,12 @@ export async function OPTIONS() { * @param request - Incoming API request. * @returns Trigger response for the created task run. */ -export async function POST(request: NextRequest): Promise { +async function handler(request: NextRequest): Promise { return createContentHandler(request); } +export const POST = withMPP(handler); + export const dynamic = "force-dynamic"; export const fetchCache = "force-no-store"; export const revalidate = 0; diff --git a/app/api/image/generate/route.ts b/app/api/image/generate/route.ts index 8de57c14e..5f6cd4edc 100644 --- a/app/api/image/generate/route.ts +++ b/app/api/image/generate/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { x402GenerateImage } from "@/lib/x402/recoup/x402GenerateImage"; import { validateGenerateImageQuery } from "@/lib/image/validateGenerateImageQuery"; +import { withMPP } from "@/lib/mpp/withMPP" /** * OPTIONS handler for CORS preflight requests. @@ -22,7 +23,7 @@ export async function OPTIONS() { * @param request - The request object containing query parameters. * @returns {Promise} JSON response matching the Recoup API format. */ -export async function GET(request: NextRequest) { +async function handler(request: NextRequest) { try { const validatedQuery = validateGenerateImageQuery(request); if (validatedQuery instanceof NextResponse) { @@ -54,3 +55,5 @@ export async function GET(request: NextRequest) { ); } } + +export const GET = withMPP(handler); diff --git a/app/api/x402/image/generate/route.ts b/app/api/x402/image/generate/route.ts index 29cd03272..f7c836f3d 100644 --- a/app/api/x402/image/generate/route.ts +++ b/app/api/x402/image/generate/route.ts @@ -4,6 +4,7 @@ import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { uploadImageAndCreateMoment } from "@/lib/arweave/uploadImageAndCreateMoment"; import { getBuyerAccount } from "@/lib/x402/getBuyerAccount"; import { parseFilesFromQuery } from "@/lib/files/parseFilesFromQuery"; +import { withMPP } from "@/lib/mpp/withMPP" /** * OPTIONS handler for CORS preflight requests. @@ -23,7 +24,7 @@ export async function OPTIONS() { * @param request - The request object containing query parameters. * @returns {Promise} JSON response with generated image URL or error. */ -export async function GET(request: NextRequest) { +async function handler(request: NextRequest) { try { const { searchParams } = new URL(request.url); const prompt = searchParams.get("prompt"); @@ -110,3 +111,5 @@ export async function GET(request: NextRequest) { ); } } + +export const GET = withMPP(handler); diff --git a/lib/mpp/pricing.ts b/lib/mpp/pricing.ts new file mode 100644 index 000000000..4a89fc596 --- /dev/null +++ b/lib/mpp/pricing.ts @@ -0,0 +1,7 @@ +export function getPriceForRoute(path: string): number { + if (path.includes("/ai")) return 0.002 + if (path.includes("/data")) return 0.001 + if (path.includes("/trade")) return 0.01 + + return 0.0005 +} \ No newline at end of file diff --git a/lib/mpp/providers/index.ts b/lib/mpp/providers/index.ts new file mode 100644 index 000000000..7e834471c --- /dev/null +++ b/lib/mpp/providers/index.ts @@ -0,0 +1,5 @@ +import { verifyStripePayment } from "./stripe" + +export async function verifyPayment(payment: string) { + return verifyStripePayment(payment) +} \ No newline at end of file diff --git a/lib/mpp/providers/stripe.ts b/lib/mpp/providers/stripe.ts new file mode 100644 index 000000000..cca5311f8 --- /dev/null +++ b/lib/mpp/providers/stripe.ts @@ -0,0 +1,14 @@ +import Stripe from "stripe" + +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { + apiVersion: "2024-06-20" +}) + +export async function verifyStripePayment(paymentId: string) { + try { + const payment = await stripe.paymentIntents.retrieve(paymentId) + return payment.status === "succeeded" + } catch { + return false + } +} \ No newline at end of file diff --git a/lib/mpp/replay.ts b/lib/mpp/replay.ts new file mode 100644 index 000000000..021bac58c --- /dev/null +++ b/lib/mpp/replay.ts @@ -0,0 +1,7 @@ +const used = new Set() + +export function isReplay(id: string) { + if (used.has(id)) return true + used.add(id) + return false +} \ No newline at end of file diff --git a/lib/mpp/session.ts b/lib/mpp/session.ts new file mode 100644 index 000000000..80b4515fc --- /dev/null +++ b/lib/mpp/session.ts @@ -0,0 +1,44 @@ +import crypto from "crypto" + +type Session = { + id: string + budget: number + spent: number + expiresAt: number +} + +const sessions = new Map() + +export function createSession(budget = 1.0): Session { + const session = { + id: crypto.randomUUID(), + budget, + spent: 0, + expiresAt: Date.now() + 10 * 60 * 1000 + } + + sessions.set(session.id, session) + return session +} + +export function getSession(id: string): Session | null { + const s = sessions.get(id) + if (!s) return null + + if (Date.now() > s.expiresAt) { + sessions.delete(id) + return null + } + + return s +} + +export function chargeSession(id: string, amount: number): boolean { + const s = getSession(id) + if (!s) return false + + if (s.spent + amount > s.budget) return false + + s.spent += amount + return true +} \ No newline at end of file diff --git a/lib/mpp/withMPP.ts b/lib/mpp/withMPP.ts new file mode 100644 index 000000000..856f05cbb --- /dev/null +++ b/lib/mpp/withMPP.ts @@ -0,0 +1,56 @@ +import { getPriceForRoute } from "./pricing" +import { verifyPayment } from "./providers" +import { createSession, getSession, chargeSession } from "./session" +import { isReplay } from "./replay" + +export function withMPP(handler: Function) { + return async (req: Request, context?: any) => { + const path = new URL(req.url).pathname + const price = getPriceForRoute(path) + + const payment = req.headers.get("x-mpp-payment") + const sessionId = req.headers.get("x-mpp-session") + + if (sessionId) { + const session = getSession(sessionId) + + if (!session) { + return Response.json({ error: "invalid_session" }, { status: 402 }) + } + + if (!chargeSession(sessionId, price)) { + return Response.json( + { error: "insufficient_balance", required: price }, + { status: 402 } + ) + } + + return handler(req, context) + } + + if (payment) { + if (isReplay(payment)) { + return Response.json({ error: "replayed_payment" }, { status: 402 }) + } + + const valid = await verifyPayment(payment) + + if (!valid) { + return Response.json({ error: "invalid_payment" }, { status: 402 }) + } + + return handler(req, context) + } + + const session = createSession() + + return Response.json( + { + error: "payment_required", + price, + session + }, + { status: 402 } + ) + } +} \ No newline at end of file