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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

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

Expand Down Expand Up @@ -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
Expand Down
76 changes: 56 additions & 20 deletions agent.js
Original file line number Diff line number Diff line change
@@ -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');
Expand All @@ -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: {
Expand All @@ -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: {
Expand Down Expand Up @@ -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;
Expand All @@ -125,19 +138,26 @@ 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,
selectedResult: input.selected_result,
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 };
Expand All @@ -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,
Expand All @@ -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
Expand Down
96 changes: 70 additions & 26 deletions history.js
Original file line number Diff line number Diff line change
@@ -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);
});
}
2 changes: 1 addition & 1 deletion index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading
Loading