diff --git a/README.md b/README.md index 1dab512..d272d80 100644 --- a/README.md +++ b/README.md @@ -15,16 +15,16 @@ StellarSearch is a pay-per-query web search API for autonomous AI agents. Every ## Real stack (no mocks) -| Layer | Real package / service | -|---|---| -| Payment protocol | `@x402/express` + `@x402/stellar` + `@x402/core` | -| Blockchain | Stellar Testnet (via Horizon API) | -| Facilitator | OpenZeppelin x402 (`channels.openzeppelin.com`) | -| Wallet connect | `@stellar/freighter-api` (real Freighter extension) | -| Balances / tx | Stellar Horizon REST API (live, not mocked) | -| Search results | Serper.dev API (real Google search results) | -| AI assistant | `groq-sdk` · Llama 3.3 70B (real Groq API) | -| Frontend | React 18, TypeScript, Tailwind CSS, Framer Motion | +| Layer | Real package / service | +| ---------------- | --------------------------------------------------- | +| Payment protocol | `@x402/express` + `@x402/stellar` + `@x402/core` | +| Blockchain | Stellar Testnet (via Horizon API) | +| Facilitator | OpenZeppelin x402 (`channels.openzeppelin.com`) | +| Wallet connect | `@stellar/freighter-api` (real Freighter extension) | +| Balances / tx | Stellar Horizon REST API (live, not mocked) | +| Search results | Serper.dev API (real Google search results) | +| AI assistant | `groq-sdk` · Llama 3.3 70B (real Groq API) | +| Frontend | React 18, TypeScript, Tailwind CSS, Framer Motion | --- @@ -40,11 +40,11 @@ npm install ### 2. Get your keys (all free) -| Key | Where to get it | -|---|---| +| Key | Where to get it | +| --------------------------- | ------------------------------------------------------------------------------------------------------------- | | `STELLAR_RECEIVING_ADDRESS` | [Stellar Lab](https://laboratory.stellar.org/#account-creator?network=test) — generate + fund testnet keypair | -| `SERPER_API_KEY` | [serper.dev](https://serper.dev/) — free tier: 2.5k queries/month | -| `GROQ_API_KEY` | [console.groq.com/keys](https://console.groq.com/keys) — free | +| `SERPER_API_KEY` | [serper.dev](https://serper.dev/) — free tier: 2.5k queries/month | +| `GROQ_API_KEY` | [console.groq.com/keys](https://console.groq.com/keys) — free | ### 3. Configure @@ -141,8 +141,12 @@ Browser (Freighter) → GET /search?q=... ### Payment Integrity & Replay Protection To guarantee that each payment identifier authorizes **exactly one provider call**, StellarSearch tracks consumed payment identifiers across Express (`server/index.ts`) and Vercel (`api/search.ts`) runtimes: + - **Payload Invalidation:** Extracts transaction hashes (or SHA-256 fallback hashes of payment headers) and invalidates consumed payloads for a 300-second window. - **Concurrency Throttling:** Rapid parallel requests using identical payment payloads are throttled so only one search query proceeds; concurrent duplicates immediately receive HTTP 402 (`Payment payload already consumed`). +- **Idempotency Keys:** Clients can send `Idempotency-Key` or `X-Idempotency-Key` together with a payer identifier and request params. Repeated in-flight or completed requests for the same logical search return the original response instead of triggering a second settlement. + +Requests bound to the same payer and query parameters must reuse the same idempotency key. The server hashes the route, payer, supplied key, and normalized params to generate a stable idempotent entry, preserving x402 settlement semantics while preventing duplicate charges from browser or proxy retries. ### Client-side duplicate submission guard @@ -498,10 +502,10 @@ The `supply-chain` CI job generates a **CycloneDX SBOM** from the committed lock ## Hackathon requirements -| Requirement | ✓ | -|---|---| -| Open-source repo + README | ✅ | -| 2–3 min video demo | Record showing: connect Freighter → search → see 402 → payment settles → results | -| Real Stellar testnet transactions | ✅ Every search settles 0.001 USDC via OpenZeppelin facilitator | -| x402 protocol | ✅ `@x402/express` + `@x402/stellar` | -| Addresses explicit demand signal | ✅ "pay-per-query web search instead of monthly subscriptions" | +| Requirement | ✓ | +| --------------------------------- | -------------------------------------------------------------------------------- | +| Open-source repo + README | ✅ | +| 2–3 min video demo | Record showing: connect Freighter → search → see 402 → payment settles → results | +| Real Stellar testnet transactions | ✅ Every search settles 0.001 USDC via OpenZeppelin facilitator | +| x402 protocol | ✅ `@x402/express` + `@x402/stellar` | +| Addresses explicit demand signal | ✅ "pay-per-query web search instead of monthly subscriptions" | diff --git a/api/search.ts b/api/search.ts index 1647b6e..825f025 100644 --- a/api/search.ts +++ b/api/search.ts @@ -22,7 +22,6 @@ const AMOUNT_USDC = config.amountUsdc const USDC_CONTRACT = NETWORK === 'stellar:mainnet' ? USDC_CONTRACT_MAINNET : USDC_CONTRACT_TESTNET export default async function handler(req: VercelRequest, res: VercelResponse) { - // ─── CORS ───────────────────────────────────────────────────────────────── res.setHeader('Access-Control-Allow-Origin', '*') res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS') @@ -54,33 +53,34 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { // ─── Payment check ──────────────────────────────────────────────────────── const paymentHeader = - req.headers['payment-signature'] || - req.headers['x-payment'] || - req.headers['X-PAYMENT'] + req.headers["payment-signature"] || + req.headers["x-payment"] || + req.headers["X-PAYMENT"]; if (!paymentHeader) { // Return x402 v2 payment requirements // The key fix: asset must be a Soroban C... contract address, NOT "USDC:ISSUER" const paymentRequired = { x402Version: 2, - error: 'Payment required', + error: "Payment required", resource: { - url: `${req.headers['x-forwarded-proto'] || 'http'}://${req.headers['host']}${req.url}`, - description: 'StellarSearch: pay-per-query web search — 0.001 USDC on Stellar', - mimeType: 'application/json', + url: `${req.headers["x-forwarded-proto"] || "http"}://${req.headers["host"]}${req.url}`, + description: + "StellarSearch: pay-per-query web search — 0.001 USDC on Stellar", + mimeType: "application/json", }, accepts: [ { - scheme: 'exact', - network: NETWORK, // "stellar:testnet" - amount: AMOUNT_STROOPS, // "10000" (stroops, not dollars) - asset: USDC_CONTRACT, // "CBIELTK6..." (Soroban contract) - payTo: RECEIVING_ADDRESS, // your G... address + scheme: "exact", + network: NETWORK, // "stellar:testnet" + amount: AMOUNT_STROOPS, // "10000" (stroops, not dollars) + asset: USDC_CONTRACT, // "CBIELTK6..." (Soroban contract) + payTo: RECEIVING_ADDRESS, // your G... address maxTimeoutSeconds: 300, extra: { areFeesSponsored: true }, }, ], - } + }; res.setHeader( 'PAYMENT-REQUIRED', @@ -91,50 +91,52 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { } // ─── Payment Replay Protection ─────────────────────────────────────────── - const consumption = consumePaymentPayload(paymentHeader) + const consumption = consumePaymentPayload(paymentHeader); if (!consumption.ok) { const errorBody: ApiErrorResponse = { error: consumption.error } return res.status(402).json(errorBody) } // ─── Payment present — proceed with search ──────────────────────────────── - console.log('✅ Payment header received') + console.log("✅ Payment header received"); - let txHash: string | null = null + let txHash: string | null = null; try { - const decoded = Buffer.from(paymentHeader as string, 'base64').toString('utf8') - const parsed = JSON.parse(decoded) - txHash = parsed.transactionHash || parsed.txHash || null + const decoded = Buffer.from(paymentHeader as string, "base64").toString( + "utf8", + ); + const parsed = JSON.parse(decoded); + txHash = parsed.transactionHash || parsed.txHash || null; } catch { // payment header not base64 JSON — fine, tx hash just won't show } - const t0 = Date.now() + const t0 = Date.now(); try { // ─── Serper.dev ────────────────────────────────────────────────────────── const requestBody: Record = { - q: q.trim(), + q: q.trim(), num: Math.min(parseInt(count) || 5, 20), - } + }; if (freshness) { const dateFilters: Record = { - pd: 'qdr:d', // past day - pw: 'qdr:w', // past week - pm: 'qdr:m', // past month - } - if (dateFilters[freshness]) requestBody.tbs = dateFilters[freshness] + pd: "qdr:d", // past day + pw: "qdr:w", // past week + pm: "qdr:m", // past month + }; + if (dateFilters[freshness]) requestBody.tbs = dateFilters[freshness]; } - const serperRes = await fetch('https://google.serper.dev/search', { - method: 'POST', + const serperRes = await fetch("https://google.serper.dev/search", { + method: "POST", headers: { - 'X-API-KEY': SERPER_API_KEY, - 'Content-Type': 'application/json', + "X-API-KEY": SERPER_API_KEY, + "Content-Type": "application/json", }, body: JSON.stringify(requestBody), - }) + }); if (!serperRes.ok) { const errText = await serperRes.text() @@ -166,6 +168,8 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { return res.json(responseBody) + if (idempotentKey) resolveIdempotentRequest(idempotentKey, response); + return res.json(response); } catch (err: any) { console.error('[search error]', err.message) const errorBody: ApiErrorResponse = { error: 'Search failed.' } diff --git a/server/corsConfig.ts b/server/corsConfig.ts index 7312613..74ed6fa 100644 --- a/server/corsConfig.ts +++ b/server/corsConfig.ts @@ -2,46 +2,51 @@ * CORS configuration — dev uses wildcard; production uses ALLOWED_ORIGINS allowlist. */ -import type { CorsOptions } from 'cors' +import type { CorsOptions } from "cors"; const CORS_ALLOWED_HEADERS = [ - 'Content-Type', - 'Authorization', - 'X-Payment', - 'payment-signature', - 'x-payment', - 'X-PAYMENT', -] as const + "Content-Type", + "Authorization", + "X-Payment", + "payment-signature", + "x-payment", + "X-PAYMENT", + "Idempotency-Key", + "X-Idempotency-Key", + "x-idempotency-key", + "X-Wallet-Address", + "x-wallet-address", +] as const; const CORS_EXPOSED_HEADERS = [ - 'PAYMENT-REQUIRED', - 'X-Payment-Response', -] as const + "PAYMENT-REQUIRED", + "X-Payment-Response", +] as const; -const CORS_METHODS = ['GET', 'POST', 'OPTIONS'] as const +const CORS_METHODS = ["GET", "POST", "OPTIONS"] as const; export function parseAllowedOrigins(raw?: string): string[] { - return (raw ?? '') - .split(',') + return (raw ?? "") + .split(",") .map((entry) => entry.trim()) - .filter(Boolean) + .filter(Boolean); } export function isProductionEnv(): boolean { - return process.env.NODE_ENV === 'production' + return process.env.NODE_ENV === "production"; } export function getCorsStartupMessage(): string { if (!isProductionEnv()) { - return 'CORS: * (development)' + return "CORS: * (development)"; } - const allowed = parseAllowedOrigins(process.env.ALLOWED_ORIGINS) + const allowed = parseAllowedOrigins(process.env.ALLOWED_ORIGINS); if (allowed.length === 0) { - return 'CORS: allowlist empty — cross-origin browser requests blocked' + return "CORS: allowlist empty — cross-origin browser requests blocked"; } - return `CORS: allowlist (${allowed.length} origin${allowed.length === 1 ? '' : 's'})` + return `CORS: allowlist (${allowed.length} origin${allowed.length === 1 ? "" : "s"})`; } export function buildCorsOptions(): CorsOptions { @@ -49,29 +54,29 @@ export function buildCorsOptions(): CorsOptions { allowedHeaders: [...CORS_ALLOWED_HEADERS], exposedHeaders: [...CORS_EXPOSED_HEADERS], methods: [...CORS_METHODS], - } + }; if (!isProductionEnv()) { - return { ...base, origin: '*' } + return { ...base, origin: "*" }; } - const allowed = parseAllowedOrigins(process.env.ALLOWED_ORIGINS) + const allowed = parseAllowedOrigins(process.env.ALLOWED_ORIGINS); if (allowed.length === 0) { console.warn( - '[cors] ALLOWED_ORIGINS is empty in production — blocking cross-origin browser requests', - ) + "[cors] ALLOWED_ORIGINS is empty in production — blocking cross-origin browser requests", + ); } return { ...base, origin(origin, callback) { if (!origin) { - callback(null, true) - return + callback(null, true); + return; } - callback(null, allowed.includes(origin)) + callback(null, allowed.includes(origin)); }, - } + }; } diff --git a/server/index.ts b/server/index.ts index 1725765..009918c 100644 --- a/server/index.ts +++ b/server/index.ts @@ -76,10 +76,12 @@ const limiter = rateLimit({ standardHeaders: true, legacyHeaders: true, handler: (_req: Request, res: Response) => { - res.setHeader('Retry-After', '60') - res.status(429).json({ error: 'Too many requests, please try again later.' }) + res.setHeader("Retry-After", "60"); + res + .status(429) + .json({ error: "Too many requests, please try again later." }); }, -}) +}); // ─── Security Headers & Middleware ──────────────────────────────────────── app.use( @@ -89,27 +91,27 @@ app.use( defaultSrc: ["'self'"], connectSrc: [ "'self'", - 'https://horizon-testnet.stellar.org', - 'https://horizon.stellar.org', - 'https://soroban-testnet.stellar.org', - 'https://soroban-rpc.mainnet.stellar.org', - 'https://google.serper.dev', - 'https://www.x402.org', - 'https://channels.openzeppelin.com', - 'http://localhost:*', - 'ws://localhost:*', + "https://horizon-testnet.stellar.org", + "https://horizon.stellar.org", + "https://soroban-testnet.stellar.org", + "https://soroban-rpc.mainnet.stellar.org", + "https://google.serper.dev", + "https://www.x402.org", + "https://channels.openzeppelin.com", + "http://localhost:*", + "ws://localhost:*", ], scriptSrc: ["'self'", "'unsafe-inline'"], styleSrc: ["'self'", "'unsafe-inline'"], - imgSrc: ["'self'", 'data:', 'https:'], + imgSrc: ["'self'", "data:", "https:"], }, }, - crossOriginResourcePolicy: { policy: 'cross-origin' }, - }) -) -app.use(cors(buildCorsOptions())) -app.use(express.json()) -app.use(limiter) + crossOriginResourcePolicy: { policy: "cross-origin" }, + }), +); +app.use(cors(buildCorsOptions())); +app.use(express.json()); +app.use(limiter); // ─── In-memory stats ────────────────────────────────────────────────────── const stats = { @@ -117,7 +119,7 @@ const stats = { totalUsdcSettled: 0, latencies: [] as number[], startTime: Date.now(), -} +}; // ─── Batch idempotency & async job stores (issues #324, #325) ──────────── export const MAX_BATCH_SIZE = 10 @@ -274,24 +276,26 @@ const groq = GROQ_API_KEY ? new Groq({ apiKey: GROQ_API_KEY }) : undefined // ─── x402 payment guard on /search ─────────────────────────────────────── // paymentMiddlewareFromConfig is the recommended API per official Stellar docs. // It uses the Coinbase public facilitator (no API key needed for testnet). -const x402Accepts = [{ - scheme: 'exact', - price: parseFloat(AMOUNT_USDC), - amount: AMOUNT_STROOPS, - network: NETWORK, - payTo: RECEIVING_ADDRESS, -}] +const x402Accepts = [ + { + scheme: "exact", + price: parseFloat(AMOUNT_USDC), + amount: AMOUNT_STROOPS, + network: NETWORK, + payTo: RECEIVING_ADDRESS, + }, +]; const x402Routes = { - 'GET /search': { + "GET /search": { accepts: x402Accepts, description: `StellarSearch: pay-per-query web search — ${AMOUNT_USDC} USDC on Stellar`, }, - 'GET /images': { + "GET /images": { accepts: x402Accepts, description: `StellarSearch: pay-per-query image search — ${AMOUNT_USDC} USDC on Stellar`, }, - 'GET /news': { + "GET /news": { accepts: x402Accepts, description: `StellarSearch: pay-per-query news search — ${AMOUNT_USDC} USDC on Stellar`, }, @@ -311,23 +315,23 @@ const x402Routes = { }, } -const facilitatorClient = new HTTPFacilitatorClient({ url: FACILITATOR_URL }) -const schemes = [{ network: NETWORK, server: new ExactStellarScheme() }] +const facilitatorClient = new HTTPFacilitatorClient({ url: FACILITATOR_URL }); +const schemes = [{ network: NETWORK, server: new ExactStellarScheme() }]; // Apply middleware to all routes, not just /search // ─── Payment Logging Middleware ────────────────────────────────────────── app.use((req, res, next) => { - if (req.path === '/search') { + if (req.path === "/search") { const { q } = req.query as Record; - const truncatedQ = q ? String(q).substring(0, 50) : ''; + const truncatedQ = q ? String(q).substring(0, 50) : ""; - res.on('finish', () => { - let paymentStatus = 'error'; - if (res.statusCode === 200) paymentStatus = 'paid'; - else if (res.statusCode === 402) paymentStatus = '402'; + res.on("finish", () => { + let paymentStatus = "error"; + if (res.statusCode === 200) paymentStatus = "paid"; + else if (res.statusCode === 402) paymentStatus = "402"; - logger.info('Payment attempt', { + logger.info("Payment attempt", { timestamp: new Date().toISOString(), ip: req.ip, query: truncatedQ, @@ -338,31 +342,31 @@ app.use((req, res, next) => { next(); }); -app.use(paymentMiddlewareFromConfig(x402Routes, facilitatorClient, schemes)) +app.use(paymentMiddlewareFromConfig(x402Routes, facilitatorClient, schemes)); // ─── Payment Replay Protection Middleware ───────────────────────────────── app.use((req, res, next) => { - const paidRoutes = ['/search', '/images', '/news'] + const paidRoutes = ["/search", "/images", "/news"]; if (paidRoutes.includes(req.path)) { const paymentHeader = - req.headers['payment-signature'] || - req.headers['x-payment'] || - req.headers['X-PAYMENT'] || - req.headers['x-payment-response'] || - req.headers['authorization'] + req.headers["payment-signature"] || + req.headers["x-payment"] || + req.headers["X-PAYMENT"] || + req.headers["x-payment-response"] || + req.headers["authorization"]; if (paymentHeader) { - const consumption = consumePaymentPayload(paymentHeader) + const consumption = consumePaymentPayload(paymentHeader); if (!consumption.ok) { - return res.status(402).json({ error: consumption.error }) + return res.status(402).json({ error: consumption.error }); } // Captured for reconciliation — links this request to the settled // payment identifier without ever touching query content. ;(req as any).paymentId = consumption.paymentId } } - next() -}) + next(); +}); // Builds and persists a ReconciliationRecord for a paid route. Never throws — // a logging failure must not affect the response already sent to the client. @@ -402,19 +406,22 @@ export const MAX_QUERY_LENGTH = 256 export function validateQuery( q: unknown, ): { ok: true; cleanQ: string } | { ok: false; error: string } { - if (typeof q !== 'string' || !q.trim()) { - return { ok: false, error: 'Missing required parameter: q' } + if (typeof q !== "string" || !q.trim()) { + return { ok: false, error: "Missing required parameter: q" }; } if (q.length > MAX_QUERY_LENGTH) { - return { ok: false, error: `Query too long. Maximum ${MAX_QUERY_LENGTH} characters.` } + return { + ok: false, + error: `Query too long. Maximum ${MAX_QUERY_LENGTH} characters.`, + }; } // Strip null bytes and ASCII control characters (C0 + DEL) to prevent // log injection and odd Serper behavior. - const cleanQ = q.replace(/[\x00-\x1F\x7F]/g, '').trim() + const cleanQ = q.replace(/[\x00-\x1F\x7F]/g, "").trim(); if (!cleanQ) { - return { ok: false, error: 'Query contains no valid characters.' } + return { ok: false, error: "Query contains no valid characters." }; } - return { ok: true, cleanQ } + return { ok: true, cleanQ }; } // ─── GET /search ────────────────────────────────────────────────────────── @@ -439,28 +446,28 @@ app.get('/search', async (req: Request, res: Response) => { const requestBody: Record = { q: cleanQ, num: Math.min(parseInt(count) || 5, 20), - } + }; // Add freshness filter if provided (Serper supports date filters) if (freshness) { const dateFilters: Record = { - 'pd': 'qdr:d', // past day - 'pw': 'qdr:w', // past week - 'pm': 'qdr:m', // past month - } + pd: "qdr:d", // past day + pw: "qdr:w", // past week + pm: "qdr:m", // past month + }; if (dateFilters[freshness]) { - requestBody.tbs = dateFilters[freshness] + requestBody.tbs = dateFilters[freshness]; } } - const serperRes = await fetch('https://google.serper.dev/search', { - method: 'POST', + const serperRes = await fetch("https://google.serper.dev/search", { + method: "POST", headers: { - 'X-API-KEY': SERPER_API_KEY, - 'Content-Type': 'application/json', + "X-API-KEY": SERPER_API_KEY, + "Content-Type": "application/json", }, body: JSON.stringify(requestBody), - }) + }); if (!serperRes.ok) { const err = await serperRes.text() @@ -472,10 +479,10 @@ app.get('/search', async (req: Request, res: Response) => { const data: unknown = await serperRes.json() const latencyMs = Date.now() - t0 - stats.totalQueries++ - stats.totalUsdcSettled += 0.001 - stats.latencies.push(latencyMs) - if (stats.latencies.length > 200) stats.latencies.shift() + stats.totalQueries++; + stats.totalUsdcSettled += 0.001; + stats.latencies.push(latencyMs); + if (stats.latencies.length > 200) stats.latencies.shift(); const results = normalizeOrganicResults(data) const queryMeta = normalizeQueryMetadata(data, cleanQ) @@ -484,16 +491,17 @@ app.get('/search', async (req: Request, res: Response) => { txHash = (req.headers['x-payment-response'] as string) || null // ── Optional AI suggestions via Groq ────────────────────────────────── - let suggestions: string[] = [] - if (req.query.suggestions === '1' && results.length > 0) { + let suggestions: string[] = []; + if (req.query.suggestions === "1" && results.length > 0) { try { const topSnippets = results.slice(0, 3).map((r) => r.description).join(' | ') const suggCompletion = await groq.chat.completions.create({ - model: 'llama-3.3-70b-versatile', + model: "llama-3.3-70b-versatile", messages: [ { - role: 'system', - content: 'You are a search assistant. Given a query and top result snippets, return exactly 3 related search queries the user might want to explore next. Output only a JSON array of 3 strings, no explanation.', + role: "system", + content: + "You are a search assistant. Given a query and top result snippets, return exactly 3 related search queries the user might want to explore next. Output only a JSON array of 3 strings, no explanation.", }, { role: 'user', @@ -515,7 +523,7 @@ app.get('/search', async (req: Request, res: Response) => { } } } catch (err: any) { - console.warn('[suggestions] Groq error:', err.message) + console.warn("[suggestions] Groq error:", err.message); } } @@ -529,7 +537,7 @@ app.get('/search', async (req: Request, res: Response) => { count: results.length, network: NETWORK, paidAmount: AMOUNT_USDC, - currency: 'USDC', + currency: "USDC", txHash, latencyMs, suggestions, @@ -552,7 +560,7 @@ app.get('/search', async (req: Request, res: Response) => { } finally { recordReconciliation({ req, route: '/search', requestId, providerDelivered, resultCount, txHash }) } -}) +}); // ─── GET /images ────────────────────────────────────────────────────────── app.get('/images', async (req: Request, res: Response) => { @@ -576,14 +584,14 @@ app.get('/images', async (req: Request, res: Response) => { const serperRes = await fetch('https://google.serper.dev/images', { method: 'POST', headers: { - 'X-API-KEY': SERPER_API_KEY, - 'Content-Type': 'application/json', + "X-API-KEY": SERPER_API_KEY, + "Content-Type": "application/json", }, body: JSON.stringify({ q: cleanQ, num: Math.min(parseInt(count) || 10, 10), }), - }) + }); if (!serperRes.ok) { const err = await serperRes.text() @@ -595,10 +603,10 @@ app.get('/images', async (req: Request, res: Response) => { const data: unknown = await serperRes.json() const latencyMs = Date.now() - t0 - stats.totalQueries++ - stats.totalUsdcSettled += parseFloat(AMOUNT_USDC) - stats.latencies.push(latencyMs) - if (stats.latencies.length > 200) stats.latencies.shift() + stats.totalQueries++; + stats.totalUsdcSettled += parseFloat(AMOUNT_USDC); + stats.latencies.push(latencyMs); + if (stats.latencies.length > 200) stats.latencies.shift(); const results = normalizeImageResults(data) @@ -610,7 +618,7 @@ app.get('/images', async (req: Request, res: Response) => { count: results.length, network: NETWORK, paidAmount: AMOUNT_USDC, - currency: 'USDC', + currency: "USDC", txHash, latencyMs, } @@ -625,7 +633,7 @@ app.get('/images', async (req: Request, res: Response) => { } finally { recordReconciliation({ req, route: '/images', requestId, providerDelivered, resultCount, txHash }) } -}) +}); // ─── GET /news ──────────────────────────────────────────────────────────── app.get('/news', async (req: Request, res: Response) => { @@ -649,27 +657,27 @@ app.get('/news', async (req: Request, res: Response) => { const requestBody: Record = { q: cleanQ, num: Math.min(parseInt(count) || 10, 20), - } + }; if (freshness) { const dateFilters: Record = { - 'pd': 'qdr:d', - 'pw': 'qdr:w', - 'pm': 'qdr:m', - } + pd: "qdr:d", + pw: "qdr:w", + pm: "qdr:m", + }; if (dateFilters[freshness]) { - requestBody.tbs = dateFilters[freshness] + requestBody.tbs = dateFilters[freshness]; } } - const serperRes = await fetch('https://google.serper.dev/news', { - method: 'POST', + const serperRes = await fetch("https://google.serper.dev/news", { + method: "POST", headers: { - 'X-API-KEY': SERPER_API_KEY, - 'Content-Type': 'application/json', + "X-API-KEY": SERPER_API_KEY, + "Content-Type": "application/json", }, body: JSON.stringify(requestBody), - }) + }); if (!serperRes.ok) { const err = await serperRes.text() @@ -681,10 +689,10 @@ app.get('/news', async (req: Request, res: Response) => { const data: unknown = await serperRes.json() const latencyMs = Date.now() - t0 - stats.totalQueries++ - stats.totalUsdcSettled += parseFloat(AMOUNT_USDC) - stats.latencies.push(latencyMs) - if (stats.latencies.length > 200) stats.latencies.shift() + stats.totalQueries++; + stats.totalUsdcSettled += parseFloat(AMOUNT_USDC); + stats.latencies.push(latencyMs); + if (stats.latencies.length > 200) stats.latencies.shift(); const results = normalizeNewsResults(data) @@ -696,7 +704,7 @@ app.get('/news', async (req: Request, res: Response) => { count: results.length, network: NETWORK, paidAmount: AMOUNT_USDC, - currency: 'USDC', + currency: "USDC", txHash, latencyMs, } @@ -1058,29 +1066,36 @@ app.get('/jobs', (_req: Request, res: Response) => { }) // ─── GET /health ────────────────────────────────────────────────────────── -app.get('/health', (_req: Request, res: Response) => { +app.get("/health", (_req: Request, res: Response) => { const avg = stats.latencies.length - ? Math.round(stats.latencies.reduce((a, b) => a + b, 0) / stats.latencies.length) - : 0 - - const up = Math.floor((Date.now() - stats.startTime) / 1000) - const uptime = up < 60 ? `${up}s` : up < 3600 ? `${Math.floor(up / 60)}m` : `${Math.floor(up / 3600)}h` + ? Math.round( + stats.latencies.reduce((a, b) => a + b, 0) / stats.latencies.length, + ) + : 0; + + const up = Math.floor((Date.now() - stats.startTime) / 1000); + const uptime = + up < 60 + ? `${up}s` + : up < 3600 + ? `${Math.floor(up / 60)}m` + : `${Math.floor(up / 3600)}h`; res.json({ - status: 'ok', - network: NETWORK, - pricePerQuery: '0.001 USDC', - protocol: 'x402', - facilitator: FACILITATOR_URL, - totalQueries: stats.totalQueries, - totalUsdcSettled: stats.totalUsdcSettled.toFixed(4), - avgLatencyMs: avg, + status: "ok", + network: NETWORK, + pricePerQuery: "0.001 USDC", + protocol: "x402", + facilitator: FACILITATOR_URL, + totalQueries: stats.totalQueries, + totalUsdcSettled: stats.totalUsdcSettled.toFixed(4), + avgLatencyMs: avg, uptime, - serperApiConfigured: !!SERPER_API_KEY, - groqApiConfigured: !!GROQ_API_KEY, + serperApiConfigured: !!SERPER_API_KEY, + groqApiConfigured: !!GROQ_API_KEY, receivingAddressConfigured: !!RECEIVING_ADDRESS, - }) -}) + }); +}); // ─── POST /ai/chat ──────────────────────────────────────────────────────── // Streams responses as Server-Sent Events when the client sends @@ -1091,25 +1106,26 @@ app.post('/ai/chat', async (req: Request, res: Response) => { return res.status(503).json({ error: 'AI assistant is not configured.' }) } const { messages, model: requestedModel } = req.body as { - messages: { role: 'system' | 'user' | 'assistant'; content: string }[] - model?: string - } + messages: { role: "system" | "user" | "assistant"; content: string }[]; + model?: string; + }; if (!messages?.length) { - return res.status(400).json({ error: 'messages array required' }) + return res.status(400).json({ error: "messages array required" }); } // Available models whitelist const AVAILABLE_MODELS = [ - 'llama-3.3-70b-versatile', - 'llama-3.1-8b-instant', - 'mixtral-8x7b-32768', - ] - + "llama-3.3-70b-versatile", + "llama-3.1-8b-instant", + "mixtral-8x7b-32768", + ]; + // Use requested model if valid, otherwise fall back to default - const model = requestedModel && AVAILABLE_MODELS.includes(requestedModel) - ? requestedModel - : 'llama-3.3-70b-versatile' + const model = + requestedModel && AVAILABLE_MODELS.includes(requestedModel) + ? requestedModel + : "llama-3.3-70b-versatile"; const wantsStream = (req.headers.accept || '').includes('text/event-stream') || @@ -1118,82 +1134,79 @@ app.post('/ai/chat', async (req: Request, res: Response) => { const groqMessages = [ { - role: 'system' as const, + role: "system" as const, content: - 'You are StellarSearch AI, a concise research assistant. Help users craft better search queries and understand results. Keep responses under 200 words.', + "You are StellarSearch AI, a concise research assistant. Help users craft better search queries and understand results. Keep responses under 200 words.", }, ...messages, - ] + ]; if (!wantsStream) { try { const completion = await groq.chat.completions.create({ model, messages: groqMessages, - max_tokens: 512, + max_tokens: 512, temperature: 0.7, - }) + }); - const content = completion.choices[0]?.message?.content || 'No response.' - return res.json({ content, model: completion.model }) + const content = completion.choices[0]?.message?.content || "No response."; + return res.json({ content, model: completion.model }); } catch (err: any) { - console.error('[groq error]', err.message) - return res.status(500).json({ error: `Groq AI error: ${err.message}` }) + console.error("[groq error]", err.message); + return res.status(500).json({ error: `Groq AI error: ${err.message}` }); } } // SSE path - res.setHeader('Content-Type', 'text/event-stream') - res.setHeader('Cache-Control', 'no-cache, no-transform') - res.setHeader('Connection', 'keep-alive') + res.setHeader("Content-Type", "text/event-stream"); + res.setHeader("Cache-Control", "no-cache, no-transform"); + res.setHeader("Connection", "keep-alive"); // Disable proxy buffering (e.g. nginx) so chunks flush immediately - res.setHeader('X-Accel-Buffering', 'no') - res.flushHeaders?.() + res.setHeader("X-Accel-Buffering", "no"); + res.flushHeaders?.(); const sendEvent = (event: string, data: Record) => { - res.write(`event: ${event}\n`) - res.write(`data: ${JSON.stringify(data)}\n\n`) - } + res.write(`event: ${event}\n`); + res.write(`data: ${JSON.stringify(data)}\n\n`); + }; // Abort the Groq stream if the client disconnects mid-response. - const controller = new AbortController() - req.on('close', () => controller.abort()) + const controller = new AbortController(); + req.on("close", () => controller.abort()); try { const stream = await groq.chat.completions.create( { model, messages: groqMessages, - max_tokens: 512, + max_tokens: 512, temperature: 0.7, stream: true, }, { signal: controller.signal }, - ) + ); for await (const chunk of stream) { - const delta = chunk.choices[0]?.delta?.content - if (delta) sendEvent('delta', { content: delta }) + const delta = chunk.choices[0]?.delta?.content; + if (delta) sendEvent("delta", { content: delta }); } - sendEvent('done', { model }) - res.end() + sendEvent("done", { model }); + res.end(); } catch (err: any) { - if (controller.signal.aborted) return res.end() - console.error('[groq stream error]', err.message) - sendEvent('error', { error: `Groq AI error: ${err.message}` }) - res.end() + if (controller.signal.aborted) return res.end(); + console.error("[groq stream error]", err.message); + sendEvent("error", { error: `Groq AI error: ${err.message}` }); + res.end(); } -}) - - - +}); // ─── GET / ──────────────────────────────────────────────────────────────── -app.get('/', (_req: Request, res: Response) => { +app.get("/", (_req: Request, res: Response) => { res.json({ - name: 'StellarSearch', - version: '1.0.0', - description: 'Pay-per-query web search for AI agents via x402 on Stellar', + name: "StellarSearch", + version: "1.0.0", + description: "Pay-per-query web search for AI agents via x402 on Stellar", endpoints: { 'GET /search?q=': '0.001 USDC via x402', 'GET /images?q=': '0.001 USDC via x402 — image results', @@ -1214,16 +1227,16 @@ app.get('/', (_req: Request, res: Response) => { }) // ─── Start ──────────────────────────────────────────────────────────────── -if (process.env.NODE_ENV !== 'production' && process.env.NODE_ENV !== 'test') { +if (process.env.NODE_ENV !== "production" && process.env.NODE_ENV !== "test") { app.listen(PORT, () => { - console.log(`\n🚀 StellarSearch on http://localhost:${PORT}`) - console.log(` Network: ${NETWORK}`) - console.log(` Facilitator: ${FACILITATOR_URL}`) - console.log(` Serper: ${SERPER_API_KEY ? '✓' : '✗ MISSING'}`) - console.log(` Groq: ${GROQ_API_KEY ? '✓' : '✗ MISSING'}`) - console.log(` Receiving: ${RECEIVING_ADDRESS || '✗ MISSING'}`) - console.log(` ${getCorsStartupMessage()}\n`) - }) + console.log(`\n🚀 StellarSearch on http://localhost:${PORT}`); + console.log(` Network: ${NETWORK}`); + console.log(` Facilitator: ${FACILITATOR_URL}`); + console.log(` Serper: ${SERPER_API_KEY ? "✓" : "✗ MISSING"}`); + console.log(` Groq: ${GROQ_API_KEY ? "✓" : "✗ MISSING"}`); + console.log(` Receiving: ${RECEIVING_ADDRESS || "✗ MISSING"}`); + console.log(` ${getCorsStartupMessage()}\n`); + }); } -export default app +export default app; diff --git a/src/hooks/useSearch.ts b/src/hooks/useSearch.ts index 879b452..08b1d00 100644 --- a/src/hooks/useSearch.ts +++ b/src/hooks/useSearch.ts @@ -19,9 +19,9 @@ import { IS_MAINNET, EXPECTED_WALLET_NETWORK, explorerTxUrl } from '../lib/stell const SERVER_URL = readBrowserConfig().apiBaseUrl // Soroban RPC URLs -const SOROBAN_RPC_TESTNET = 'https://soroban-testnet.stellar.org' -const SOROBAN_RPC_MAINNET = 'https://soroban-rpc.mainnet.stellar.org' // Or another public RPC -const SOROBAN_RPC_URL = IS_MAINNET ? SOROBAN_RPC_MAINNET : SOROBAN_RPC_TESTNET +const SOROBAN_RPC_TESTNET = "https://soroban-testnet.stellar.org"; +const SOROBAN_RPC_MAINNET = "https://soroban-rpc.mainnet.stellar.org"; // Or another public RPC +const SOROBAN_RPC_URL = IS_MAINNET ? SOROBAN_RPC_MAINNET : SOROBAN_RPC_TESTNET; import type { SearchResult, SearchReceipt, SearchResponse, PaymentStep, SearchSession, SearchMode } from '../types' @@ -96,8 +96,13 @@ function releaseSearchLock(id: string): void { */ export function useSearch(walletAddress: string | null = null) { const [session, setSession] = useState({ - query: '', results: [], txHash: null, paidAmount: null, status: 'idle', suggestions: [], - }) + query: "", + results: [], + txHash: null, + paidAmount: null, + status: "idle", + suggestions: [], + }); // Synchronous same-tab guard. React state (`session.status`) updates are // batched/async, so a double Enter or double click can both pass the @@ -124,13 +129,13 @@ export function useSearch(walletAddress: string | null = null) { } inFlightRef.current = true - let freshness = '' - let count = countOverride + let freshness = ""; + let count = countOverride; - if (typeof freshnessOrCount === 'string') { - freshness = freshnessOrCount - } else if (typeof freshnessOrCount === 'number') { - count = freshnessOrCount + if (typeof freshnessOrCount === "string") { + freshness = freshnessOrCount; + } else if (typeof freshnessOrCount === "number") { + count = freshnessOrCount; } setSession({ @@ -142,7 +147,7 @@ export function useSearch(walletAddress: string | null = null) { results: [], txHash: null, paidAmount: null, - status: 'searching', + status: "searching", step: 1, suggestions: [], }) @@ -156,7 +161,7 @@ export function useSearch(walletAddress: string | null = null) { suggestions: mode === 'web' ? '1' : '0', }) if (freshness) { - params.set('freshness', freshness) + params.set("freshness", freshness); } const advance = (step: PaymentStep) => @@ -194,8 +199,9 @@ export function useSearch(walletAddress: string | null = null) { networkPassphrase: opts?.networkPassphrase ?? passphrase, }) - if (result.error) throw new Error(result.error.message) - if (!result.signedAuthEntry) throw new Error('Freighter returned no signedAuthEntry') + try { + if (!walletAddress) + throw new Error("Connect your Freighter wallet first."); console.log('✅ Freighter signed. Type:', typeof result.signedAuthEntry) @@ -243,39 +249,87 @@ export function useSearch(walletAddress: string | null = null) { }) } - // Flow step 2 — parse the PAYMENT-REQUIRED header - advance(2) - console.log('💰 402 received, parsing payment requirements...') - const paymentRequired = httpClient.getPaymentRequiredResponse( - (name) => firstRes.headers.get(name) - ) - console.log('💰 Payment requirements:', paymentRequired) - - // Flow step 3 — createPaymentPayload() triggers the Freighter popup (signs auth entry) - advance(3) - console.log('🔐 Triggering Freighter popup via createPaymentPayload...') - const paymentPayload = await client.createPaymentPayload(paymentRequired) - console.log('✅ Freighter approved, payload created') - - const paymentHeaders = httpClient.encodePaymentSignatureHeader(paymentPayload) - console.log('✅ Payment headers encoded') - - // Flow step 4 — retry with X-PAYMENT header - advance(4) - console.log('🔄 Retrying with payment...') - const paidResPromise = fetch(`${SERVER_URL}/search?${params}`, { - headers: paymentHeaders, - }) - - // Flow step 5 — facilitator settles on Stellar while the retry is in flight - advance(5) - const paidRes = await paidResPromise - console.log('📡 Paid response status:', paidRes.status) - - if (!paidRes.ok) { - const text = await paidRes.text() - throw new Error(`Payment failed: server returned ${paidRes.status} — ${text}`) - } + // Step 1 — verify Freighter is on correct network + const net = await getNetworkDetails(); + if (net.error) throw new Error(net.error.message); + if (net.network !== EXPECTED_WALLET_NETWORK) { + throw new Error( + `Switch Freighter to ${EXPECTED_WALLET_NETWORK}. Currently: ${net.network}`, + ); + } + console.log("✅ Network verified:", net.network); + + // Step 2 — build the signer + const passphrase = IS_MAINNET ? Networks.PUBLIC : Networks.TESTNET; + const signer = { + address: walletAddress, + signAuthEntry: async ( + xdr: string, + opts?: { networkPassphrase?: string }, + ): Promise<{ signedAuthEntry: string; signerAddress: string }> => { + console.log("🔑 Calling Freighter signAuthEntry..."); + + const result = await signAuthEntry(xdr, { + networkPassphrase: opts?.networkPassphrase ?? passphrase, + }); + + if (result.error) throw new Error(result.error.message); + if (!result.signedAuthEntry) + throw new Error("Freighter returned no signedAuthEntry"); + + console.log( + "✅ Freighter signed. Type:", + typeof result.signedAuthEntry, + ); + + const raw = result.signedAuthEntry; + const signedAuthEntry = + typeof raw === "string" + ? raw + : Buffer.from(raw as unknown as Uint8Array).toString("base64"); + + console.log( + "✅ signedAuthEntry base64 length:", + signedAuthEntry.length, + ); + + return { signedAuthEntry, signerAddress: walletAddress }; + }, + }; + + // Step 3 — build the x402 client with correct .register() chain + const client = new x402Client().register( + "stellar:*", + new ExactStellarScheme(signer, { url: SOROBAN_RPC_URL }), + ); + const httpClient = new x402HTTPClient(client); + console.log("✅ x402 client built"); + + // Flow step 1 — initial request, expect 402 + advance(1); + console.log("🚀 Initial request:", `${SERVER_URL}/search?${params}`); + const firstRes = await fetch(`${SERVER_URL}/search?${params}`, { + headers: { + "X-Idempotency-Key": idempotencyKey, + "x-wallet-address": walletAddress, + }, + }); + console.log("📡 Status:", firstRes.status); + + if (firstRes.status !== 402) { + if (!firstRes.ok) throw new Error(`Server error ${firstRes.status}`); + const data = await firstRes.json(); + return setSession({ + query, + results: data.results ?? [], + txHash: null, + paidAmount: null, + status: "complete", + step: 6, + durationMs: Date.now() - t0, + suggestions: data.suggestions ?? [], + }); + } const data = (await paidRes.json()) as SearchResponse console.log('✅ Search complete!') @@ -307,27 +361,42 @@ export function useSearch(walletAddress: string | null = null) { }) } - // Persist receipt - if (data.txHash) { - try { - const receiptsRaw = localStorage.getItem('stellarsearch_receipts') - const receipts: SearchReceipt[] = receiptsRaw ? JSON.parse(receiptsRaw) : [] - - const newReceipt: SearchReceipt = { - txHash: data.txHash, - query: query.trim(), - amount: data.paidAmount || '0.001', - timestamp: new Date().toISOString(), - network: data.network || 'stellar:testnet', + // Persist receipt + if (data.txHash) { + try { + const receiptsRaw = localStorage.getItem("stellarsearch_receipts"); + const receipts: SearchReceipt[] = receiptsRaw + ? JSON.parse(receiptsRaw) + : []; + + const newReceipt: SearchReceipt = { + txHash: data.txHash, + query: query.trim(), + amount: data.paidAmount || "0.001", + timestamp: new Date().toISOString(), + network: data.network || "stellar:testnet", + }; + + // Keep only last 50 receipts + const updated = [newReceipt, ...receipts].slice(0, 50); + localStorage.setItem( + "stellarsearch_receipts", + JSON.stringify(updated), + ); + console.log("📄 Receipt persisted"); + } catch (e) { + console.warn("Failed to persist receipt:", e); } - - // Keep only last 50 receipts - const updated = [newReceipt, ...receipts].slice(0, 50) - localStorage.setItem('stellarsearch_receipts', JSON.stringify(updated)) - console.log('📄 Receipt persisted') - } catch (e) { - console.warn('Failed to persist receipt:', e) } + } catch (err: any) { + console.error("❌ Search failed:", err); + const msg = err.message || "Search failed."; + toast.error("Search Payment Failed", { description: msg }); + setSession((prev) => ({ + ...prev, + status: "error", + error: msg, + })); } } catch (err: any) { @@ -361,4 +430,4 @@ export function useSearch(walletAddress: string | null = null) { }, []) return { session, search, reset } -} \ No newline at end of file +} diff --git a/src/lib/paymentIntegrity.test.ts b/src/lib/paymentIntegrity.test.ts index fe9e30d..0ac9017 100644 --- a/src/lib/paymentIntegrity.test.ts +++ b/src/lib/paymentIntegrity.test.ts @@ -1,139 +1,226 @@ -import { describe, it, expect, beforeEach } from 'vitest' +import { describe, it, expect, beforeEach } from "vitest"; import { extractPaymentIdentifier, consumePaymentPayload, isPaymentConsumed, cleanupExpiredPayments, resetConsumedPayments, + resetIdempotentRequests, getConsumedPaymentsCount, DEFAULT_PAYMENT_VALIDITY_WINDOW_MS, -} from './paymentIntegrity' + buildIdempotencyKey, + beginIdempotentRequest, + resolveIdempotentRequest, +} from "./paymentIntegrity"; -describe('src/lib/paymentIntegrity — Replay Protection & Payload Tracking', () => { +describe("src/lib/paymentIntegrity — Replay Protection & Payload Tracking", () => { beforeEach(() => { - resetConsumedPayments() - }) - - describe('extractPaymentIdentifier', () => { - it('returns null for null, undefined, or empty values', () => { - expect(extractPaymentIdentifier(null)).toBeNull() - expect(extractPaymentIdentifier(undefined)).toBeNull() - expect(extractPaymentIdentifier('')).toBeNull() - expect(extractPaymentIdentifier(' ')).toBeNull() - }) - - it('extracts explicit transactionHash from JSON object', () => { - const id = extractPaymentIdentifier({ transactionHash: 'tx_12345' }) - expect(id).toBe('tx:tx_12345') - }) - - it('extracts explicit txHash from base64 JSON header string', () => { - const payload = Buffer.from(JSON.stringify({ txHash: 'hash_abc' })).toString('base64') - const id = extractPaymentIdentifier(payload) - expect(id).toBe('tx:hash_abc') - }) - - it('extracts signature/id/nonce from parsed JSON', () => { - expect(extractPaymentIdentifier({ signature: 'sig_999' })).toBe('tx:sig_999') - expect(extractPaymentIdentifier({ id: 'id_777' })).toBe('tx:id_777') - expect(extractPaymentIdentifier({ nonce: 'nonce_555' })).toBe('tx:nonce_555') - }) - - it('falls back to SHA-256 hash for raw non-JSON header strings', () => { - const rawHeader = 'X-Payment-Signature-Raw-Token-Value' - const id = extractPaymentIdentifier(rawHeader) - expect(id).toMatch(/^hash:[a-f0-9]{64}$/) - }) - - it('produces identical identifier for same raw header string', () => { - const rawHeader = 'X-Payment-Signature-Raw-Token-Value' - expect(extractPaymentIdentifier(rawHeader)).toBe(extractPaymentIdentifier(rawHeader)) - }) - }) - - describe('consumePaymentPayload & isPaymentConsumed', () => { - it('successfully consumes a fresh payment payload on first attempt', () => { - const header = Buffer.from(JSON.stringify({ transactionHash: 'tx_first' })).toString('base64') - const result = consumePaymentPayload(header) - expect(result.ok).toBe(true) + resetConsumedPayments(); + resetIdempotentRequests(); + }); + + describe("extractPaymentIdentifier", () => { + it("returns null for null, undefined, or empty values", () => { + expect(extractPaymentIdentifier(null)).toBeNull(); + expect(extractPaymentIdentifier(undefined)).toBeNull(); + expect(extractPaymentIdentifier("")).toBeNull(); + expect(extractPaymentIdentifier(" ")).toBeNull(); + }); + + it("extracts explicit transactionHash from JSON object", () => { + const id = extractPaymentIdentifier({ transactionHash: "tx_12345" }); + expect(id).toBe("tx:tx_12345"); + }); + + it("extracts explicit txHash from base64 JSON header string", () => { + const payload = Buffer.from( + JSON.stringify({ txHash: "hash_abc" }), + ).toString("base64"); + const id = extractPaymentIdentifier(payload); + expect(id).toBe("tx:hash_abc"); + }); + + it("extracts signature/id/nonce from parsed JSON", () => { + expect(extractPaymentIdentifier({ signature: "sig_999" })).toBe( + "tx:sig_999", + ); + expect(extractPaymentIdentifier({ id: "id_777" })).toBe("tx:id_777"); + expect(extractPaymentIdentifier({ nonce: "nonce_555" })).toBe( + "tx:nonce_555", + ); + }); + + it("falls back to SHA-256 hash for raw non-JSON header strings", () => { + const rawHeader = "X-Payment-Signature-Raw-Token-Value"; + const id = extractPaymentIdentifier(rawHeader); + expect(id).toMatch(/^hash:[a-f0-9]{64}$/); + }); + + it("produces identical identifier for same raw header string", () => { + const rawHeader = "X-Payment-Signature-Raw-Token-Value"; + expect(extractPaymentIdentifier(rawHeader)).toBe( + extractPaymentIdentifier(rawHeader), + ); + }); + }); + + describe("consumePaymentPayload & isPaymentConsumed", () => { + it("successfully consumes a fresh payment payload on first attempt", () => { + const header = Buffer.from( + JSON.stringify({ transactionHash: "tx_first" }), + ).toString("base64"); + const result = consumePaymentPayload(header); + expect(result.ok).toBe(true); if (result.ok) { - expect(result.paymentId).toBe('tx:tx_first') + expect(result.paymentId).toBe("tx:tx_first"); } - expect(isPaymentConsumed(header)).toBe(true) - }) - - it('rejects a consumed payment payload on second attempt within validity window', () => { - const header = Buffer.from(JSON.stringify({ transactionHash: 'tx_replay' })).toString('base64') - const first = consumePaymentPayload(header) - expect(first.ok).toBe(true) - - const second = consumePaymentPayload(header) - expect(second.ok).toBe(false) + expect(isPaymentConsumed(header)).toBe(true); + }); + + it("rejects a consumed payment payload on second attempt within validity window", () => { + const header = Buffer.from( + JSON.stringify({ transactionHash: "tx_replay" }), + ).toString("base64"); + const first = consumePaymentPayload(header); + expect(first.ok).toBe(true); + + const second = consumePaymentPayload(header); + expect(second.ok).toBe(false); if (!second.ok) { - expect(second.error).toBe('Payment payload already consumed') - expect(second.paymentId).toBe('tx:tx_replay') + expect(second.error).toBe("Payment payload already consumed"); + expect(second.paymentId).toBe("tx:tx_replay"); } - }) + }); - it('rejects alternative base64 format representing the same transactionHash', () => { - const jsonStr = JSON.stringify({ transactionHash: 'tx_shared' }) - const headerBase64 = Buffer.from(jsonStr).toString('base64') - const headerObject = { transactionHash: 'tx_shared' } + it("rejects alternative base64 format representing the same transactionHash", () => { + const jsonStr = JSON.stringify({ transactionHash: "tx_shared" }); + const headerBase64 = Buffer.from(jsonStr).toString("base64"); + const headerObject = { transactionHash: "tx_shared" }; - expect(consumePaymentPayload(headerBase64).ok).toBe(true) - expect(consumePaymentPayload(headerObject).ok).toBe(false) - }) + expect(consumePaymentPayload(headerBase64).ok).toBe(true); + expect(consumePaymentPayload(headerObject).ok).toBe(false); + }); - it('allows re-consumption after the validity window expires', () => { - const header = Buffer.from(JSON.stringify({ transactionHash: 'tx_expired' })).toString('base64') - const t0 = 1000000 + it("allows re-consumption after the validity window expires", () => { + const header = Buffer.from( + JSON.stringify({ transactionHash: "tx_expired" }), + ).toString("base64"); + const t0 = 1000000; // Consume at t0 - const res1 = consumePaymentPayload(header, DEFAULT_PAYMENT_VALIDITY_WINDOW_MS, t0) - expect(res1.ok).toBe(true) + const res1 = consumePaymentPayload( + header, + DEFAULT_PAYMENT_VALIDITY_WINDOW_MS, + t0, + ); + expect(res1.ok).toBe(true); // Reject at t0 + 299 seconds - const res2 = consumePaymentPayload(header, DEFAULT_PAYMENT_VALIDITY_WINDOW_MS, t0 + 299 * 1000) - expect(res2.ok).toBe(false) + const res2 = consumePaymentPayload( + header, + DEFAULT_PAYMENT_VALIDITY_WINDOW_MS, + t0 + 299 * 1000, + ); + expect(res2.ok).toBe(false); // Allow at t0 + 301 seconds (expired) - const res3 = consumePaymentPayload(header, DEFAULT_PAYMENT_VALIDITY_WINDOW_MS, t0 + 301 * 1000) - expect(res3.ok).toBe(true) - }) - - it('purges expired entries via cleanupExpiredPayments', () => { - const h1 = { transactionHash: 'tx_1' } - const h2 = { transactionHash: 'tx_2' } - const t0 = 1000000 - - consumePaymentPayload(h1, 1000, t0) // expires at t0 + 1000 - consumePaymentPayload(h2, 5000, t0) // expires at t0 + 5000 - - expect(getConsumedPaymentsCount()).toBe(2) - - cleanupExpiredPayments(t0 + 2000) - expect(getConsumedPaymentsCount()).toBe(1) - - cleanupExpiredPayments(t0 + 6000) - expect(getConsumedPaymentsCount()).toBe(0) - }) - }) - - describe('Concurrency Protection', () => { - it('ensures only one request succeeds among parallel concurrent calls for the same payload', async () => { - const header = Buffer.from(JSON.stringify({ transactionHash: 'tx_concurrent' })).toString('base64') + const res3 = consumePaymentPayload( + header, + DEFAULT_PAYMENT_VALIDITY_WINDOW_MS, + t0 + 301 * 1000, + ); + expect(res3.ok).toBe(true); + }); + + it("purges expired entries via cleanupExpiredPayments", () => { + const h1 = { transactionHash: "tx_1" }; + const h2 = { transactionHash: "tx_2" }; + const t0 = 1000000; + + consumePaymentPayload(h1, 1000, t0); // expires at t0 + 1000 + consumePaymentPayload(h2, 5000, t0); // expires at t0 + 5000 + + expect(getConsumedPaymentsCount()).toBe(2); + + cleanupExpiredPayments(t0 + 2000); + expect(getConsumedPaymentsCount()).toBe(1); + + cleanupExpiredPayments(t0 + 6000); + expect(getConsumedPaymentsCount()).toBe(0); + }); + }); + + describe("Idempotent request keys", () => { + it("binds the same logical request to the same payer and params for retries", () => { + const first = buildIdempotencyKey( + "/search", + "GABC", + { q: "stellar", count: "5", freshness: "pw" }, + "same-key", + ); + const second = buildIdempotencyKey( + "/search", + "GABC", + { q: "stellar", count: "5", freshness: "pw" }, + "same-key", + ); + const third = buildIdempotencyKey( + "/search", + "GXYZ", + { q: "stellar", count: "5", freshness: "pw" }, + "same-key", + ); + + expect(first).toBe(second); + expect(first).not.toBe(third); + }); + + it("returns the original response for repeated completed idempotency keys", () => { + const key = buildIdempotencyKey( + "/search", + "GABC", + { q: "stellar", count: "5" }, + "client-123", + ); + const first = beginIdempotentRequest( + "/search", + "GABC", + { q: "stellar", count: "5" }, + "client-123", + ); + expect(first.duplicate).toBe(false); + + const payload = { results: [{ title: "ok" }], count: 1 }; + resolveIdempotentRequest(key, payload); + + const second = beginIdempotentRequest( + "/search", + "GABC", + { q: "stellar", count: "5" }, + "client-123", + ); + expect(second.duplicate).toBe(true); + expect(second.record.value).toEqual(payload); + }); + }); + + describe("Concurrency Protection", () => { + it("ensures only one request succeeds among parallel concurrent calls for the same payload", async () => { + const header = Buffer.from( + JSON.stringify({ transactionHash: "tx_concurrent" }), + ).toString("base64"); const attempts = Array.from({ length: 20 }, () => - Promise.resolve().then(() => consumePaymentPayload(header)) - ) + Promise.resolve().then(() => consumePaymentPayload(header)), + ); - const results = await Promise.all(attempts) + const results = await Promise.all(attempts); - const successes = results.filter((r) => r.ok) - const failures = results.filter((r) => !r.ok) + const successes = results.filter((r) => r.ok); + const failures = results.filter((r) => !r.ok); - expect(successes).toHaveLength(1) - expect(failures).toHaveLength(19) - }) - }) -}) + expect(successes).toHaveLength(1); + expect(failures).toHaveLength(19); + }); + }); +}); diff --git a/src/lib/paymentIntegrity.ts b/src/lib/paymentIntegrity.ts index 30ecb48..06ddd9d 100644 --- a/src/lib/paymentIntegrity.ts +++ b/src/lib/paymentIntegrity.ts @@ -1,18 +1,33 @@ -import crypto from 'crypto' +import crypto from "crypto"; /** * Default validity window for consumed payment payloads in milliseconds. * Aligned with x402 maxTimeoutSeconds (300 seconds = 5 minutes). */ -export const DEFAULT_PAYMENT_VALIDITY_WINDOW_MS = 300 * 1000 +export const DEFAULT_PAYMENT_VALIDITY_WINDOW_MS = 300 * 1000; export interface ConsumedPayment { - consumedAt: number - expiresAt: number + consumedAt: number; + expiresAt: number; +} + +export interface IdempotentRequestRecord { + key: string; + route: string; + payer: string; + paramsKey: string; + createdAt: number; + expiresAt: number; + status: "pending" | "resolved"; + promise?: Promise; + value?: T; + resolve?: (value: T) => void; + reject?: (error: unknown) => void; } // In-memory store for consumed payment identifiers and their expiration timestamps -const consumedPayments = new Map() +const consumedPayments = new Map(); +const idempotentRequests = new Map(); /** * Periodically purge expired payment entries to prevent memory leaks. @@ -20,23 +35,170 @@ const consumedPayments = new Map() export function cleanupExpiredPayments(now: number = Date.now()): void { for (const [id, record] of consumedPayments.entries()) { if (record.expiresAt <= now) { - consumedPayments.delete(id) + consumedPayments.delete(id); } } + clearExpiredIdempotentRequests(now); } /** * Resets the consumed payment store. Essential for clean test isolation. */ export function resetConsumedPayments(): void { - consumedPayments.clear() + consumedPayments.clear(); +} + +export function resetIdempotentRequests(): void { + idempotentRequests.clear(); } /** * Returns the current size of the consumed payments cache (useful for diagnostic tests). */ export function getConsumedPaymentsCount(): number { - return consumedPayments.size + return consumedPayments.size; +} + +export function getIdempotentRequestCount(): number { + return idempotentRequests.size; +} + +export function normalizeIdempotencyKey(value: unknown): string | null { + if (typeof value !== "string") return null; + const normalized = value.trim(); + return normalized.length > 0 ? normalized : null; +} + +export function buildIdempotencyKey( + route: string, + payer: string, + params: Record, + providedKey?: string, +): string { + const safeRoute = String(route || "").trim(); + const safePayer = String(payer || "").trim(); + const supplied = normalizeIdempotencyKey(providedKey) ?? "generated"; + const normalizedParams = Object.fromEntries( + Object.entries(params) + .filter( + ([, value]) => + value !== undefined && value !== null && String(value).trim() !== "", + ) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, value]) => [key, String(value).trim()]), + ); + + const payload = `${safeRoute}|${safePayer}|${supplied}|${JSON.stringify(normalizedParams)}`; + return `idem:${crypto.createHash("sha256").update(payload).digest("hex")}`; +} + +export function getIdempotencyHeaderValue( + headers: Record, +): string | null { + const candidates = [ + "idempotency-key", + "x-idempotency-key", + "x-payment-idempotency-key", + ]; + + for (const headerName of candidates) { + const value = headers[headerName]; + if (Array.isArray(value)) { + const first = value.find(Boolean); + if (first) return normalizeIdempotencyKey(first); + } + if (typeof value === "string") { + const normalized = normalizeIdempotencyKey(value); + if (normalized) return normalized; + } + } + + return null; +} + +export function beginIdempotentRequest( + route: string, + payer: string, + params: Record, + providedKey?: string, + now: number = Date.now(), + validityWindowMs: number = DEFAULT_PAYMENT_VALIDITY_WINDOW_MS, +): { + ok: true; + duplicate: boolean; + key: string; + record: IdempotentRequestRecord; +} { + const key = buildIdempotencyKey(route, payer, params, providedKey); + const existing = idempotentRequests.get(key); + + if (existing && existing.expiresAt > now) { + return { + ok: true, + duplicate: true, + key, + record: existing as IdempotentRequestRecord, + }; + } + + const record: IdempotentRequestRecord = { + key, + route, + payer, + paramsKey: JSON.stringify( + Object.fromEntries( + Object.entries(params) + .filter( + ([, value]) => + value !== undefined && + value !== null && + String(value).trim() !== "", + ) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, value]) => [key, String(value).trim()]), + ), + ), + createdAt: now, + expiresAt: now + validityWindowMs, + status: "pending", + }; + + idempotentRequests.set(key, record); + return { ok: true, duplicate: false, key, record }; +} + +export function resolveIdempotentRequest( + key: string, + value: T, + now: number = Date.now(), + validityWindowMs: number = DEFAULT_PAYMENT_VALIDITY_WINDOW_MS, +): void { + const record = idempotentRequests.get(key); + if (!record) return; + + record.status = "resolved"; + record.value = value; + record.expiresAt = now + validityWindowMs; + record.resolve?.(value); +} + +export function rejectIdempotentRequest(key: string, error: unknown): void { + const record = idempotentRequests.get(key); + if (!record) return; + record.reject?.(error); +} + +export function getIdempotentResult(key: string): T | undefined { + const record = idempotentRequests.get(key); + return record?.value as T | undefined; +} + +export function clearExpiredIdempotentRequests(now: number = Date.now()): void { + for (const [key, record] of idempotentRequests.entries()) { + if (record.expiresAt <= now) { + idempotentRequests.delete(key); + } + } } /** @@ -46,31 +208,32 @@ export function getConsumedPaymentsCount(): number { * If no explicit ID field is found, computes a SHA-256 hash of the normalized header string. */ export function extractPaymentIdentifier(header: unknown): string | null { - if (!header || (typeof header !== 'string' && typeof header !== 'object')) { - return null + if (!header || (typeof header !== "string" && typeof header !== "object")) { + return null; } - const rawString = typeof header === 'string' ? header.trim() : JSON.stringify(header) - if (!rawString) return null + const rawString = + typeof header === "string" ? header.trim() : JSON.stringify(header); + if (!rawString) return null; // 1. Try parsing JSON (or base64-decoded JSON) - let obj: any = null - if (typeof header === 'object') { - obj = header + let obj: any = null; + if (typeof header === "object") { + obj = header; } else { try { - obj = JSON.parse(rawString) + obj = JSON.parse(rawString); } catch { try { - const decoded = Buffer.from(rawString, 'base64').toString('utf8') - obj = JSON.parse(decoded) + const decoded = Buffer.from(rawString, "base64").toString("utf8"); + obj = JSON.parse(decoded); } catch { // Raw non-JSON string } } } - if (obj && typeof obj === 'object') { + if (obj && typeof obj === "object") { const explicitId = obj.transactionHash || obj.txHash || @@ -78,15 +241,15 @@ export function extractPaymentIdentifier(header: unknown): string | null { obj.signature || obj.id || obj.nonce || - obj.paymentId - if (typeof explicitId === 'string' && explicitId.trim()) { - return `tx:${explicitId.trim()}` + obj.paymentId; + if (typeof explicitId === "string" && explicitId.trim()) { + return `tx:${explicitId.trim()}`; } } // 2. Fallback: SHA-256 hash of the raw header string - const hash = crypto.createHash('sha256').update(rawString).digest('hex') - return `hash:${hash}` + const hash = crypto.createHash("sha256").update(rawString).digest("hex"); + return `hash:${hash}`; } /** @@ -97,36 +260,45 @@ export function extractPaymentIdentifier(header: unknown): string | null { export function consumePaymentPayload( header: unknown, validityWindowMs: number = DEFAULT_PAYMENT_VALIDITY_WINDOW_MS, - now: number = Date.now() -): { ok: true; paymentId: string } | { ok: false; error: string; paymentId: string | null } { - cleanupExpiredPayments(now) + now: number = Date.now(), +): + | { ok: true; paymentId: string } + | { ok: false; error: string; paymentId: string | null } { + cleanupExpiredPayments(now); - const paymentId = extractPaymentIdentifier(header) + const paymentId = extractPaymentIdentifier(header); if (!paymentId) { - return { ok: false, error: 'Invalid or missing payment header', paymentId: null } + return { + ok: false, + error: "Invalid or missing payment header", + paymentId: null, + }; } - const existing = consumedPayments.get(paymentId) + const existing = consumedPayments.get(paymentId); if (existing && existing.expiresAt > now) { - return { ok: false, error: 'Payment payload already consumed', paymentId } + return { ok: false, error: "Payment payload already consumed", paymentId }; } // Atomically mark as consumed consumedPayments.set(paymentId, { consumedAt: now, expiresAt: now + validityWindowMs, - }) + }); - return { ok: true, paymentId } + return { ok: true, paymentId }; } /** * Returns whether a payment identifier is currently marked as consumed within its validity window. */ -export function isPaymentConsumed(header: unknown, now: number = Date.now()): boolean { - cleanupExpiredPayments(now) - const paymentId = extractPaymentIdentifier(header) - if (!paymentId) return false - const existing = consumedPayments.get(paymentId) - return !!(existing && existing.expiresAt > now) +export function isPaymentConsumed( + header: unknown, + now: number = Date.now(), +): boolean { + cleanupExpiredPayments(now); + const paymentId = extractPaymentIdentifier(header); + if (!paymentId) return false; + const existing = consumedPayments.get(paymentId); + return !!(existing && existing.expiresAt > now); }