diff --git a/.env.example b/.env.example index 5e2d105..f51c0b9 100644 --- a/.env.example +++ b/.env.example @@ -27,9 +27,10 @@ CIRCLE_WALLET_ID= CIRCLE_NETWORK=ARC-TESTNET USDC_TOKEN_ADDRESS=0x3600000000000000000000000000000000000000 -# x402 search middleware +# x402 Nimble bridge (required β€” start with: npm run start:server) SEARCH_MIDDLEWARE_URL=http://localhost:3000/search SEARCH_PAYMENT_ADDRESS=0x1111111111111111111111111111111111111111 SEARCH_PAYMENT_AMOUNT=0.001 SEARCH_PAYMENT_TOKEN=USDC SEARCH_PAYMENT_CHAIN=ARC-TESTNET +NIMBLE_API_KEY= # used by server.js only, not the agent process diff --git a/README.md b/README.md index 38316c2..19141d4 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ An autonomous Web3 shopping agent. Give it a prompt, it searches the web, picks ## Features ### 🧠 Agent Intelligence -- **Autonomous Agentic Loop**: Powered by Anthropic's **Claude 3.5 Sonnet**, the agent runs a continuous "think-act" loop. It uses native tool-calling to independently decide when to search, evaluate products, execute payments, or log results. +- **Autonomous Agentic Loop**: Powered by the **Anthropic Claude API**, the agent runs a continuous "think-act" loop with a configurable turn cap to prevent runaway execution. It uses native tool-calling to independently decide when to search, evaluate products, execute payments, or log results β€” with no human in the loop after the initial prompt. - **Strategic Evaluation**: Unlike simple scripts, the agent evaluates search results against user constraints (budget, reputation, and service type) before deciding to purchase. - **Web Search via Nimble**: The agent searches the live web using the **Nimble API** β€” a managed web data platform that handles CAPTCHAs, bot detection, and proxy rotation automatically. Returns structured results (titles, URLs, descriptions) for any query without scraping infrastructure. @@ -143,7 +143,7 @@ Node.js (CommonJS), single-process. The agent, payment, search, logging, and pub **Senso / cited.md** (`SENSO_API_KEY`) - Publishes a markdown receipt as a public citeable at `cited.md/shop3/` -- Receipt includes: query, search results considered, product, price, tx hash, BaseScan link +- Receipt includes: query, search results considered, product, price, and tx hash ### External Services @@ -191,9 +191,15 @@ DD_AGENT_HOST=localhost # default DD_AGENT_PORT=8126 # default ``` +## Spend Guard + +The $10/day limit is enforced by a ClickHouse-backed ledger before each Circle transaction is submitted. Every payment records a row to `agent_spend`; the pre-flight check sums today's rows and rejects the payment if adding the new amount would exceed the cap. This persists across process restarts (unlike an in-memory guard). The limit is configurable via `MAX_DAILY_USD`. + +This is a server-side JS guard β€” it cannot be bypassed by an external attacker, but could be bypassed by modifying the agent source. For the demo it is the authoritative mechanism; a production deployment could layer on a Circle wallet policy for custodian-level enforcement. + ## Agent Wallet -The agent's smart wallet address on Base Sepolia: +The agent's Circle wallet on ARC-TESTNET: ``` 0x490776E3c67986f1A2385413e52FAeE1772A729A diff --git a/agent.js b/agent.js index f7bd688..75c538c 100644 --- a/agent.js +++ b/agent.js @@ -1,6 +1,6 @@ const Anthropic = require('@anthropic-ai/sdk'); const { searchWeb } = require('./search'); -const { mockPaymentFlow, getWalletAddress } = require('./payment'); +const { mockPaymentFlow, getWalletAddress, getWalletStatus } = require('./payment'); const { logPurchase, getRecentPurchases } = require('./memory'); const { publishReceipt } = require('./publish'); const { notifyPurchase } = require('./notify'); @@ -12,19 +12,30 @@ const client = new Anthropic(); const tools = [ { name: 'search_web', - description: 'Search the web for products, services, or information using Nimble. Returns titles, URLs, and descriptions.', + description: 'Search the web for products, services, or information via the Shop3 x402 bridge. Returns structured results.', input_schema: { type: 'object', properties: { query: { type: 'string', description: 'The search query' }, num_results: { type: 'number', description: 'Number of results to return (default 5)' }, + schema: { + type: 'object', + description: 'Optional structured extraction schema. Specify fields to extract from each result.', + properties: { + fields: { + type: 'array', + items: { type: 'string', enum: ['name', 'price', 'url', 'rating', 'vendor', 'description'] }, + description: 'Fields to extract from each search result', + }, + }, + }, }, required: ['query'], }, }, { name: 'pay_for_purchase', - description: 'Pay for a selected product/service from the agent smart wallet using USDC on ARC-TESTNET. Enforces a daily spending limit.', + description: 'Pay for a selected product/service from the agent smart wallet using USDC on ARC-TESTNET via Circle. Enforces a daily spending limit.', input_schema: { type: 'object', properties: { @@ -37,7 +48,7 @@ const tools = [ }, { name: 'log_to_database', - description: 'Log a completed purchase to ClickHouse for audit trail.', + description: 'Log a completed purchase to ClickHouse for audit trail and analytics.', input_schema: { type: 'object', properties: { @@ -89,20 +100,22 @@ async function executeTool(name, input, context, dryRun) { console.log(`\n[agent] Searching: "${input.query}"`); const start = Date.now(); const results = await withSpan('agent.tool.search_web', { query: input.query }, () => - searchWeb(input.query, input.num_results ?? 5) + searchWeb(input.query, input.num_results ?? 5, input.schema ?? null) ); context.searchResults = results; + context.toolsInvoked.push('search_web'); timing('tool.duration_ms', Date.now() - start, { tool: 'search_web' }); - gauge('search.results_count', results.length, { query: input.query }); + gauge('search.results_count', results.length); console.log(`[agent] Found ${results.length} results`); - results.forEach((r, i) => console.log(` ${i + 1}. ${r.title} β€” ${r.url}`)); + results.forEach((r, i) => console.log(` ${i + 1}. ${r.title ?? r.name} β€” ${r.url}`)); return results; } case 'pay_for_purchase': { + context.toolsInvoked.push('pay_for_purchase'); if (dryRun) { console.log(`\n[agent] DRY RUN β€” would pay for: ${input.selected_result} (${input.price})`); - context.txHash = '0x0000000000000000000000000000000000000000000000000000000000000000'; + context.txHash = '0x00000000000000000000000000000000000000000000000000000000deadbeef'; context.selectedResult = input.selected_result; context.price = input.price; context.sourceUrl = input.source_url; @@ -125,6 +138,8 @@ async function executeTool(name, input, context, dryRun) { case 'log_to_database': { console.log(`\n[agent] Logging purchase to ClickHouse`); + context.toolsInvoked.push('log_to_database'); + const priceUsd = parseFloat((input.price ?? '').replace(/[^0-9.]/g, '')) || 0; await withSpan('agent.tool.log_to_database', {}, () => logPurchase({ query: input.query, @@ -132,12 +147,17 @@ async function executeTool(name, input, context, dryRun) { price: input.price, txHash: input.tx_hash, sourceUrl: input.source_url, + nimbleResultsCount: context.searchResults?.length ?? 0, + totalLatencyMs: Date.now() - context.runStart, + toolsInvoked: [...context.toolsInvoked], + priceUsd, }) ); return { success: true }; } case 'publish_receipt': { + context.toolsInvoked.push('publish_receipt'); if (dryRun) { console.log(`\n[agent] DRY RUN β€” skipping receipt publish for: ${input.selected_result}`); return { success: true, dry_run: true, receipt_url: null }; @@ -158,12 +178,11 @@ async function executeTool(name, input, context, dryRun) { } case 'check_purchase_history': { + context.toolsInvoked.push('check_purchase_history'); const purchases = await withSpan('agent.tool.check_purchase_history', {}, () => getRecentPurchases(input.limit ?? 10) ); - if (purchases.length === 0) { - return { purchases: [], message: 'No purchases yet.' }; - } + if (purchases.length === 0) return { purchases: [], message: 'No purchases yet.' }; return { purchases: purchases.map((p) => ({ product: p.selected_result, @@ -179,32 +198,49 @@ async function executeTool(name, input, context, dryRun) { } } +async function printStartupBanner(walletAddress, dryRun) { + const status = await getWalletStatus().catch(() => null); + const bal = status?.balanceUsdc !== null && status?.balanceUsdc !== undefined + ? `$${status.balanceUsdc.toFixed(2)} USDC` + : 'unavailable'; + const spent = `$${(status?.spentTodayUsd ?? 0).toFixed(2)}`; + const cap = `$${(status?.dailyCapUsd ?? 10).toFixed(2)}`; + const network = status?.network ?? process.env.CIRCLE_NETWORK ?? 'ARC-TESTNET'; + + console.log('[agent] ' + '─'.repeat(54)); + console.log(`[agent] Wallet: ${walletAddress}`); + console.log(`[agent] Balance: ${bal}`); + console.log(`[agent] Daily cap: ${cap}`); + console.log(`[agent] Spent today: ${spent}`); + console.log(`[agent] Network: ${network}${dryRun ? ' [DRY RUN]' : ''}`); + console.log('[agent] ' + '─'.repeat(54)); +} + async function runAgent(userPrompt, { dryRun = false } = {}) { const runStart = Date.now(); increment('agent.run.started'); if (dryRun) increment('agent.run.dry_run'); const walletAddress = await getWalletAddress(); - console.log(`\n[agent] Smart wallet: ${walletAddress}`); - if (dryRun) console.log('[agent] DRY RUN MODE β€” no payments or receipts will be submitted'); - console.log(`[agent] Starting: "${userPrompt}"\n`); + await printStartupBanner(walletAddress, dryRun); + console.log(`\n[agent] Starting: "${userPrompt}"\n`); - const context = {}; + const context = { toolsInvoked: [], runStart }; const messages = [{ role: 'user', content: userPrompt }]; const systemPrompt = `You are Shop3, an autonomous Web3 shopping agent. Your job is to: 1. (Optional) Check purchase history to avoid buying duplicates -2. Search the web to find the best option matching the user's request +2. Search the web to find the best option matching the user's request. Use a structured schema when you know the fields you need (e.g. name, price, url). 3. Evaluate results and select the best one under the user's budget -4. Pay for it autonomously from your smart wallet (USDC on ARC-TESTNET) +4. Pay for it autonomously from your smart wallet (USDC on ARC-TESTNET via Circle) 5. Log the purchase to the database 6. Publish a verified receipt Your smart wallet address is: ${walletAddress} -Daily spend limit: $${parseFloat(process.env.MAX_DAILY_USD) || 10} USD (enforced on-chain) -Network: ARC-TESTNET +Daily spend limit: $${parseFloat(process.env.MAX_DAILY_USD) || 10} USD +Network: ARC-TESTNET (Circle) Payment token: USDC -${dryRun ? '\nDRY RUN: You are in simulation mode. Payments will not be executed and receipts will not be published.' : ''} +${dryRun ? '\nDRY RUN: Simulation mode. Payments will not be executed and receipts will not be published.' : ''} When selecting a result to buy, prefer options that are: - Under $10/month or one-time diff --git a/history.js b/history.js index 7c50572..1ffc890 100644 --- a/history.js +++ b/history.js @@ -1,31 +1,75 @@ require('dotenv').config(); -const { getRecentPurchases } = require('./memory'); +const { getRecentPurchases, getAnalytics } = require('./memory'); -const limit = parseInt(process.argv[2], 10) || 10; +const args = process.argv.slice(2); +const statsMode = args.includes('--stats'); +const limitArg = args.find((a) => !a.startsWith('--')); +const limit = parseInt(limitArg, 10) || 10; -getRecentPurchases(limit) - .then((rows) => { - if (rows.length === 0) { - console.log('No purchases yet.'); +if (statsMode) { + getAnalytics() + .then(({ topDomains, toolsDistribution, summary }) => { + console.log('\n── Shop3 Analytics (last 7 days) ──────────────────────────\n'); + + console.log('Top source domains:'); + if (topDomains.length === 0) { + console.log(' (no data)'); + } else { + topDomains.forEach((r) => console.log(` ${String(r.picks).padEnd(4)} ${r.domain}`)); + } + + console.log('\nTools per purchase:'); + if (toolsDistribution.length === 0) { + console.log(' (no data)'); + } else { + toolsDistribution.forEach((r) => + console.log(` ${r.tool_count} tools β†’ ${r.purchases} purchase(s)`) + ); + } + + console.log('\nSummary:'); + console.log(` Purchases: ${summary.total_purchases ?? 0}`); + console.log(` Total spent: $${parseFloat(summary.total_spent_usd ?? 0).toFixed(2)}`); + console.log(` Avg price: $${parseFloat(summary.avg_price_usd ?? 0).toFixed(2)}`); + console.log(` Avg duration: ${parseFloat(summary.avg_duration_s ?? 0).toFixed(1)}s`); + console.log('\n─'.repeat(55)); process.exit(0); - } - - console.log(`\nLast ${rows.length} purchase(s):\n`); - console.log('─'.repeat(100)); - - for (const row of rows) { - console.log(`Time: ${row.timestamp}`); - console.log(`Query: ${row.query}`); - console.log(`Product: ${row.selected_result}`); - console.log(`Price: ${row.price}`); - console.log(`Tx: https://sepolia.basescan.org/tx/${row.tx_hash}`); - console.log(`Source: ${row.source_url}`); + }) + .catch((err) => { + console.error('[history] Analytics failed:', err.message); + process.exit(1); + }); +} else { + getRecentPurchases(limit) + .then((rows) => { + if (rows.length === 0) { + console.log('No purchases yet.'); + process.exit(0); + } + + console.log(`\nLast ${rows.length} purchase(s):\n`); console.log('─'.repeat(100)); - } - - process.exit(0); - }) - .catch((err) => { - console.error('[history] Failed:', err.message); - process.exit(1); - }); + + for (const row of rows) { + console.log(`Time: ${row.timestamp}`); + console.log(`Query: ${row.query}`); + console.log(`Product: ${row.selected_result}`); + console.log(`Price: ${row.price}`); + console.log(`Tx: ${row.tx_hash}`); + console.log(`Source: ${row.source_url}`); + if (row.tools_invoked?.length) { + console.log(`Tools: ${row.tools_invoked.join(' β†’ ')}`); + } + if (row.total_latency_ms) { + console.log(`Time: ${(row.total_latency_ms / 1000).toFixed(1)}s`); + } + console.log('─'.repeat(100)); + } + + process.exit(0); + }) + .catch((err) => { + console.error('[history] Failed:', err.message); + process.exit(1); + }); +} diff --git a/index.js b/index.js index 3626758..a92ee68 100644 --- a/index.js +++ b/index.js @@ -2,7 +2,7 @@ require('dotenv').config(); const REQUIRED_ENV = [ 'ANTHROPIC_API_KEY', - 'NIMBLE_API_KEY', + 'SEARCH_MIDDLEWARE_URL', // agent always routes through the x402 bridge 'CIRCLE_API_KEY', 'CIRCLE_ENTITY_SECRET', 'CIRCLE_WALLET_ADDRESS', diff --git a/memory.js b/memory.js index c916f10..bb676e2 100644 --- a/memory.js +++ b/memory.js @@ -14,7 +14,8 @@ function getClient() { } async function ensureTable() { - await getClient().exec({ + const db = getClient(); + await db.exec({ query: ` CREATE TABLE IF NOT EXISTS agent_purchases ( timestamp DateTime DEFAULT now(), @@ -22,12 +23,25 @@ async function ensureTable() { selected_result String, price String, tx_hash String, - source_url String + source_url String, + nimble_results_count UInt32 DEFAULT 0, + total_latency_ms UInt64 DEFAULT 0, + tools_invoked Array(String) DEFAULT [], + price_usd Float32 DEFAULT 0 ) ENGINE = MergeTree() ORDER BY timestamp `, }); - await getClient().exec({ + // Idempotent column additions for existing tables + for (const col of [ + 'nimble_results_count UInt32 DEFAULT 0', + 'total_latency_ms UInt64 DEFAULT 0', + 'tools_invoked Array(String) DEFAULT []', + 'price_usd Float32 DEFAULT 0', + ]) { + await db.exec({ query: `ALTER TABLE agent_purchases ADD COLUMN IF NOT EXISTS ${col}` }); + } + await db.exec({ query: ` CREATE TABLE IF NOT EXISTS agent_spend ( timestamp DateTime DEFAULT now(), @@ -38,19 +52,22 @@ async function ensureTable() { }); } -async function logPurchase({ query, selectedResult, price, txHash, sourceUrl }) { +async function logPurchase({ query, selectedResult, price, txHash, sourceUrl, + nimbleResultsCount = 0, totalLatencyMs = 0, toolsInvoked = [], priceUsd = 0 }) { await ensureTable(); await getClient().insert({ table: 'agent_purchases', - values: [ - { - query, - selected_result: selectedResult, - price, - tx_hash: txHash, - source_url: sourceUrl, - }, - ], + values: [{ + query, + selected_result: selectedResult, + price, + tx_hash: txHash, + source_url: sourceUrl, + nimble_results_count: nimbleResultsCount, + total_latency_ms: totalLatencyMs, + tools_invoked: toolsInvoked, + price_usd: priceUsd, + }], format: 'JSONEachRow', }); console.log('[memory] Purchase logged to ClickHouse'); @@ -66,6 +83,47 @@ async function getRecentPurchases(limit = 10) { return await result.json(); } +async function getAnalytics() { + await ensureTable(); + const db = getClient(); + + const [domainsRes, toolsRes, summaryRes] = await Promise.all([ + db.query({ + query: ` + SELECT extract(source_url, 'https?://([^/]+)') AS domain, count() AS picks + FROM agent_purchases + GROUP BY domain ORDER BY picks DESC LIMIT 5 + `, + format: 'JSONEachRow', + }), + db.query({ + query: ` + SELECT length(tools_invoked) AS tool_count, count() AS purchases + FROM agent_purchases + GROUP BY tool_count ORDER BY tool_count + `, + format: 'JSONEachRow', + }), + db.query({ + query: ` + SELECT count() AS total_purchases, + sum(price_usd) AS total_spent_usd, + avg(price_usd) AS avg_price_usd, + avg(total_latency_ms / 1000) AS avg_duration_s + FROM agent_purchases + WHERE timestamp > now() - INTERVAL 7 DAY + `, + format: 'JSONEachRow', + }), + ]); + + return { + topDomains: await domainsRes.json(), + toolsDistribution: await toolsRes.json(), + summary: (await summaryRes.json())[0] ?? {}, + }; +} + async function recordSpend(amountUSD) { await ensureTable(); await getClient().insert({ @@ -87,4 +145,4 @@ async function getSpendToday() { return parseFloat(rows[0]?.total ?? 0); } -module.exports = { logPurchase, getRecentPurchases, recordSpend, getSpendToday }; +module.exports = { logPurchase, getRecentPurchases, getAnalytics, recordSpend, getSpendToday }; diff --git a/package.json b/package.json index ed66ff7..f1c2330 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "test": "echo \"Error: no test specified\" && exit 1", "start:server": "node server.js", "history": "node history.js", + "history:stats": "node history.js --stats", "setup:geo": "node scripts/setup-geo.js", "geo:status": "node scripts/geo-status.js", "lapdog": "lapdog node index.js", diff --git a/payment.js b/payment.js index 8cd7346..4a7d849 100644 --- a/payment.js +++ b/payment.js @@ -1,3 +1,4 @@ +const { randomUUID } = require('crypto'); const { initiateDeveloperControlledWalletsClient } = require('@circle-fin/developer-controlled-wallets'); const { isAddress } = require('viem'); const axios = require('axios'); @@ -20,6 +21,35 @@ async function getWalletAddress() { return addr; } +async function getWalletStatus() { + const address = process.env.CIRCLE_WALLET_ADDRESS; + const network = process.env.CIRCLE_NETWORK || 'ARC-TESTNET'; + const dailyCapUsd = MAX_DAILY_USD; + let balanceUsdc = null; + let spentTodayUsd = 0; + + try { + const client = getCircleClient(); + const res = await client.listWalletTokenBalances({ walletId: process.env.CIRCLE_WALLET_ID }); + const balances = res.data?.tokenBalances ?? []; + const usdc = balances.find( + (b) => b.token?.symbol === 'USDC' || + b.token?.tokenAddress?.toLowerCase() === process.env.USDC_TOKEN_ADDRESS?.toLowerCase() + ); + balanceUsdc = parseFloat(usdc?.amount ?? '0'); + } catch { + // non-fatal β€” banner still prints with 'unavailable' + } + + try { + spentTodayUsd = await getSpendToday(); + } catch { + // non-fatal + } + + return { address, network, balanceUsdc, spentTodayUsd, dailyCapUsd }; +} + async function checkSpendLimit(amountUSD) { if (isNaN(amountUSD) || amountUSD <= 0) { throw new Error(`Invalid payment amount: ${amountUSD}`); @@ -45,11 +75,32 @@ async function checkWalletBalance(client, amountUSD) { } } catch (err) { if (err.message.startsWith('Insufficient')) throw err; - // Balance check is best-effort β€” don't block payment if API call fails console.warn(`[payment] Could not verify wallet balance (proceeding): ${err.message}`); } } +function isNonRetryable(err) { + const msg = (err?.message ?? '').toUpperCase(); + return msg.includes('INSUFFICIENT_FUNDS') || msg.includes('POLICY_DENIED') || + msg.includes('DENIED') || msg.includes('CANCELLED'); +} + +async function retryWithBackoff(fn, { attempts = 2, delayMs = 5000 } = {}) { + for (let i = 0; i < attempts; i++) { + try { + return await fn(); + } catch (err) { + if (i < attempts - 1 && !isNonRetryable(err)) { + console.log(`[payment] Attempt ${i + 1} failed: ${err.message}. Retrying in ${delayMs / 1000}s...`); + increment('payment.tx.retried', { attempt: String(i + 1) }); + await new Promise((r) => setTimeout(r, delayMs)); + } else { + throw err; + } + } + } +} + // Poll Circle until the transaction reaches a terminal state, return blockchain txHash async function waitForCircleTx(client, txId, timeoutMs = 120000) { const start = Date.now(); @@ -84,18 +135,24 @@ async function handle402Payment(paymentInfo) { const client = getCircleClient(); await checkWalletBalance(client, amountUSD); + const idempotencyKey = randomUUID(); let txId; + try { - const res = await client.createTransaction({ - walletId: process.env.CIRCLE_WALLET_ID, - blockchain: process.env.CIRCLE_NETWORK || 'ARC-TESTNET', - destinationAddress: payTo, - amounts: [amount.toString()], - tokenAddress: process.env.USDC_TOKEN_ADDRESS, - fee: { type: 'level', config: { feeLevel: 'MEDIUM' } }, + txId = await retryWithBackoff(async () => { + const res = await client.createTransaction({ + walletId: process.env.CIRCLE_WALLET_ID, + blockchain: process.env.CIRCLE_NETWORK || 'ARC-TESTNET', + destinationAddress: payTo, + amounts: [amount.toString()], + tokenAddress: process.env.USDC_TOKEN_ADDRESS, + fee: { type: 'level', config: { feeLevel: 'MEDIUM' } }, + idempotencyKey, + }); + const id = res.data?.id; + if (!id) throw new Error('Circle did not return a transaction ID'); + return id; }); - txId = res.data?.id; - if (!txId) throw new Error('Circle did not return a transaction ID'); span.setTag('payment.circle_tx_id', txId); increment('payment.tx.submitted', { token, chain }); console.log(`[payment] Circle tx submitted: ${txId}`); @@ -134,7 +191,6 @@ async function handle402Payment(paymentInfo) { }); } -// Simulate hitting a 402 endpoint (for demo) async function fetchWithPayment(url, headers = {}) { try { const response = await axios.get(url, { headers }); @@ -153,7 +209,6 @@ async function fetchWithPayment(url, headers = {}) { } } -// Mock a 402 flow for demo when no real paywall endpoint is available async function mockPaymentFlow(selectedResult, price) { console.log(`[payment] Simulating 402 payment for: ${selectedResult}`); @@ -170,4 +225,4 @@ async function mockPaymentFlow(selectedResult, price) { return handle402Payment(mockPaymentInfo); } -module.exports = { fetchWithPayment, mockPaymentFlow, getWalletAddress, handle402Payment }; +module.exports = { fetchWithPayment, mockPaymentFlow, getWalletAddress, getWalletStatus, handle402Payment }; diff --git a/publish.js b/publish.js index 5bfe8db..a74f66f 100644 --- a/publish.js +++ b/publish.js @@ -52,7 +52,7 @@ ${resultList || 'N/A'} ## Payment Proof - **Transaction Hash:** \`${txHash}\` -- **Chain:** ARC-TESTNET +- **Chain:** ARC-TESTNET (Circle) - **Token:** USDC --- diff --git a/search.js b/search.js index 0f22196..e478c41 100644 --- a/search.js +++ b/search.js @@ -1,37 +1,16 @@ -const axios = require('axios'); const { fetchWithPayment } = require('./payment'); -async function searchWeb(query, numResults = 5) { +async function searchWeb(query, numResults = 5, schema = null) { const middlewareUrl = process.env.SEARCH_MIDDLEWARE_URL; - if (middlewareUrl && process.env.SERVER_MODE !== 'true') { - const url = new URL(middlewareUrl); - url.searchParams.set('query', query); - url.searchParams.set('num_results', String(numResults)); + if (!middlewareUrl) throw new Error('SEARCH_MIDDLEWARE_URL not set β€” start the middleware with: npm run start:server'); - const { data } = await fetchWithPayment(url.toString()); - return data?.results ?? []; - } + const url = new URL(middlewareUrl); + url.searchParams.set('query', query); + url.searchParams.set('num_results', String(numResults)); + if (schema) url.searchParams.set('schema', JSON.stringify(schema)); - const apiKey = process.env.NIMBLE_API_KEY; - if (!apiKey) throw new Error('NIMBLE_API_KEY not set'); - - const response = await axios.post( - 'https://sdk.nimbleway.com/v1/search', - { query, max_results: numResults }, - { - headers: { - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json', - }, - } - ); - - const results = response.data?.results ?? []; - return results.map((r) => ({ - title: r.title, - url: r.url, - description: r.description, - })); + const { data } = await fetchWithPayment(url.toString()); + return data?.results ?? []; } module.exports = { searchWeb }; diff --git a/server.js b/server.js index b1b27fd..599323e 100644 --- a/server.js +++ b/server.js @@ -1,9 +1,9 @@ require('dotenv').config(); -process.env.SERVER_MODE = 'true'; const express = require('express'); +const axios = require('axios'); +const Anthropic = require('@anthropic-ai/sdk'); const { initiateDeveloperControlledWalletsClient } = require('@circle-fin/developer-controlled-wallets'); -const { searchWeb } = require('./search'); const app = express(); const PORT = parseInt(process.env.SERVER_PORT, 10) || 3000; @@ -12,10 +12,38 @@ const PAYMENT_PRICE = process.env.SEARCH_PAYMENT_AMOUNT || '0.001'; const PAYMENT_TOKEN = process.env.SEARCH_PAYMENT_TOKEN || 'USDC'; const PAYMENT_CHAIN = process.env.SEARCH_PAYMENT_CHAIN || 'ARC-TESTNET'; +const ALLOWED_SCHEMA_FIELDS = new Set(['name', 'price', 'url', 'rating', 'vendor', 'description']); + +// In-memory replay guard β€” resets on server restart, sufficient for testnet/dev use +const usedTxHashes = new Set(); + function isValidTxHash(value) { return typeof value === 'string' && /^0x[a-fA-F0-9]{64}$/.test(value); } +async function nimbleSearch(query, numResults = 5) { + const apiKey = process.env.NIMBLE_API_KEY; + if (!apiKey) throw new Error('NIMBLE_API_KEY not set'); + + const response = await axios.post( + 'https://sdk.nimbleway.com/v1/search', + { query, max_results: numResults }, + { + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + } + ); + + return (response.data?.results ?? []).map((r) => ({ + title: r.title, + url: r.url, + description: r.description, + })); +} + +// Verify a Circle tx: state, recipient, amount, chain β€” returns { ok, reason } async function verifyCirclePayment(txHash) { const client = initiateDeveloperControlledWalletsClient({ apiKey: process.env.CIRCLE_API_KEY, @@ -23,14 +51,75 @@ async function verifyCirclePayment(txHash) { }); const res = await client.listTransactions({ txHash }); const tx = res.data?.transactions?.[0]; - return tx?.state === 'CONFIRMED' || tx?.state === 'COMPLETE'; + + if (!tx) return { ok: false, reason: 'tx_not_found' }; + if (tx.state !== 'CONFIRMED' && tx.state !== 'COMPLETE') return { ok: false, reason: 'not_confirmed' }; + if (tx.destinationAddress?.toLowerCase() !== PAYMENT_PAYTO.toLowerCase()) { + return { ok: false, reason: 'wrong_recipient' }; + } + const paid = parseFloat(tx.amounts?.[0] ?? '0'); + if (paid < parseFloat(PAYMENT_PRICE)) { + return { ok: false, reason: 'amount_too_low', paid, required: PAYMENT_PRICE }; + } + if (tx.blockchain && tx.blockchain !== PAYMENT_CHAIN) { + return { ok: false, reason: 'wrong_chain' }; + } + return { ok: true }; } -// In-memory replay guard β€” resets on restart, sufficient for testnet/dev use -const usedTxHashes = new Set(); +function validateSchema(schemaStr) { + let parsed; + try { + parsed = JSON.parse(schemaStr); + } catch { + return { valid: false, error: 'schema must be valid JSON' }; + } + if (!Array.isArray(parsed?.fields)) { + return { valid: false, error: 'schema.fields must be an array of strings' }; + } + const invalid = parsed.fields.filter((f) => !ALLOWED_SCHEMA_FIELDS.has(f)); + if (invalid.length > 0) { + return { valid: false, error: `unknown fields: ${invalid.join(', ')}. Allowed: ${[...ALLOWED_SCHEMA_FIELDS].join(', ')}` }; + } + return { valid: true, fields: parsed.fields }; +} + +async function extractWithSchema(results, fields) { + const client = new Anthropic(); + const prompt = `Extract the following fields from each search result. Return only a JSON array with one object per result, in the same order. If a field cannot be determined, use null. + +Fields to extract: ${fields.join(', ')} + +Search results: +${JSON.stringify(results.map((r) => ({ title: r.title, url: r.url, description: r.description })), null, 2)}`; + + const response = await client.messages.create({ + model: 'claude-haiku-4-5-20251001', + max_tokens: 1024, + messages: [{ role: 'user', content: prompt }], + }); + + try { + const text = response.content[0]?.text ?? '[]'; + const jsonMatch = text.match(/\[[\s\S]*\]/); + const extracted = JSON.parse(jsonMatch ? jsonMatch[0] : text); + return extracted.map((item, i) => { + const safe = {}; + for (const f of fields) safe[f] = item[f] ?? null; + safe.url = results[i]?.url; // always preserve original URL + return safe; + }); + } catch { + return results.map((r) => { + const obj = { url: r.url, unparseable: true }; + for (const f of fields) obj[f] = null; + return obj; + }); + } +} app.get('/', (req, res) => { - res.json({ message: 'Search middleware running', version: '1.0.0' }); + res.json({ message: 'Shop3 Nimbleβ†’x402 bridge running', version: '1.0.0' }); }); app.get('/health', (req, res) => { @@ -43,6 +132,14 @@ app.get('/search', async (req, res) => { return res.status(400).json({ error: 'query parameter is required' }); } + // Parse and validate optional schema + let schemaFields = null; + if (req.query.schema) { + const { valid, fields, error } = validateSchema(req.query.schema); + if (!valid) return res.status(400).json({ error: `Invalid schema: ${error}` }); + schemaFields = fields; + } + const paymentProof = req.header('x-payment-proof'); if (!paymentProof) { return res.status(402).json({ @@ -55,36 +152,31 @@ app.get('/search', async (req, res) => { } if (!isValidTxHash(paymentProof)) { - return res.status(402).json({ - error: 'Invalid payment proof', - required_payment: { - price: `${PAYMENT_PRICE} ${PAYMENT_TOKEN}`, - payTo: PAYMENT_PAYTO, - reason: 'Pay to search the web', - chain: PAYMENT_CHAIN, - token: PAYMENT_TOKEN, - }, - }); + return res.status(402).json({ error: 'Invalid payment proof format' }); } if (usedTxHashes.has(paymentProof)) { return res.status(402).json({ error: 'Payment proof already used' }); } - try { - const confirmed = await verifyCirclePayment(paymentProof); - if (!confirmed) { - return res.status(402).json({ error: 'Payment transaction not confirmed on-chain' }); - } - } catch { - return res.status(402).json({ error: 'Could not verify payment transaction via Circle' }); + const verification = await verifyCirclePayment(paymentProof).catch((err) => ({ + ok: false, reason: 'verification_error', detail: err.message, + })); + + if (!verification.ok) { + return res.status(402).json({ error: 'Payment verification failed', reason: verification.reason }); } usedTxHashes.add(paymentProof); try { const numResults = Number(req.query.num_results) || 5; - const results = await searchWeb(query, numResults); + let results = await nimbleSearch(query, numResults); + + if (schemaFields) { + results = await extractWithSchema(results, schemaFields); + } + return res.json({ results }); } catch (err) { console.error('[server] Search failed:', err); @@ -93,6 +185,6 @@ app.get('/search', async (req, res) => { }); app.listen(PORT, () => { - console.log(`[server] Search middleware running at http://localhost:${PORT}`); - console.log(`[server] Payment handler expects x-payment-proof header and returns 402 if missing.`); + console.log(`[server] Shop3 Nimbleβ†’x402 bridge running at http://localhost:${PORT}`); + console.log(`[server] Agent pays ${PAYMENT_PRICE} ${PAYMENT_TOKEN} per search β†’ verified on-chain before results are returned`); });