From 3cfa73c9b4e340ff3324990d96804ed779851704 Mon Sep 17 00:00:00 2001 From: Aaron Date: Sat, 23 May 2026 14:36:01 -0400 Subject: [PATCH 1/2] Remove Claude 3.5 Sonnet reference from agentic loop feature blurb Co-Authored-By: Claude Sonnet 4.6 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 38316c2..e1724dd 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. From c14a4d0d62021f640772dae9c53e13d3701af514 Mon Sep 17 00:00:00 2001 From: Aaron Date: Sat, 23 May 2026 14:43:55 -0400 Subject: [PATCH 2/2] Resolve issues #7 #8 #9 #10 #11 #12 #14 #15 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #15 β€” Fix stale Base Sepolia/BaseScan references (demo-blocker) - publish.js: chain label updated to ARC-TESTNET (Circle), no broken link - history.js: removed BaseScan URL, tx hash shown as plain text - README: fixed wallet section heading and receipt description #14 β€” Auto-retry with backoff on Circle transaction failures - payment.js: retryWithBackoff() helper (2 attempts, 5s linear backoff) - idempotencyKey (randomUUID) on createTransaction prevents double-spend on retry - Non-retryable errors (DENIED, INSUFFICIENT_FUNDS) bypass retry immediately - Emits shop3.payment.tx.retried metric #12 β€” Pre-flight wallet status banner at agent startup - payment.js: getWalletStatus() fetches USDC balance + spend today (non-fatal) - agent.js: 5-line banner printed before first tool call (wallet, balance, daily cap, spent today, network) #11 β€” Structured extraction schema in search middleware - server.js: accepts ?schema={"fields":["name","price","url",...]} param - Field whitelist enforced (name, price, url, rating, vendor, description) - Extraction batched into one claude-haiku-4-5 call per query - agent.js: search_web tool exposes optional schema field - search.js: passes schema through to middleware URL #10 β€” ClickHouse analytics schema + aggregation queries - memory.js: 4 new columns (nimble_results_count, total_latency_ms, tools_invoked Array(String), price_usd); ALTER TABLE IF NOT EXISTS is idempotent - agent.js: captures and writes all 4 new fields at log_to_database step - history.js: --stats mode runs 3 ClickHouse aggregations (top domains, tools distribution, 7-day spend summary); npm run history:stats #9 β€” Document spend guard direction - README: "Spend Guard" section documents Option A (ClickHouse-backed JS guard) with honest scope statement #8 β€” Harden 402 payment verification - server.js: verifyCirclePayment() now checks state, recipient address, amount (>= PAYMENT_PRICE), and chain β€” returns {ok, reason} not boolean - 402 response body distinguishes tx_not_found / not_confirmed / wrong_recipient / amount_too_low / wrong_chain / already_used #7 β€” Make x402 middleware always-on - search.js: SERVER_MODE branch removed; agent always routes through SEARCH_MIDDLEWARE_URL; fails fast if env var is unset - server.js: nimbleSearch() lives here only (agent never calls Nimble directly) - index.js: SEARCH_MIDDLEWARE_URL added to REQUIRED_ENV; NIMBLE_API_KEY removed - .env.example: NIMBLE_API_KEY annotated as server-only Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 3 +- README.md | 10 +++- agent.js | 76 ++++++++++++++++++++------- history.js | 96 ++++++++++++++++++++++++---------- index.js | 2 +- memory.js | 86 +++++++++++++++++++++++++----- package.json | 1 + payment.js | 81 ++++++++++++++++++++++++----- publish.js | 2 +- search.js | 37 +++---------- server.js | 144 +++++++++++++++++++++++++++++++++++++++++---------- 11 files changed, 405 insertions(+), 133 deletions(-) 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 e1724dd..19141d4 100644 --- a/README.md +++ b/README.md @@ -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`); });