Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 25 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

---

Expand All @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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" |
70 changes: 37 additions & 33 deletions api/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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',
Expand All @@ -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<string, unknown> = {
q: q.trim(),
q: q.trim(),
num: Math.min(parseInt(count) || 5, 20),
}
};

if (freshness) {
const dateFilters: Record<string, string> = {
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()
Expand Down Expand Up @@ -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.' }
Expand Down
63 changes: 34 additions & 29 deletions server/corsConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,76 +2,81 @@
* 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 {
const base: 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));
},
}
};
}
Loading