From 8c264b6628a4dc1fd13c9a7ecbd97d70daee1871 Mon Sep 17 00:00:00 2001 From: Aaron Date: Sat, 23 May 2026 14:24:44 -0400 Subject: [PATCH 1/3] Add lapdog LLM observability support - telemetry.js: new withLLMSpan() wraps Claude API calls with llm.provider, llm.model, and token usage tags so lapdog captures cost and cache metrics - agent.js: agentic loop now uses withLLMSpan for every Claude call - package.json: add lapdog and lapdog:server npm scripts - README: document lapdog install + usage in Quick Start, update feature blurb Run `npm run lapdog` to get a live dashboard at lapdog.datadoghq.com showing token counts, cost, cache hit rates, and tool traces with no Datadog account. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 17 +++++++++++++++-- agent.js | 18 ++++++++++-------- package.json | 4 +++- telemetry.js | 19 ++++++++++++++++++- 4 files changed, 46 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 74e8c1f..63b3ab2 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ An autonomous Web3 shopping agent. Give it a prompt, it searches the web, picks ### 📊 Transparency & Observability - **Purchase Audit Log**: Every transaction is recorded in **ClickHouse Cloud**, capturing the original user query, selected product, price, and the immutable blockchain transaction hash. - **Verified Receipts (cited.md)**: Automatically publishes public, markdown-formatted receipts via the **Senso platform**. These receipts are "citeable," making the agent's actions discoverable by search engines and other AI agents. -- **Datadog Instrumentation**: Full observability with Datadog APM. Tracks end-to-end agent run durations, per-tool execution spans, and custom metrics for payment success rates and on-chain confirmation times. +- **Datadog APM + Lapdog**: Full observability with Datadog APM. Tracks end-to-end agent run durations, per-tool execution spans, and custom metrics for payment success rates and on-chain confirmation times. In development, use **lapdog** for a local live dashboard showing every Claude API call with token counts, cost, cache hit rates, and tool traces — no Datadog account required. - **GEO Monitoring**: Integrated AI brand visibility tracking. Monitors how major LLMs (ChatGPT, Claude, Perplexity, Gemini) perceive and represent the "Shop3" brand across the web. ## Quick start @@ -63,7 +63,20 @@ node history.js # last 10 purchases node history.js 25 # last N purchases ``` -6. (Optional) Run the local search middleware (x402 payment-gated search): +6. (Optional) Run with **lapdog** for a local LLM observability dashboard: + +```bash +# Install lapdog (one-time) +pip install ddapm-test-agent + +# Run agent with live token/cost/trace dashboard at lapdog.datadoghq.com +npm run lapdog + +# Or run the search server with lapdog +npm run lapdog:server +``` + +7. (Optional) Run the local search middleware (x402 payment-gated search): ```bash npm run start:server diff --git a/agent.js b/agent.js index b9972d9..c69448f 100644 --- a/agent.js +++ b/agent.js @@ -3,7 +3,7 @@ const { searchWeb } = require('./search'); const { mockPaymentFlow, getWalletAddress } = require('./payment'); const { logPurchase } = require('./memory'); const { publishReceipt } = require('./publish'); -const { withSpan, increment, gauge, timing } = require('./telemetry'); +const { withSpan, withLLMSpan, increment, gauge, timing } = require('./telemetry'); const client = new Anthropic(); @@ -173,13 +173,15 @@ Always complete all 4 steps: search → pay → log → publish. Do not stop ear // Agentic loop while (true) { - const response = await client.messages.create({ - model: 'claude-sonnet-4-6', - max_tokens: 4096, - system: systemPrompt, - tools, - messages, - }); + const response = await withLLMSpan('claude-sonnet-4-6', () => + client.messages.create({ + model: 'claude-sonnet-4-6', + max_tokens: 4096, + system: systemPrompt, + tools, + messages, + }) + ); messages.push({ role: 'assistant', content: response.content }); diff --git a/package.json b/package.json index eb4a735..5603259 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,9 @@ "start:server": "node server.js", "history": "node history.js", "setup:geo": "node scripts/setup-geo.js", - "geo:status": "node scripts/geo-status.js" + "geo:status": "node scripts/geo-status.js", + "lapdog": "lapdog node index.js", + "lapdog:server": "lapdog node server.js" }, "keywords": [], "author": "", diff --git a/telemetry.js b/telemetry.js index 4ced718..4b634c2 100644 --- a/telemetry.js +++ b/telemetry.js @@ -11,6 +11,23 @@ async function withSpan(name, tags, fn) { return tracer.trace(name, { tags }, fn); } +// Wrap a Claude API call so lapdog captures it as an LLM span with token/cost metadata. +// usage = { input_tokens, output_tokens, cache_read_input_tokens, cache_creation_input_tokens } +// model = e.g. 'claude-sonnet-4-6' +async function withLLMSpan(model, fn) { + return tracer.trace('claude.completion', { tags: { 'llm.provider': 'anthropic', 'llm.model': model } }, async (span) => { + const result = await fn(); + const usage = result?.usage; + if (usage && span) { + span.setTag('llm.usage.input_tokens', usage.input_tokens ?? 0); + span.setTag('llm.usage.output_tokens', usage.output_tokens ?? 0); + span.setTag('llm.usage.cache_read_tokens', usage.cache_read_input_tokens ?? 0); + span.setTag('llm.usage.cache_creation_tokens', usage.cache_creation_input_tokens ?? 0); + } + return result; + }); +} + // Increment a counter metric function increment(metric, tags = {}) { tracer.dogstatsd.increment(`shop3.${metric}`, 1, tagsArray(tags)); @@ -30,4 +47,4 @@ function tagsArray(obj) { return Object.entries(obj).map(([k, v]) => `${k}:${v}`); } -module.exports = { tracer, withSpan, increment, gauge, timing }; +module.exports = { tracer, withSpan, withLLMSpan, increment, gauge, timing }; From e48f429bc54f7554fc7c7342568528f36093fe48 Mon Sep 17 00:00:00 2001 From: Aaron Date: Sat, 23 May 2026 14:26:10 -0400 Subject: [PATCH 2/3] Surface payment spans in lapdog with outcome tags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit withSpan() now passes the span to the callback so callers can attach dynamic results. payment.js uses this to tag every payment.transaction span with: payment.status (confirmed/submit_failed/confirmation_failed), payment.confirmation_ms, payment.circle_tx_id, payment.tx_hash, and payment.daily_spend_usd — all visible in the lapdog trace dashboard. Co-Authored-By: Claude Sonnet 4.6 --- payment.js | 11 ++++++++++- telemetry.js | 5 +++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/payment.js b/payment.js index e1139b6..b5a70a3 100644 --- a/payment.js +++ b/payment.js @@ -60,7 +60,7 @@ async function handle402Payment(paymentInfo) { const amountUSD = parseFloat(amount); await checkSpendLimit(amountUSD); - return withSpan('payment.transaction', { token, chain, amount }, async () => { + return withSpan('payment.transaction', { token, chain, 'payment.amount_usd': amountUSD }, async (span) => { const client = getCircleClient(); let txId; @@ -75,9 +75,12 @@ async function handle402Payment(paymentInfo) { }); 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}`); } catch (err) { + span.setTag('payment.status', 'submit_failed'); + span.setTag('error', true); increment('payment.tx.error', { token, chain, reason: 'submit_failed' }); throw err; } @@ -90,12 +93,18 @@ async function handle402Payment(paymentInfo) { const confirmMs = Date.now() - confirmStart; await recordSpend(amountUSD); const spentToday = await getSpendToday(); + span.setTag('payment.status', 'confirmed'); + span.setTag('payment.confirmation_ms', confirmMs); + span.setTag('payment.tx_hash', txHash); + span.setTag('payment.daily_spend_usd', spentToday); timing('payment.confirmation_ms', confirmMs, { token, chain }); gauge('payment.amount_usd', amountUSD, { token, chain }); gauge('payment.daily_spend_usd', spentToday); increment('payment.tx.confirmed', { token, chain }); console.log(`[payment] Confirmed: ${txHash}`); } catch (err) { + span.setTag('payment.status', 'confirmation_failed'); + span.setTag('error', true); increment('payment.tx.error', { token, chain, reason: 'confirmation_failed' }); throw err; } diff --git a/telemetry.js b/telemetry.js index 4b634c2..26eef94 100644 --- a/telemetry.js +++ b/telemetry.js @@ -6,9 +6,10 @@ const tracer = require('dd-trace').init({ logInjection: true, }); -// Wrap an async fn in a named span, attaching arbitrary tags +// Wrap an async fn in a named span. The span is passed as the first arg to fn +// so callers can call span.setTag() to attach dynamic results. async function withSpan(name, tags, fn) { - return tracer.trace(name, { tags }, fn); + return tracer.trace(name, { tags }, (span) => fn(span)); } // Wrap a Claude API call so lapdog captures it as an LLM span with token/cost metadata. From cb1988e6ae63157d03723d101d84aeb5eb9bb061 Mon Sep 17 00:00:00 2001 From: Aaron Date: Sat, 23 May 2026 14:31:25 -0400 Subject: [PATCH 3/3] Add missing features: safety guards, dry-run, notifications, scheduling Safety: - agent.js: max-turns guard (MAX_AGENT_TURNS, default 10) aborts runaway loops - payment.js: wallet USDC balance check before submitting Circle transaction - payment.js: MAX_DAILY_USD now read from env var (default 10) - server.js: payment replay protection via in-memory usedTxHashes Set Agent capabilities: - agent.js: --dry-run mode skips payment and receipt, runs search/eval only - agent.js: check_purchase_history tool lets agent avoid duplicate purchases - agent.js: fix pay_for_purchase description (Base Sepolia -> ARC-TESTNET) - agent.js: system prompt now says Shop3 (was Valution Agent) - agent.js: notifyPurchase() called on every completed run New files: - notify.js: POST to WEBHOOK_URL on purchase complete (non-fatal if missing) - scripts/schedule.js: run SCHEDULED_PROMPTS on SCHEDULE_INTERVAL_HOURS repeat Cleanup: - package.json: remove dead ZeroDev deps, add schedule and dry-run scripts - index.js: fix name from Valution Agent to Shop3, wire --dry-run flag - .env.example: document MAX_DAILY_USD, MAX_AGENT_TURNS, WEBHOOK_URL, SCHEDULED_PROMPTS, SCHEDULE_INTERVAL_HOURS Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 11 +++++ README.md | 9 +++- agent.js | 101 +++++++++++++++++++++++++++++++++++--------- index.js | 18 ++++---- notify.js | 24 +++++++++++ package.json | 7 ++- payment.js | 23 +++++++++- scripts/schedule.js | 44 +++++++++++++++++++ server.js | 9 ++++ 9 files changed, 210 insertions(+), 36 deletions(-) create mode 100644 notify.js create mode 100644 scripts/schedule.js diff --git a/.env.example b/.env.example index 1cf3af4..5e2d105 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,17 @@ DD_API_KEY= DD_AGENT_HOST=localhost DD_AGENT_PORT=8126 +# Agent behaviour +MAX_DAILY_USD=10 +MAX_AGENT_TURNS=10 + +# Notifications — POST purchase events to this URL (optional) +WEBHOOK_URL= + +# Scheduled runs — JSON array of prompts, interval in hours (optional) +SCHEDULED_PROMPTS=[] +SCHEDULE_INTERVAL_HOURS=24 + # Circle Programmable Wallet CIRCLE_API_KEY= CIRCLE_ENTITY_SECRET= diff --git a/README.md b/README.md index 63b3ab2..38316c2 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,10 @@ An autonomous Web3 shopping agent. Give it a prompt, it searches the web, picks ### 💸 Web3 Payments & Safety - **Circle Programmable Wallets**: Uses **Circle's Developer-Controlled Wallets** on ARC-TESTNET. The agent holds its own USDC balance and signs transactions server-side via Circle's API — no private key management required. - **USDC on ARC-TESTNET**: Facilitates real-world value transfer using stablecoins on ARC-TESTNET, with the agent wallet pre-funded with 20 USDC. -- **Spend Guard**: A hard-coded safety mechanism that enforces a **$10/day spending limit**. This prevents the agent from runaway spending in the event of an infinite loop or adversarial prompt. +- **Spend Guard**: A configurable daily spending limit (default $10, set via `MAX_DAILY_USD`). Backed by ClickHouse so it persists across restarts. Prevents runaway spending from infinite loops or adversarial prompts. +- **Wallet Balance Check**: Verifies on-chain USDC balance before submitting a payment. Fails fast with a clear error rather than letting the Circle API reject mid-flight. +- **Max Turns Guard**: The agentic loop is capped at `MAX_AGENT_TURNS` (default 10) iterations. If Claude loops without completing, the run aborts and logs an error instead of burning API credits indefinitely. +- **Dry-Run Mode**: Pass `--dry-run` (or `npm run dry-run`) to simulate a full agent run — search and evaluate without executing any payment or publishing any receipt. Useful for testing prompts and budget planning. - **x402 Micropayment Protocol**: Implements a local "Payment Required" middleware. The agent handles `402` status codes by paying the required fee on-chain and retrying the request with a verifiable payment proof header. ### 📊 Transparency & Observability @@ -20,6 +23,10 @@ An autonomous Web3 shopping agent. Give it a prompt, it searches the web, picks - **Verified Receipts (cited.md)**: Automatically publishes public, markdown-formatted receipts via the **Senso platform**. These receipts are "citeable," making the agent's actions discoverable by search engines and other AI agents. - **Datadog APM + Lapdog**: Full observability with Datadog APM. Tracks end-to-end agent run durations, per-tool execution spans, and custom metrics for payment success rates and on-chain confirmation times. In development, use **lapdog** for a local live dashboard showing every Claude API call with token counts, cost, cache hit rates, and tool traces — no Datadog account required. - **GEO Monitoring**: Integrated AI brand visibility tracking. Monitors how major LLMs (ChatGPT, Claude, Perplexity, Gemini) perceive and represent the "Shop3" brand across the web. +- **Webhook Notifications**: Set `WEBHOOK_URL` to receive a POST on every completed (or dry-run) purchase with product, price, tx hash, and receipt URL. +- **Scheduled Runs**: `npm run schedule` runs a configurable list of prompts on a repeat interval (set via `SCHEDULED_PROMPTS` and `SCHEDULE_INTERVAL_HOURS`). +- **Purchase Memory**: The agent can call `check_purchase_history` before buying to avoid repurchasing the same product across separate runs. +- **Payment Replay Protection**: The x402 search middleware tracks used transaction hashes. A proof that has already unlocked a search result cannot be reused. ## Quick start diff --git a/agent.js b/agent.js index c69448f..f7bd688 100644 --- a/agent.js +++ b/agent.js @@ -1,10 +1,12 @@ const Anthropic = require('@anthropic-ai/sdk'); const { searchWeb } = require('./search'); const { mockPaymentFlow, getWalletAddress } = require('./payment'); -const { logPurchase } = require('./memory'); +const { logPurchase, getRecentPurchases } = require('./memory'); const { publishReceipt } = require('./publish'); +const { notifyPurchase } = require('./notify'); const { withSpan, withLLMSpan, increment, gauge, timing } = require('./telemetry'); +const MAX_TURNS = parseInt(process.env.MAX_AGENT_TURNS) || 10; const client = new Anthropic(); const tools = [ @@ -22,7 +24,7 @@ const tools = [ }, { name: 'pay_for_purchase', - description: 'Pay for a selected product/service from the agent smart wallet using USDC on Base Sepolia. Enforces $10/day spending limit.', + description: 'Pay for a selected product/service from the agent smart wallet using USDC on ARC-TESTNET. Enforces a daily spending limit.', input_schema: { type: 'object', properties: { @@ -68,9 +70,20 @@ const tools = [ required: ['query', 'selected_result', 'price', 'tx_hash', 'source_url'], }, }, + { + name: 'check_purchase_history', + description: 'Check what Shop3 has already purchased. Use this before buying to avoid duplicates.', + input_schema: { + type: 'object', + properties: { + limit: { type: 'number', description: 'Number of recent purchases to retrieve (default 10)' }, + }, + required: [], + }, + }, ]; -async function executeTool(name, input, context) { +async function executeTool(name, input, context, dryRun) { switch (name) { case 'search_web': { console.log(`\n[agent] Searching: "${input.query}"`); @@ -87,6 +100,15 @@ async function executeTool(name, input, context) { } case 'pay_for_purchase': { + if (dryRun) { + console.log(`\n[agent] DRY RUN — would pay for: ${input.selected_result} (${input.price})`); + context.txHash = '0x0000000000000000000000000000000000000000000000000000000000000000'; + context.selectedResult = input.selected_result; + context.price = input.price; + context.sourceUrl = input.source_url; + increment('payment.dry_run'); + return { success: true, dry_run: true, tx_hash: context.txHash }; + } console.log(`\n[agent] Paying for: ${input.selected_result} (${input.price})`); const start = Date.now(); const txHash = await withSpan('agent.tool.pay_for_purchase', { @@ -116,6 +138,10 @@ async function executeTool(name, input, context) { } case '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 }; + } console.log(`\n[agent] Publishing receipt to cited.md`); const url = await withSpan('agent.tool.publish_receipt', {}, () => publishReceipt({ @@ -131,48 +157,70 @@ async function executeTool(name, input, context) { return { success: true, receipt_url: url }; } + case '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.' }; + } + return { + purchases: purchases.map((p) => ({ + product: p.selected_result, + price: p.price, + when: p.timestamp, + tx_hash: p.tx_hash, + })), + }; + } + default: throw new Error(`Unknown tool: ${name}`); } } -async function runAgent(userPrompt) { +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`); const context = {}; - const messages = [ - { - role: 'user', - content: userPrompt, - }, - ]; + const messages = [{ role: 'user', content: userPrompt }]; - const systemPrompt = `You are an autonomous Web3 shopping agent. Your job is to: -1. Search the web to find the best option matching the user's request -2. Evaluate results and select the best one under the user's budget -3. Pay for it autonomously from your smart wallet (USDC on Base Sepolia testnet) -4. Log the purchase to the database -5. Publish a verified receipt + 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 +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) +5. Log the purchase to the database +6. Publish a verified receipt Your smart wallet address is: ${walletAddress} -Daily spend limit: $10 USD (enforced on-chain) -Network: Base Sepolia (testnet) +Daily spend limit: $${parseFloat(process.env.MAX_DAILY_USD) || 10} USD (enforced on-chain) +Network: ARC-TESTNET Payment token: USDC +${dryRun ? '\nDRY RUN: You are in 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 - Have clear pricing - Are reputable API services or products -Always complete all 4 steps: search → pay → log → publish. Do not stop early.`; +Always complete all steps: search → pay → log → publish. Do not stop early.`; + + let turns = 0; - // Agentic loop while (true) { + if (turns >= MAX_TURNS) { + throw new Error(`Agent exceeded maximum turn limit (${MAX_TURNS}). Aborting to prevent runaway loop.`); + } + turns++; + const response = await withLLMSpan('claude-sonnet-4-6', () => client.messages.create({ model: 'claude-sonnet-4-6', @@ -192,8 +240,18 @@ Always complete all 4 steps: search → pay → log → publish. Do not stop ear .join('\n'); timing('agent.run.duration_ms', Date.now() - runStart); increment('agent.run.completed'); + gauge('agent.run.turns', turns); console.log('\n[agent] Done.\n'); console.log(finalText); + + await notifyPurchase({ + selectedResult: context.selectedResult, + price: context.price, + txHash: context.txHash, + receiptUrl: context.receiptUrl, + dryRun, + }); + return { summary: finalText, ...context }; } @@ -205,9 +263,10 @@ Always complete all 4 steps: search → pay → log → publish. Do not stop ear let result; try { - result = await executeTool(block.name, block.input, context); + result = await executeTool(block.name, block.input, context, dryRun); } catch (err) { console.error(`[agent] Tool error (${block.name}):`, err.message); + increment('agent.tool.error', { tool: block.name }); result = { error: err.message }; } diff --git a/index.js b/index.js index ee72893..3626758 100644 --- a/index.js +++ b/index.js @@ -20,22 +20,22 @@ if (missing.length > 0) { const { runAgent } = require('./agent'); -const prompt = process.argv.slice(2).join(' ') || 'Find me the best web data API subscription under $10 and buy it'; +const args = process.argv.slice(2); +const dryRun = args.includes('--dry-run'); +const promptArgs = args.filter((a) => a !== '--dry-run'); +const prompt = promptArgs.join(' ') || 'Find me the best web data API subscription under $10 and buy it'; console.log('='.repeat(60)); -console.log(' Valution Agent — Autonomous Web3 Shopping'); +console.log(' Shop3 — Autonomous Web3 Shopping Agent'); console.log('='.repeat(60)); +if (dryRun) console.log(' [DRY RUN MODE]'); console.log(`\nPrompt: "${prompt}"\n`); -runAgent(prompt) +runAgent(prompt, { dryRun }) .then((result) => { console.log('\n' + '='.repeat(60)); - if (result.receiptUrl) { - console.log(`\nReceipt: ${result.receiptUrl}`); - } - if (result.txHash) { - console.log(`Tx: ${result.txHash}`); - } + if (result.receiptUrl) console.log(`Receipt: ${result.receiptUrl}`); + if (result.txHash) console.log(`Tx: ${result.txHash}`); console.log('='.repeat(60)); }) .catch((err) => { diff --git a/notify.js b/notify.js new file mode 100644 index 0000000..70ad97d --- /dev/null +++ b/notify.js @@ -0,0 +1,24 @@ +const axios = require('axios'); + +async function notifyPurchase({ selectedResult, price, txHash, receiptUrl, dryRun = false }) { + const url = process.env.WEBHOOK_URL; + if (!url) return; + + const payload = { + event: dryRun ? 'shop3.dry_run' : 'shop3.purchase_complete', + product: selectedResult, + price, + tx_hash: txHash ?? null, + receipt_url: receiptUrl ?? null, + timestamp: new Date().toISOString(), + }; + + try { + await axios.post(url, payload, { timeout: 5000 }); + console.log(`[notify] Webhook delivered to ${url}`); + } catch (err) { + console.warn(`[notify] Webhook failed (non-fatal): ${err.message}`); + } +} + +module.exports = { notifyPurchase }; diff --git a/package.json b/package.json index 5603259..ed66ff7 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,9 @@ "setup:geo": "node scripts/setup-geo.js", "geo:status": "node scripts/geo-status.js", "lapdog": "lapdog node index.js", - "lapdog:server": "lapdog node server.js" + "lapdog:server": "lapdog node server.js", + "schedule": "node scripts/schedule.js", + "dry-run": "node index.js --dry-run" }, "keywords": [], "author": "", @@ -20,13 +22,10 @@ "@anthropic-ai/sdk": "^0.98.0", "@circle-fin/developer-controlled-wallets": "^10.3.1", "@clickhouse/client": "^1.18.5", - "@zerodev/ecdsa-validator": "^5.4.9", - "@zerodev/sdk": "^5.5.10", "axios": "^1.16.1", "dd-trace": "^5.104.0", "dotenv": "^17.4.2", "express": "^5.2.1", - "permissionless": "^0.3.5", "tslib": "^2.8.1", "viem": "^2.50.4" } diff --git a/payment.js b/payment.js index b5a70a3..8cd7346 100644 --- a/payment.js +++ b/payment.js @@ -4,7 +4,7 @@ const axios = require('axios'); const { withSpan, increment, gauge, timing } = require('./telemetry'); const { getSpendToday, recordSpend } = require('./memory'); -const MAX_DAILY_USD = 10; +const MAX_DAILY_USD = parseFloat(process.env.MAX_DAILY_USD) || 10; function getCircleClient() { const apiKey = process.env.CIRCLE_API_KEY; @@ -30,6 +30,26 @@ async function checkSpendLimit(amountUSD) { } } +async function checkWalletBalance(client, amountUSD) { + try { + 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() + ); + const available = parseFloat(usdc?.amount ?? '0'); + console.log(`[payment] Wallet USDC balance: $${available.toFixed(2)}`); + if (available < amountUSD) { + throw new Error(`Insufficient USDC balance: $${available.toFixed(2)} available, $${amountUSD} required`); + } + } 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}`); + } +} + // Poll Circle until the transaction reaches a terminal state, return blockchain txHash async function waitForCircleTx(client, txId, timeoutMs = 120000) { const start = Date.now(); @@ -62,6 +82,7 @@ async function handle402Payment(paymentInfo) { return withSpan('payment.transaction', { token, chain, 'payment.amount_usd': amountUSD }, async (span) => { const client = getCircleClient(); + await checkWalletBalance(client, amountUSD); let txId; try { diff --git a/scripts/schedule.js b/scripts/schedule.js new file mode 100644 index 0000000..bb6f2e4 --- /dev/null +++ b/scripts/schedule.js @@ -0,0 +1,44 @@ +require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') }); + +const { runAgent } = require('../agent'); + +const PROMPTS = (() => { + try { + return JSON.parse(process.env.SCHEDULED_PROMPTS || '[]'); + } catch { + return []; + } +})(); + +const INTERVAL_HOURS = parseFloat(process.env.SCHEDULE_INTERVAL_HOURS) || 24; +const INTERVAL_MS = INTERVAL_HOURS * 60 * 60 * 1000; + +if (PROMPTS.length === 0) { + console.error('[schedule] No prompts found. Set SCHEDULED_PROMPTS in .env as a JSON array of strings.'); + console.error(' Example: SCHEDULED_PROMPTS=["Find me the best web data API under $10 and buy it"]'); + process.exit(1); +} + +console.log(`[schedule] Running ${PROMPTS.length} prompt(s) every ${INTERVAL_HOURS}h`); +PROMPTS.forEach((p, i) => console.log(` ${i + 1}. ${p}`)); + +async function runAll() { + const ts = new Date().toISOString(); + console.log(`\n[schedule] Firing at ${ts}`); + for (const prompt of PROMPTS) { + try { + console.log(`\n[schedule] → "${prompt}"`); + await runAgent(prompt); + } catch (err) { + console.error(`[schedule] Run failed for prompt "${prompt}": ${err.message}`); + } + } +} + +// Run immediately, then on interval +runAll().then(() => { + setInterval(runAll, INTERVAL_MS); +}).catch((err) => { + console.error('[schedule] Fatal error on first run:', err.message); + process.exit(1); +}); diff --git a/server.js b/server.js index 757c252..b1b27fd 100644 --- a/server.js +++ b/server.js @@ -26,6 +26,9 @@ async function verifyCirclePayment(txHash) { return tx?.state === 'CONFIRMED' || tx?.state === 'COMPLETE'; } +// In-memory replay guard — resets on restart, sufficient for testnet/dev use +const usedTxHashes = new Set(); + app.get('/', (req, res) => { res.json({ message: 'Search middleware running', version: '1.0.0' }); }); @@ -64,6 +67,10 @@ app.get('/search', async (req, res) => { }); } + if (usedTxHashes.has(paymentProof)) { + return res.status(402).json({ error: 'Payment proof already used' }); + } + try { const confirmed = await verifyCirclePayment(paymentProof); if (!confirmed) { @@ -73,6 +80,8 @@ app.get('/search', async (req, res) => { return res.status(402).json({ error: 'Could not verify payment transaction via Circle' }); } + usedTxHashes.add(paymentProof); + try { const numResults = Number(req.query.num_results) || 5; const results = await searchWeb(query, numResults);