diff --git a/api/search.test.ts b/api/search.test.ts index 6951e8b..fdc15ac 100644 --- a/api/search.test.ts +++ b/api/search.test.ts @@ -16,13 +16,61 @@ vi.mock('../src/lib/constants', async () => { } }) -vi.mock('../src/lib/paymentIntegrity', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, consumePaymentPayload: vi.fn(actual.consumePaymentPayload) } +// ─── Mock x402 facilitator ────────────────────────────────────────────── +const { mockVerify, mockSettle, mockDecode } = vi.hoisted(() => ({ + mockVerify: vi.fn().mockResolvedValue({ isValid: true }), + mockSettle: vi.fn().mockResolvedValue({ success: true, transaction: 'tx_ok', network: 'stellar:testnet' }), + mockDecode: vi.fn((header: string) => { + try { + const decoded = Buffer.from(header, 'base64').toString('utf8') + const parsed = JSON.parse(decoded) + return { + x402Version: 2, + payload: parsed, + accepted: { + scheme: 'exact', + network: 'stellar:testnet', + asset: 'CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA', + amount: '10000', + payTo: 'GAAZI4TCR3TY5OJHCTJC2A4AFL5MNSF3GAKGOWG5W2LBBGCS2TDPZOM3', + maxTimeoutSeconds: 300, + extra: { areFeesSponsored: true }, + }, + } + } catch { + throw new Error('Malformed payment payload') + } + }), +})) + +vi.mock('@x402/core/server', () => ({ + HTTPFacilitatorClient: class { + verify = mockVerify + settle = mockSettle + constructor(_opts: any) {} + }, +})) + +vi.mock('@x402/core/http', () => ({ + decodePaymentSignatureHeader: mockDecode, +})) + +vi.mock('@x402/stellar/exact/server', () => ({ + ExactStellarScheme: class {}, +})) + +vi.mock('../src/lib/x402Config', async () => { + const actual: any = await vi.importActual('../src/lib/x402Config') + return { + ...actual, + getNetwork: () => 'stellar:testnet', + getPayTo: () => 'GAAZI4TCR3TY5OJHCTJC2A4AFL5MNSF3GAKGOWG5W2LBBGCS2TDPZOM3', + getFacilitatorUrl: () => 'https://www.x402.org/facilitator', + } }) import handler from './search' -import { resetConsumedPayments, consumePaymentPayload } from '../src/lib/paymentIntegrity' +import { resetConsumedPayments } from '../src/lib/paymentIntegrity' function mockReqRes(overrides: any = {}) { const req: any = { @@ -51,9 +99,15 @@ describe('api/search — Vercel x402 settlement (aligned with Express)', () => { beforeEach(() => { vi.clearAllMocks() - ;(consumePaymentPayload as any).mockClear() resetConsumedPayments() - global.fetch = originalFetch + // Always use a spy so not.toHaveBeenCalled() assertions work + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ organic: [] }), + } as any) + // Default: facilitator verify and settle succeed + mockVerify.mockResolvedValue({ isValid: true }) + mockSettle.mockResolvedValue({ success: true, transaction: 'tx_ok', network: 'stellar:testnet' }) }) it('handles OPTIONS preflight', async () => { @@ -99,7 +153,7 @@ describe('api/search — Vercel x402 settlement (aligned with Express)', () => { expect(decoded.x402Version).toBe(2) expect(decoded.accepts[0].scheme).toBe('exact') expect(decoded.accepts[0].network).toBe('stellar:testnet') - expect(decoded.accepts[0].amount).toBe('10000') // stroops = 0.001 USDC + expect(decoded.accepts[0].amount).toBe('10000') expect(decoded.accepts[0].asset).toBe('CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA') expect(decoded.accepts[0].payTo).toBe(process.env.STELLAR_RECEIVING_ADDRESS) expect(decoded.accepts[0].maxTimeoutSeconds).toBe(300) @@ -113,7 +167,7 @@ describe('api/search — Vercel x402 settlement (aligned with Express)', () => { expect(res.setHeader).toHaveBeenCalledWith('Access-Control-Expose-Headers', expect.stringContaining('PAYMENT-REQUIRED')) }) - it('proceeds to search when payment header present (Serper mock)', async () => { + it('proceeds to search after facilitator verify+settle succeeds', async () => { const fakeTx = Buffer.from(JSON.stringify({ transactionHash: 'abc123' })).toString('base64') global.fetch = vi.fn().mockResolvedValue({ ok: true, @@ -130,6 +184,8 @@ describe('api/search — Vercel x402 settlement (aligned with Express)', () => { headers: { 'x-payment': fakeTx }, }) await handler(req, res) + expect(mockVerify).toHaveBeenCalledTimes(1) + expect(mockSettle).toHaveBeenCalledTimes(1) expect(res._json.results).toHaveLength(1) expect(res._json.results[0].title).toBe('Stellar') expect(res._json.paidAmount).toBe('0.001') @@ -140,7 +196,6 @@ describe('api/search — Vercel x402 settlement (aligned with Express)', () => { }) it('preserves x402 settlement — amount decoded from constants is 0.001 USDC', async () => { - // Verify that Vercel and Express share same amount via constants const { STELLAR_NETWORK, AMOUNT_STROOPS, AMOUNT_USDC } = await import('../src/lib/constants') expect(STELLAR_NETWORK).toBe('stellar:testnet') expect(parseInt(AMOUNT_STROOPS)).toBe(10000) @@ -263,152 +318,257 @@ describe('api/search — Vercel x402 settlement (aligned with Express)', () => { expect(res._json.results[0].title).toBe('Valid Vercel Result') expect(res._json.results[0].url).toBe('https://vercel.com/docs') }) +}) + +// ─── x402 payment verification tests (Issue #107) ───────────────────────── - it('distinguishes original, executed, and suggested query text when spelling is corrected', async () => { - const fakeTx = Buffer.from(JSON.stringify({ transactionHash: 'tx_spelling_test' })).toString('base64') +describe('api/search — x402 payment verification (Issue #107)', () => { + beforeEach(() => { + vi.clearAllMocks() + resetConsumedPayments() + // Always use a spy so not.toHaveBeenCalled() assertions work global.fetch = vi.fn().mockResolvedValue({ ok: true, - json: async () => ({ - searchParameters: { q: 'stellar blockchain' }, - searchInformation: { originalQuery: 'stelarr blockchan' }, - organic: [ - { title: 'Stellar', link: 'https://stellar.org', snippet: 'Blockchain' }, - ], - }), + json: async () => ({ organic: [] }), } as any) + // Default: facilitator verify and settle succeed + mockVerify.mockResolvedValue({ isValid: true }) + mockSettle.mockResolvedValue({ success: true, transaction: 'tx_ok', network: 'stellar:testnet' }) + }) + + it('rejects payment when facilitator verify returns isValid: false', async () => { + mockVerify.mockResolvedValue({ isValid: false, invalidReason: 'Signature mismatch' }) + const fakeTx = Buffer.from(JSON.stringify({ transactionHash: 'tx_bad_sig' })).toString('base64') + + const { req, res } = mockReqRes({ + method: 'GET', + query: { q: 'stellar' }, + headers: { 'x-payment': fakeTx }, + }) + await handler(req, res) + expect(res._status).toBe(402) + expect(res._json.error).toMatch(/Signature mismatch/) + expect(mockSettle).not.toHaveBeenCalled() + // Serper must NOT be called + expect(global.fetch).not.toHaveBeenCalled() + }) + + it('rejects payment when facilitator verify throws', async () => { + mockVerify.mockRejectedValue(new Error('Network timeout')) + const fakeTx = Buffer.from(JSON.stringify({ transactionHash: 'tx_verify_err' })).toString('base64') + + const { req, res } = mockReqRes({ + method: 'GET', + query: { q: 'stellar' }, + headers: { 'x-payment': fakeTx }, + }) + await handler(req, res) + expect(res._status).toBe(402) + expect(res._json.error).toMatch(/Network timeout/) + expect(mockSettle).not.toHaveBeenCalled() + expect(global.fetch).not.toHaveBeenCalled() + }) + + it('rejects payment when facilitator settle returns success: false', async () => { + mockVerify.mockResolvedValue({ isValid: true }) + mockSettle.mockResolvedValue({ success: false, errorReason: 'Insufficient balance' }) + const fakeTx = Buffer.from(JSON.stringify({ transactionHash: 'tx_underpay' })).toString('base64') const { req, res } = mockReqRes({ method: 'GET', - query: { q: 'stelarr blockchan' }, + query: { q: 'stellar' }, headers: { 'x-payment': fakeTx }, }) + await handler(req, res) + expect(res._status).toBe(402) + expect(res._json.error).toMatch(/Insufficient balance/) + expect(global.fetch).not.toHaveBeenCalled() + }) + + it('rejects payment when facilitator settle throws', async () => { + mockVerify.mockResolvedValue({ isValid: true }) + mockSettle.mockRejectedValue(new Error('Settlement network error')) + const fakeTx = Buffer.from(JSON.stringify({ transactionHash: 'tx_settle_err' })).toString('base64') + const { req, res } = mockReqRes({ + method: 'GET', + query: { q: 'stellar' }, + headers: { 'x-payment': fakeTx }, + }) await handler(req, res) - expect(res._json.originalQuery).toBe('stelarr blockchan') - expect(res._json.executedQuery).toBe('stellar blockchain') - expect(res._json.suggestedQuery).toBe('stellar blockchain') - expect(res._json.isCorrected).toBe(true) - expect(res._json.query).toBe('stellar blockchain') + expect(res._status).toBe(402) + expect(res._json.error).toMatch(/Settlement network error/) + expect(global.fetch).not.toHaveBeenCalled() }) - it('provides suggestedQuery when didYouMean is present without auto-correction', async () => { - const fakeTx = Buffer.from(JSON.stringify({ transactionHash: 'tx_did_you_mean_test' })).toString('base64') + it('rejects malformed payment header (invalid base64)', async () => { + const { req, res } = mockReqRes({ + method: 'GET', + query: { q: 'stellar' }, + headers: { 'x-payment': '!!!NOT_BASE64!!!' }, + }) + await handler(req, res) + expect(res._status).toBe(402) + expect(res._json.error).toMatch(/Malformed payment payload/) + expect(mockVerify).not.toHaveBeenCalled() + expect(global.fetch).not.toHaveBeenCalled() + }) + + it('rejects empty string payment header (no header present)', async () => { + const { req, res } = mockReqRes({ + method: 'GET', + query: { q: 'stellar' }, + headers: {}, + }) + await handler(req, res) + expect(res._status).toBe(402) + expect(res._json.error).toBe('Payment required') + }) + + it('never trusts header presence alone — verify must be called before Serper', async () => { + const fakeTx = Buffer.from(JSON.stringify({ transactionHash: 'tx_presence_only' })).toString('base64') global.fetch = vi.fn().mockResolvedValue({ ok: true, - json: async () => ({ - searchParameters: { q: 'stelarr blockchan' }, - spelling: { didYouMean: 'stellar blockchain' }, - organic: [ - { title: 'Stelarr Results', link: 'https://stellar.org/alt', snippet: 'Alt snippet' }, - ], - }), + json: async () => ({ organic: [{ title: 'Test', link: 'https://test.com' }] }), } as any) const { req, res } = mockReqRes({ method: 'GET', - query: { q: 'stelarr blockchan' }, + query: { q: 'test' }, headers: { 'x-payment': fakeTx }, }) - await handler(req, res) - expect(res._json.originalQuery).toBe('stelarr blockchan') - expect(res._json.executedQuery).toBe('stelarr blockchan') - expect(res._json.suggestedQuery).toBe('stellar blockchain') - expect(res._json.isCorrected).toBe(false) - expect(res._json.query).toBe('stelarr blockchan') - }) -}) -// ─── Parameter validation matrix (issue #188) ──────────────────────────────── + // Verify must be called before any Serper fetch + expect(mockVerify).toHaveBeenCalledTimes(1) + expect(mockSettle).toHaveBeenCalledTimes(1) + expect(global.fetch).toHaveBeenCalledTimes(1) + }) -describe('api/search — parameter validation matrix (issue #188)', () => { - let matrixCounter = 0 - const matrixTx = () => Buffer.from(JSON.stringify({ transactionHash: `tx_matrix_vercel_${++matrixCounter}` })).toString('base64') + it('forged payment with wrong tx hash is rejected by facilitator', async () => { + mockVerify.mockResolvedValue({ isValid: false, invalidReason: 'Transaction not found on network' }) + const forged = Buffer.from(JSON.stringify({ transactionHash: 'forged_nonexistent_tx' })).toString('base64') - beforeEach(() => { - vi.clearAllMocks() - ;(consumePaymentPayload as any).mockClear() - resetConsumedPayments() + const { req, res } = mockReqRes({ + method: 'GET', + query: { q: 'stellar' }, + headers: { 'x-payment': forged }, + }) + await handler(req, res) + expect(res._status).toBe(402) + expect(res._json.error).toMatch(/Transaction not found/) + expect(global.fetch).not.toHaveBeenCalled() }) - const rejectCases: { label: string; query: Record; error: RegExp }[] = [ - { label: 'count=0 (below min)', query: { q: 'stellar', count: '0' }, error: /count/ }, - { label: 'count=-1 (negative)', query: { q: 'stellar', count: '-1' }, error: /count/ }, - { label: 'count=21 (above max)', query: { q: 'stellar', count: '21' }, error: /count/ }, - { label: 'count=999 (above max)', query: { q: 'stellar', count: '999' }, error: /count/ }, - { label: 'count=abc (non-integer)', query: { q: 'stellar', count: 'abc' }, error: /integer/ }, - { label: 'count=1.5 (non-integer)', query: { q: 'stellar', count: '1.5' }, error: /integer/ }, - { label: 'count=1e3 (non-integer)', query: { q: 'stellar', count: '1e3' }, error: /integer/ }, - { label: 'count repeated (array)', query: { q: 'stellar', count: ['1', '2'] }, error: /single value/ }, - { label: 'freshness=day (unknown enum)', query: { q: 'stellar', freshness: 'day' }, error: /freshness/ }, - { label: 'freshness=1 (unknown enum)', query: { q: 'stellar', freshness: '1' }, error: /freshness/ }, - { label: 'freshness repeated (array)', query: { q: 'stellar', freshness: ['pd', 'pw'] }, error: /single value/ }, - ] - - for (const c of rejectCases) { - it(`rejects ${c.label} early (400) without invoking payment or Serper adapters`, async () => { - global.fetch = vi.fn() - const { req, res } = mockReqRes({ method: 'GET', query: { q: 'stellar', ...c.query } }) - await handler(req, res) - expect(res._status).toBe(400) - expect(res._json.error).toMatch(c.error) - expect(consumePaymentPayload).not.toHaveBeenCalled() - expect(global.fetch).not.toHaveBeenCalled() + it('expired payment is rejected by facilitator', async () => { + mockVerify.mockResolvedValue({ isValid: false, invalidReason: 'Payment expired' }) + const expired = Buffer.from(JSON.stringify({ transactionHash: 'tx_expired_payment' })).toString('base64') + + const { req, res } = mockReqRes({ + method: 'GET', + query: { q: 'stellar' }, + headers: { 'x-payment': expired }, }) - } + await handler(req, res) + expect(res._status).toBe(402) + expect(res._json.error).toMatch(/expired/i) + expect(global.fetch).not.toHaveBeenCalled() + }) + + it('underpaid payment is rejected by facilitator', async () => { + mockVerify.mockResolvedValue({ isValid: false, invalidReason: 'Insufficient payment amount' }) + const underpaid = Buffer.from(JSON.stringify({ transactionHash: 'tx_underpaid' })).toString('base64') - it('rejects invalid count even with a payment header present (validation precedes payment)', async () => { - global.fetch = vi.fn() const { req, res } = mockReqRes({ method: 'GET', - query: { q: 'stellar', count: 'nope' }, - headers: { 'x-payment': matrixTx() }, + query: { q: 'stellar' }, + headers: { 'x-payment': underpaid }, }) await handler(req, res) - expect(res._status).toBe(400) - expect(consumePaymentPayload).not.toHaveBeenCalled() + expect(res._status).toBe(402) + expect(res._json.error).toMatch(/Insufficient payment amount/) expect(global.fetch).not.toHaveBeenCalled() }) - it('forwards the default count 5 when count is omitted', async () => { - let capturedBody: any = null - global.fetch = vi.fn().mockImplementation(async (_url: any, opts: any) => { - capturedBody = JSON.parse(opts.body) - return { ok: true, json: async () => ({ organic: [] }) } as any + it('payment-signature header is also accepted and verified', async () => { + const fakeTx = Buffer.from(JSON.stringify({ transactionHash: 'tx_sig_header' })).toString('base64') + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ organic: [] }), + } as any) + + const { req, res } = mockReqRes({ + method: 'GET', + query: { q: 'stellar' }, + headers: { 'payment-signature': fakeTx }, }) - const { req, res } = mockReqRes({ method: 'GET', query: { q: 'stellar' }, headers: { 'x-payment': matrixTx() } }) await handler(req, res) - expect(res._json.results).toEqual([]) - expect(capturedBody.num).toBe(5) + expect(mockVerify).toHaveBeenCalledTimes(1) + expect(mockSettle).toHaveBeenCalledTimes(1) + expect(res._json.query).toBe('stellar') }) - it('forwards count at min and max bounds', async () => { - for (const count of ['1', '20']) { - let capturedBody: any = null - global.fetch = vi.fn().mockImplementation(async (_url: any, opts: any) => { - capturedBody = JSON.parse(opts.body) - return { ok: true, json: async () => ({ organic: [] }) } as any - }) - const { req, res } = mockReqRes({ method: 'GET', query: { q: 'stellar', count }, headers: { 'x-payment': matrixTx() } }) - await handler(req, res) - expect(res._json.results).toEqual([]) - expect(capturedBody.num).toBe(Number(count)) - } + it('X-PAYMENT header (uppercase) is also accepted and verified', async () => { + const fakeTx = Buffer.from(JSON.stringify({ transactionHash: 'tx_upper_header' })).toString('base64') + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ organic: [] }), + } as any) + + const { req, res } = mockReqRes({ + method: 'GET', + query: { q: 'stellar' }, + headers: { 'X-PAYMENT': fakeTx }, + }) + await handler(req, res) + expect(mockVerify).toHaveBeenCalledTimes(1) + expect(mockSettle).toHaveBeenCalledTimes(1) }) - it('forwards tbs for each supported freshness enum', async () => { - for (const [freshness, tbs] of Object.entries({ pd: 'qdr:d', pw: 'qdr:w', pm: 'qdr:m' })) { - let capturedBody: any = null - global.fetch = vi.fn().mockImplementation(async (_url: any, opts: any) => { - capturedBody = JSON.parse(opts.body) - return { ok: true, json: async () => ({ organic: [] }) } as any - }) - const { req, res } = mockReqRes({ method: 'GET', query: { q: 'stellar', freshness }, headers: { 'x-payment': matrixTx() } }) - await handler(req, res) - expect(res._json.results).toEqual([]) - expect(capturedBody.tbs).toBe(tbs) - } + it('both verify and settle must succeed for search to proceed', async () => { + // Verify succeeds but settle fails + mockVerify.mockResolvedValue({ isValid: true }) + mockSettle.mockResolvedValue({ success: false, errorReason: 'Settlement rejected' }) + const fakeTx = Buffer.from(JSON.stringify({ transactionHash: 'tx_settle_fail' })).toString('base64') + + const { req, res } = mockReqRes({ + method: 'GET', + query: { q: 'stellar' }, + headers: { 'x-payment': fakeTx }, + }) + await handler(req, res) + expect(res._status).toBe(402) + expect(global.fetch).not.toHaveBeenCalled() }) -}) + it('replay protection still works alongside facilitator verification', async () => { + const fakeTx = Buffer.from(JSON.stringify({ transactionHash: 'tx_replay_verify' })).toString('base64') + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ organic: [] }), + } as any) + + // First request: verify + settle + search + const first = mockReqRes({ + method: 'GET', + query: { q: 'stellar' }, + headers: { 'x-payment': fakeTx }, + }) + await handler(first.req, first.res) + expect(first.res._json.query).toBe('stellar') + expect(mockVerify).toHaveBeenCalledTimes(1) + expect(mockSettle).toHaveBeenCalledTimes(1) + // Second request: rejected by replay protection (before facilitator) + const second = mockReqRes({ + method: 'GET', + query: { q: 'stellar' }, + headers: { 'x-payment': fakeTx }, + }) + await handler(second.req, second.res) + expect(second.res._status).toBe(402) + expect(second.res._json.error).toBe('Payment payload already consumed') + // Facilitator should NOT be called again + expect(mockVerify).toHaveBeenCalledTimes(1) + }) +}) diff --git a/api/search.ts b/api/search.ts index 1647b6e..c06dea9 100644 --- a/api/search.ts +++ b/api/search.ts @@ -1,25 +1,100 @@ import type { VercelRequest, VercelResponse } from '@vercel/node' -import { - USDC_CONTRACT_MAINNET, - USDC_CONTRACT_TESTNET, +import { HTTPFacilitatorClient } from '@x402/core/server' +import { decodePaymentSignatureHeader } from '@x402/core/http' +import { ExactStellarScheme } from '@x402/stellar/exact/server' +import { + STELLAR_NETWORK, + AMOUNT_USDC, } from '../src/lib/constants' +import { + getNetwork, + buildPaymentRequirement, + getPayTo, + buildPaymentRequiredPayload, +} from '../src/lib/x402Config' import { consumePaymentPayload } from '../src/lib/paymentIntegrity' -import { formatConfigurationError, readServerConfig } from '../src/lib/config' +import { normalizeOrganicResults } from '../src/lib/serperNormalizer' +import type { SearchResponse, ApiErrorResponse } from '../src/types/index.js' // ─── Config ─────────────────────────────────────────────────────────────── -let config -try { - config = readServerConfig() -} catch (error) { - console.error(formatConfigurationError(error)) - throw error +const NETWORK = getNetwork() as 'stellar:testnet' | 'stellar:mainnet' +const RECEIVING_ADDRESS = getPayTo() +const SERPER_API_KEY = process.env.SERPER_API_KEY! +const FACILITATOR_URL = process.env.FACILITATOR_URL || 'https://www.x402.org/facilitator' + +// ─── x402 facilitator for payment verification ──────────────────────────── +const facilitatorClient = new HTTPFacilitatorClient({ url: FACILITATOR_URL }) +const exactScheme = new ExactStellarScheme() + +/** + * Verify an x402 payment payload against payment requirements using the facilitator. + * Never trusts header presence alone — forged, malformed, expired, and underpaid + * payments are rejected before reaching Serper. + */ +async function verifyPayment( + paymentHeader: string, +): Promise<{ ok: true; txHash: string | null } | { ok: false; status: number; error: string }> { + // 1. Decode the payment payload from the header + let paymentPayload + try { + paymentPayload = decodePaymentSignatureHeader(paymentHeader) + } catch { + return { ok: false, status: 402, error: 'Malformed payment payload' } + } + + // 2. Validate basic payload structure + if (!paymentPayload || typeof paymentPayload !== 'object') { + return { ok: false, status: 402, error: 'Invalid payment payload' } + } + if (!paymentPayload.payload || typeof paymentPayload.payload !== 'object') { + return { ok: false, status: 402, error: 'Malformed payment payload: missing payload field' } + } + + // 3. Build payment requirements from shared config + const paymentRequirements = buildPaymentRequirement() as any + + // 4. Verify with facilitator (checks signature, amount, expiry, network) + try { + const verifyResult = await facilitatorClient.verify(paymentPayload, paymentRequirements as any) + + if (!verifyResult.isValid) { + return { + ok: false, + status: 402, + error: verifyResult.invalidReason || 'Payment verification failed', + } + } + } catch (err: any) { + const message = err?.message || String(err) + console.error('[x402 verify]', message) + return { ok: false, status: 402, error: `Payment verification error: ${message}` } + } + + // 5. Settle the payment through the facilitator + try { + const settleResult = await facilitatorClient.settle(paymentPayload, paymentRequirements as any) + + if (!settleResult.success) { + return { + ok: false, + status: 402, + error: settleResult.errorReason || 'Payment settlement failed', + } + } + } catch (err: any) { + const message = err?.message || String(err) + console.error('[x402 settle]', message) + return { ok: false, status: 402, error: `Payment settlement error: ${message}` } + } + + // 6. Extract tx hash from payment payload + const txHash = + (paymentPayload.payload as Record)?.transactionHash as string || + (paymentPayload.payload as Record)?.txHash as string || + null + + return { ok: true, txHash } } -const RECEIVING_ADDRESS = config.receivingAddress -const NETWORK = config.stellarNetwork -const SERPER_API_KEY = config.serperApiKey -const AMOUNT_STROOPS = config.amountStroops -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) { @@ -58,29 +133,10 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { 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', - 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', - }, - 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 - maxTimeoutSeconds: 300, - extra: { areFeesSponsored: true }, - }, - ], - } + if (!paymentHeader || typeof paymentHeader !== 'string') { + // Return x402 v2 payment requirements from shared config + const requestUrl = `${req.headers['x-forwarded-proto'] || 'http'}://${req.headers['host']}${req.url}` + const paymentRequired = buildPaymentRequiredPayload(requestUrl) res.setHeader( 'PAYMENT-REQUIRED', @@ -97,18 +153,17 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { return res.status(402).json(errorBody) } - // ─── Payment present — proceed with search ──────────────────────────────── - console.log('✅ Payment header received') - - 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 - } catch { - // payment header not base64 JSON — fine, tx hash just won't show + // ─── Verify and settle payment via facilitator ─────────────────────────── + const verification = await verifyPayment(paymentHeader) + if (!verification.ok) { + const errorBody: ApiErrorResponse = { error: verification.error } + return res.status(verification.status).json(errorBody) } + const txHash = verification.txHash + + console.log('✅ Payment verified and settled via facilitator') + const t0 = Date.now() try { @@ -147,19 +202,14 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { const latencyMs = Date.now() - t0 const results = normalizeOrganicResults(data) - const queryMeta = normalizeQueryMetadata(data, q.trim()) const responseBody: SearchResponse = { - query: queryMeta.executedQuery, - originalQuery: queryMeta.originalQuery, - executedQuery: queryMeta.executedQuery, - suggestedQuery: queryMeta.suggestedQuery, - isCorrected: queryMeta.isCorrected, + query: q.trim(), results, - count: results.length, - network: NETWORK, - paidAmount: AMOUNT_USDC, - currency: 'USDC', + count: results.length, + network: NETWORK, + paidAmount: AMOUNT_USDC, + currency: 'USDC', txHash, latencyMs, } diff --git a/src/lib/x402Config.ts b/src/lib/x402Config.ts new file mode 100644 index 0000000..8a042fe --- /dev/null +++ b/src/lib/x402Config.ts @@ -0,0 +1,250 @@ +/** + * x402Config.ts + * + * Single source of truth for x402 payment route and asset configuration. + * Both Express (server/index.ts) and Vercel (api/search.ts) import this + * module to build payment requirements, eliminating protocol drift. + * + * Fields covered: + * - network (stellar:testnet | stellar:mainnet) + * - asset (Soroban USDC contract address) + * - amount (stroops — 0.001 USDC = 10000) + * - payTo ( Stellar receiving address from env) + * - timeout (maxTimeoutSeconds) + * - fee sponsorship (areFeesSponsored) + */ + +import { + STELLAR_NETWORK, + USDC_CONTRACT, + AMOUNT_STROOPS, + AMOUNT_USDC, +} from './constants' + +// ─── Constants ─────────────────────────────────────────────────────────── +const MAX_TIMEOUT_SECONDS = 300 +const X402_VERSION = 2 + +// ─── Types ─────────────────────────────────────────────────────────────── + +export interface X402PaymentRequirement { + scheme: string + network: string + asset: string + amount: string + payTo: string + maxTimeoutSeconds: number + extra: Record +} + +export interface X402ExpressPaymentOption { + scheme: string + payTo: string + price: number + network: string +} + +export interface X402RouteConfig { + accepts: X402ExpressPaymentOption[] + description: string +} + +export interface X402FullConfig { + x402Version: number + network: string + asset: string + amount: string + price: number + payTo: string + maxTimeoutSeconds: number + extra: Record + facilitatorUrl: string + expressRoutes: Record +} + +// ─── Validation ────────────────────────────────────────────────────────── + +function validateNetwork(network: string): network is 'stellar:testnet' | 'stellar:mainnet' { + return network === 'stellar:testnet' || network === 'stellar:mainnet' +} + +function validateAddress(addr: string): boolean { + return typeof addr === 'string' && /^[A-Z2-7]{56}$/.test(addr) +} + +function validateStroops(amount: string): boolean { + const n = parseInt(amount, 10) + return Number.isFinite(n) && n > 0 +} + +function validatePayTo(payTo: string | undefined, _network: string): string { + if (payTo && validateAddress(payTo)) return payTo + // Fallback: read from env at call time + const envPayTo = process.env.STELLAR_RECEIVING_ADDRESS + if (envPayTo && validateAddress(envPayTo)) return envPayTo + throw new Error( + `[x402Config] STELLAR_RECEIVING_ADDRESS is missing or invalid`, + ) +} + +// ─── Public API ────────────────────────────────────────────────────────── + +/** + * Returns the validated network string for x402. + */ +export function getNetwork(): string { + const network = STELLAR_NETWORK + if (!validateNetwork(network)) { + throw new Error(`[x402Config] Invalid STELLAR_NETWORK: ${network}`) + } + return network +} + +/** + * Returns the Soroban USDC contract address for the current network. + */ +export function getAsset(): string { + const contract = USDC_CONTRACT + if (!validateAddress(contract)) { + throw new Error(`[x402Config] Invalid USDC_CONTRACT: ${contract}`) + } + return contract +} + +/** + * Returns the payment amount in stroops. + */ +export function getAmount(): string { + if (!validateStroops(AMOUNT_STROOPS)) { + throw new Error(`[x402Config] Invalid AMOUNT_STROOPS: ${AMOUNT_STROOPS}`) + } + return AMOUNT_STROOPS +} + +/** + * Returns the payment amount as a decimal USDC string. + */ +export function getAmountUsdc(): string { + return AMOUNT_USDC +} + +/** + * Returns the payment amount as a parsed number (for Express middleware price field). + */ +export function getPrice(): number { + const price = parseFloat(AMOUNT_USDC) + if (!Number.isFinite(price) || price <= 0) { + throw new Error(`[x402Config] Invalid AMOUNT_USDC: ${AMOUNT_USDC}`) + } + return price +} + +/** + * Returns the payTo address. Validates or throws if not configured. + */ +export function getPayTo(override?: string): string { + const network = getNetwork() + return validatePayTo(override, network) +} + +/** + * Returns the default payment requirement object shared by all runtimes. + */ +export function buildPaymentRequirement( + payToOverride?: string, +): X402PaymentRequirement { + return { + scheme: 'exact', + network: getNetwork(), + asset: getAsset(), + amount: getAmount(), + payTo: getPayTo(payToOverride), + maxTimeoutSeconds: MAX_TIMEOUT_SECONDS, + extra: { areFeesSponsored: true }, + } +} + +/** + * Builds the x402 route configuration for Express middleware. + * The Express middleware (paymentMiddlewareFromConfig) expects PaymentOption format: + * { scheme, payTo, price, network } — the facilitator resolves asset and amount from price. + * Each paid route (GET /search, /images, /news) shares the same payment option. + */ +export function buildExpressRoutes( + payToOverride?: string, +): Record { + const payTo = getPayTo(payToOverride) + const network = getNetwork() + const price = getPrice() + const amountUsdc = getAmountUsdc() + + const paymentOption: X402ExpressPaymentOption = { + scheme: 'exact', + payTo, + price, + network: network as 'stellar:testnet' | 'stellar:mainnet', + } + + const routeDescription = (label: string) => + `StellarSearch: pay-per-query ${label} — ${amountUsdc} USDC on ${network}` + + return { + 'GET /search': { + accepts: [paymentOption], + description: routeDescription('web search'), + }, + 'GET /images': { + accepts: [paymentOption], + description: routeDescription('image search'), + }, + 'GET /news': { + accepts: [paymentOption], + description: routeDescription('news search'), + }, + } +} + +/** + * Builds the x402 Payment-Required response body for Vercel's manual 402. + */ +export function buildPaymentRequiredPayload( + requestUrl: string, + payToOverride?: string, +) { + const requirement = buildPaymentRequirement(payToOverride) + return { + x402Version: X402_VERSION, + error: 'Payment required', + resource: { + url: requestUrl, + description: `StellarSearch: pay-per-query web search — ${getAmountUsdc()} USDC on ${getNetwork()}`, + mimeType: 'application/json', + }, + accepts: [requirement], + } +} + +/** + * Returns the facilitator URL from env or default. + */ +export function getFacilitatorUrl(): string { + return process.env.FACILITATOR_URL || 'https://www.x402.org/facilitator' +} + +/** + * Full configuration object for snapshot testing and validation. + */ +export function getFullConfig(payToOverride?: string): X402FullConfig { + return { + x402Version: X402_VERSION, + network: getNetwork(), + asset: getAsset(), + amount: getAmount(), + price: getPrice(), + payTo: getPayTo(payToOverride), + maxTimeoutSeconds: MAX_TIMEOUT_SECONDS, + extra: { areFeesSponsored: true }, + facilitatorUrl: getFacilitatorUrl(), + expressRoutes: buildExpressRoutes(payToOverride), + } +}