A2A x402 Gateway v2.0.0: Agent-to-Agent protocol with x402 micropayments
- A2A v0.3 compliant JSON-RPC server - x402 V2 micropayments (Base USDC + SKALE gasless) - SIWx session auth (wallet-based, pay-once-reuse) - 3 skills: screenshot, markdown-to-PDF, markdown-to-HTML - 19 tests passing - Web dashboard with payment flow visualization - Service catalog at /x402 for programmatic discovery Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
939251912b
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
*.log
|
||||
197
README.md
Normal file
197
README.md
Normal file
@ -0,0 +1,197 @@
|
||||
# A2A x402 Gateway
|
||||
|
||||
**Pay-per-request AI agent services via A2A protocol + x402 micropayments**
|
||||
|
||||
An A2A-compliant agent server that exposes screenshot, PDF, and document generation services with x402 cryptocurrency micropayments on Base network.
|
||||
|
||||
Built by [OpSpawn](https://opspawn.com) for the [SF Agentic Commerce x402 Hackathon](https://dorahacks.io/hackathon/x402).
|
||||
|
||||
## How It Works
|
||||
|
||||
```
|
||||
Agent Client → A2A Gateway → 402: Pay USDC → Service Delivery
|
||||
```
|
||||
|
||||
1. **Agent sends A2A message** (JSON-RPC over HTTP)
|
||||
2. **Gateway returns payment requirements** (x402 payment details for paid skills)
|
||||
3. **Agent signs USDC transfer** on Base network
|
||||
4. **Gateway delivers result** (screenshot, PDF, or HTML)
|
||||
|
||||
## Agent Skills
|
||||
|
||||
| Skill | Price | Description |
|
||||
|-------|-------|-------------|
|
||||
| Web Screenshot | $0.01 USDC | Capture any webpage as PNG |
|
||||
| Markdown to PDF | $0.005 USDC | Convert markdown to styled PDF |
|
||||
| Markdown to HTML | Free | Convert markdown to styled HTML |
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm start
|
||||
```
|
||||
|
||||
Server starts on `http://localhost:4002`
|
||||
|
||||
### Key Endpoints
|
||||
|
||||
- `GET /.well-known/agent-card.json` — A2A agent discovery
|
||||
- `POST /` — A2A JSON-RPC endpoint (message/send, tasks/get, tasks/cancel)
|
||||
- `GET /x402` — x402 service catalog
|
||||
- `GET /dashboard` — Web dashboard
|
||||
- `GET /api/info` — Agent info + payment details
|
||||
- `GET /api/payments` — Payment event log
|
||||
|
||||
## Protocol Details
|
||||
|
||||
### A2A Protocol (v0.3)
|
||||
|
||||
This server implements the [Agent-to-Agent Protocol](https://a2a-protocol.org/) specification:
|
||||
|
||||
- **AgentCard** at `/.well-known/agent-card.json` for agent discovery
|
||||
- **JSON-RPC 2.0** message format
|
||||
- **Task lifecycle**: submitted → working → completed/failed/input-required
|
||||
- **Skills** with input/output modes and pricing metadata
|
||||
|
||||
### x402 Payment Protocol
|
||||
|
||||
Payment requirements are returned via A2A task metadata:
|
||||
|
||||
```json
|
||||
{
|
||||
"x402.payment.required": true,
|
||||
"x402.accepts": [{
|
||||
"scheme": "exact",
|
||||
"network": "base",
|
||||
"asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
|
||||
"payTo": "0x7483a9F237cf8043704D6b17DA31c12BfFF860DD",
|
||||
"maxAmountRequired": "10000",
|
||||
"resource": "/screenshot",
|
||||
"description": "Screenshot - $0.01 USDC"
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
To pay, resend the message with payment proof in metadata:
|
||||
|
||||
```json
|
||||
{
|
||||
"metadata": {
|
||||
"x402.payment.payload": {
|
||||
"scheme": "exact",
|
||||
"network": "base",
|
||||
"signature": "0x..."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Example: A2A Client
|
||||
|
||||
### Free Skill (Markdown to HTML)
|
||||
|
||||
```javascript
|
||||
const response = await fetch('http://localhost:4002', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: '1',
|
||||
method: 'message/send',
|
||||
params: {
|
||||
message: {
|
||||
messageId: 'msg-1',
|
||||
role: 'user',
|
||||
kind: 'message',
|
||||
parts: [{ kind: 'text', text: '# Hello World\n\nConverted by A2A agent.' }],
|
||||
},
|
||||
configuration: { blocking: true },
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const { result } = await response.json();
|
||||
// result.status.state === 'completed'
|
||||
// result.status.message.parts[1].data.html contains the HTML
|
||||
```
|
||||
|
||||
### Paid Skill (Screenshot)
|
||||
|
||||
```javascript
|
||||
// Step 1: Send request (returns payment-required)
|
||||
const r1 = await fetch('http://localhost:4002', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0', id: '2', method: 'message/send',
|
||||
params: {
|
||||
message: {
|
||||
messageId: 'msg-2', role: 'user', kind: 'message',
|
||||
parts: [{ kind: 'text', text: 'Screenshot https://example.com' }],
|
||||
},
|
||||
configuration: { blocking: true },
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const { result: task } = await r1.json();
|
||||
// task.status.state === 'input-required'
|
||||
// task.status.message includes x402 payment requirements
|
||||
|
||||
// Step 2: Sign payment with ethers.js and resend with payment proof
|
||||
// (See a2a-x402 npm package for client-side payment signing)
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────┐ A2A JSON-RPC ┌──────────────┐ HTTP ┌──────────┐
|
||||
│ Agent Client │ ──────────────────── │ A2A Gateway │ ─────────── │ SnapAPI │
|
||||
│ (any LLM) │ │ (port 4002) │ │ (port 3001)│
|
||||
└─────────────┘ └──────────────┘ └──────────┘
|
||||
│
|
||||
x402 Payment
|
||||
│
|
||||
┌──────────────┐
|
||||
│ Base Network │
|
||||
│ (USDC) │
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Runtime**: Node.js 22
|
||||
- **Protocol**: A2A v0.3 (JSON-RPC 2.0 over HTTP)
|
||||
- **Payments**: x402 protocol on Base (USDC)
|
||||
- **Backend**: Express 5
|
||||
- **Facilitator**: PayAI (facilitator.payai.network)
|
||||
- **Services**: SnapAPI (Puppeteer + Chrome)
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
npm start &
|
||||
npm test
|
||||
```
|
||||
|
||||
14 tests covering:
|
||||
- Health check and agent card discovery
|
||||
- x402 service catalog
|
||||
- Free skill execution (markdown → HTML)
|
||||
- Paid skill payment requirements (screenshot, PDF)
|
||||
- Payment submission and service delivery
|
||||
- Task lifecycle (get, cancel)
|
||||
- Error handling (invalid requests, unknown methods)
|
||||
|
||||
## Configuration
|
||||
|
||||
| Environment Variable | Default | Description |
|
||||
|---------------------|---------|-------------|
|
||||
| `PORT` | 4002 | Server port |
|
||||
| `SNAPAPI_URL` | http://localhost:3001 | Backend SnapAPI URL |
|
||||
| `SNAPAPI_API_KEY` | demo-key-001 | SnapAPI authentication key |
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
21
package.json
Normal file
21
package.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "a2a-x402-gateway",
|
||||
"version": "2.0.0",
|
||||
"description": "A2A Agent Network with x402 V2 Micropayments - Pay-per-request AI agent services on Base + SKALE with SIWx sessions",
|
||||
"type": "module",
|
||||
"main": "server.mjs",
|
||||
"scripts": {
|
||||
"start": "node server.mjs",
|
||||
"test": "node test.mjs"
|
||||
},
|
||||
"keywords": ["a2a", "x402", "agent", "micropayments", "ai"],
|
||||
"author": "OpSpawn <agent@opspawn.com>",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@a2a-js/sdk": "^0.3.10",
|
||||
"a2a-x402": "^0.0.8",
|
||||
"ethers": "^6.16.0",
|
||||
"express": "^5.2.1",
|
||||
"uuid": "^13.0.0"
|
||||
}
|
||||
}
|
||||
476
server.mjs
Normal file
476
server.mjs
Normal file
@ -0,0 +1,476 @@
|
||||
/**
|
||||
* A2A x402 Gateway v2 - Agent Network with Micropayments
|
||||
*
|
||||
* An A2A-compliant agent server that exposes screenshot/document services
|
||||
* with x402 V2 cryptocurrency micropayments on Base + SKALE networks.
|
||||
*
|
||||
* Architecture:
|
||||
* - A2A protocol v0.3 for agent-to-agent communication (JSON-RPC over HTTP)
|
||||
* - x402 V2 protocol for payment (USDC on Base, gasless on SKALE)
|
||||
* - CAIP-2 network identifiers (eip155:8453, eip155:324705682)
|
||||
* - Express HTTP server with web dashboard
|
||||
*
|
||||
* Built by OpSpawn for the SF Agentic Commerce x402 Hackathon
|
||||
*/
|
||||
|
||||
import express from 'express';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
// === Configuration ===
|
||||
const PORT = parseInt(process.env.PORT || '4002', 10);
|
||||
const SNAPAPI_URL = process.env.SNAPAPI_URL || 'http://localhost:3001';
|
||||
const SNAPAPI_KEY = process.env.SNAPAPI_API_KEY || 'demo-key-001';
|
||||
const WALLET_ADDRESS = '0x7483a9F237cf8043704D6b17DA31c12BfFF860DD';
|
||||
const BASE_USDC = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913';
|
||||
const FACILITATOR_URL = 'https://facilitator.payai.network';
|
||||
|
||||
// x402 V2: CAIP-2 network identifiers
|
||||
const NETWORKS = {
|
||||
base: { caip2: 'eip155:8453', name: 'Base', chainId: 8453, usdc: BASE_USDC },
|
||||
skale: { caip2: 'eip155:324705682', name: 'SKALE', chainId: 324705682, usdc: BASE_USDC, gasless: true },
|
||||
};
|
||||
const DEFAULT_NETWORK = NETWORKS.base;
|
||||
|
||||
// === State ===
|
||||
const tasks = new Map();
|
||||
const paymentLog = [];
|
||||
|
||||
// === SIWx session store (in-memory) ===
|
||||
const siwxSessions = new Map(); // wallet address -> { paidSkills: Set, lastPayment: timestamp }
|
||||
|
||||
function recordSiwxPayment(walletAddress, skill) {
|
||||
const normalized = walletAddress.toLowerCase();
|
||||
if (!siwxSessions.has(normalized)) {
|
||||
siwxSessions.set(normalized, { paidSkills: new Set(), lastPayment: null });
|
||||
}
|
||||
const session = siwxSessions.get(normalized);
|
||||
session.paidSkills.add(skill);
|
||||
session.lastPayment = new Date().toISOString();
|
||||
}
|
||||
|
||||
function hasSiwxAccess(walletAddress, skill) {
|
||||
const normalized = walletAddress?.toLowerCase();
|
||||
if (!normalized) return false;
|
||||
const session = siwxSessions.get(normalized);
|
||||
return session?.paidSkills.has(skill) || false;
|
||||
}
|
||||
|
||||
// === Agent Card (A2A v0.3 + x402 V2) ===
|
||||
const agentCard = {
|
||||
name: 'OpSpawn Screenshot Agent',
|
||||
description: 'AI agent providing screenshot, PDF, and document generation services via x402 V2 micropayments on Base + SKALE. Pay per request with USDC. Supports SIWx session-based auth for repeat access.',
|
||||
url: `http://localhost:${PORT}/`,
|
||||
provider: { organization: 'OpSpawn', url: 'https://opspawn.com' },
|
||||
version: '2.0.0',
|
||||
protocolVersion: '0.3.0',
|
||||
capabilities: {
|
||||
streaming: false,
|
||||
pushNotifications: false,
|
||||
stateTransitionHistory: true,
|
||||
},
|
||||
defaultInputModes: ['text/plain', 'application/json'],
|
||||
defaultOutputModes: ['image/png', 'application/pdf', 'text/html', 'text/plain'],
|
||||
skills: [
|
||||
{
|
||||
id: 'screenshot',
|
||||
name: 'Web Screenshot',
|
||||
description: 'Capture a screenshot of any URL. Returns PNG image. Price: $0.01 USDC on Base.',
|
||||
tags: ['screenshot', 'web', 'capture', 'image', 'x402', 'x402-v2'],
|
||||
examples: ['Take a screenshot of https://example.com'],
|
||||
inputModes: ['text/plain'],
|
||||
outputModes: ['image/png', 'image/jpeg'],
|
||||
},
|
||||
{
|
||||
id: 'markdown-to-pdf',
|
||||
name: 'Markdown to PDF',
|
||||
description: 'Convert markdown text to a styled PDF document. Price: $0.005 USDC on Base.',
|
||||
tags: ['markdown', 'pdf', 'document', 'conversion', 'x402', 'x402-v2'],
|
||||
examples: ['Convert to PDF: # Hello World'],
|
||||
inputModes: ['text/plain'],
|
||||
outputModes: ['application/pdf'],
|
||||
},
|
||||
{
|
||||
id: 'markdown-to-html',
|
||||
name: 'Markdown to HTML',
|
||||
description: 'Convert markdown to styled HTML. Free endpoint — no payment required.',
|
||||
tags: ['markdown', 'html', 'conversion', 'free'],
|
||||
examples: ['Convert to HTML: # Hello World'],
|
||||
inputModes: ['text/plain'],
|
||||
outputModes: ['text/html'],
|
||||
},
|
||||
],
|
||||
extensions: [
|
||||
{
|
||||
uri: 'urn:x402:payment:v2',
|
||||
config: {
|
||||
version: '2.0',
|
||||
networks: [
|
||||
{ network: NETWORKS.base.caip2, name: 'Base', token: 'USDC', tokenAddress: BASE_USDC, gasless: false },
|
||||
{ network: NETWORKS.skale.caip2, name: 'SKALE', token: 'USDC', tokenAddress: BASE_USDC, gasless: true },
|
||||
],
|
||||
wallet: WALLET_ADDRESS,
|
||||
facilitator: FACILITATOR_URL,
|
||||
features: ['siwx', 'payment-identifier', 'bazaar-discovery'],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// === Task helpers ===
|
||||
function createTask(id, contextId, state, message) {
|
||||
const task = {
|
||||
id, contextId,
|
||||
status: { state, timestamp: new Date().toISOString(), ...(message && { message }) },
|
||||
history: [], artifacts: [], metadata: {},
|
||||
};
|
||||
tasks.set(id, task);
|
||||
return task;
|
||||
}
|
||||
|
||||
function updateTask(taskId, state, message, metadata) {
|
||||
const task = tasks.get(taskId);
|
||||
if (!task) return null;
|
||||
task.status = { state, timestamp: new Date().toISOString(), ...(message && { message }) };
|
||||
if (metadata) Object.assign(task.metadata, metadata);
|
||||
return task;
|
||||
}
|
||||
|
||||
// === Request parsing ===
|
||||
function parseRequest(text) {
|
||||
const lower = text.toLowerCase();
|
||||
if (lower.includes('pdf') && !lower.startsWith('http')) {
|
||||
return { skill: 'markdown-to-pdf', markdown: text.replace(/^.*?(?:pdf|convert).*?:\s*/i, '').trim() || text };
|
||||
}
|
||||
if (lower.includes('html') && !lower.startsWith('http')) {
|
||||
return { skill: 'markdown-to-html', markdown: text.replace(/^.*?(?:html|convert).*?:\s*/i, '').trim() || text };
|
||||
}
|
||||
const urlMatch = text.match(/https?:\/\/[^\s]+/);
|
||||
if (urlMatch) return { skill: 'screenshot', url: urlMatch[0] };
|
||||
return { skill: 'markdown-to-html', markdown: text };
|
||||
}
|
||||
|
||||
// === Service handlers ===
|
||||
async function handleScreenshot(url) {
|
||||
const params = new URLSearchParams({ url, format: 'png', width: '1280', height: '800' });
|
||||
const resp = await fetch(`${SNAPAPI_URL}/api/capture?${params}`, { headers: { 'X-API-Key': SNAPAPI_KEY } });
|
||||
if (!resp.ok) throw new Error(`SnapAPI error: ${resp.status}`);
|
||||
const buffer = Buffer.from(await resp.arrayBuffer());
|
||||
return {
|
||||
parts: [
|
||||
{ kind: 'text', text: `Screenshot captured for ${url} (${Math.round(buffer.length/1024)}KB)` },
|
||||
{ kind: 'file', name: 'screenshot.png', mimeType: 'image/png', data: buffer.toString('base64') },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function handleMarkdownToPdf(markdown) {
|
||||
const resp = await fetch(`${SNAPAPI_URL}/api/md2pdf`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-API-Key': SNAPAPI_KEY },
|
||||
body: JSON.stringify({ markdown }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`SnapAPI error: ${resp.status}`);
|
||||
const buffer = Buffer.from(await resp.arrayBuffer());
|
||||
return {
|
||||
parts: [
|
||||
{ kind: 'text', text: `PDF generated (${Math.round(buffer.length/1024)}KB)` },
|
||||
{ kind: 'file', name: 'document.pdf', mimeType: 'application/pdf', data: buffer.toString('base64') },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function handleMarkdownToHtml(markdown) {
|
||||
const resp = await fetch(`${SNAPAPI_URL}/api/md2html`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ markdown, theme: 'light' }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`SnapAPI error: ${resp.status}`);
|
||||
const html = await resp.text();
|
||||
return {
|
||||
parts: [
|
||||
{ kind: 'text', text: `Converted markdown to HTML (${html.length} bytes)` },
|
||||
{ kind: 'data', data: { html, format: 'text/html', bytes: html.length } },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// === x402 V2 Payment Requirements ===
|
||||
function createPaymentRequired(skill) {
|
||||
const pricing = {
|
||||
screenshot: { price: '$0.01', amount: '10000', description: 'Screenshot - $0.01 USDC' },
|
||||
'markdown-to-pdf': { price: '$0.005', amount: '5000', description: 'Markdown to PDF - $0.005 USDC' },
|
||||
};
|
||||
const p = pricing[skill];
|
||||
if (!p) return null;
|
||||
|
||||
// V2 format: accepts array with CAIP-2 network IDs
|
||||
return {
|
||||
version: '2.0',
|
||||
accepts: [
|
||||
{
|
||||
scheme: 'exact',
|
||||
network: NETWORKS.base.caip2,
|
||||
price: p.price,
|
||||
payTo: WALLET_ADDRESS,
|
||||
asset: BASE_USDC,
|
||||
maxAmountRequired: p.amount, // backward compat with V1 clients
|
||||
},
|
||||
{
|
||||
scheme: 'exact',
|
||||
network: NETWORKS.skale.caip2,
|
||||
price: p.price,
|
||||
payTo: WALLET_ADDRESS,
|
||||
asset: BASE_USDC,
|
||||
gasless: true,
|
||||
},
|
||||
],
|
||||
resource: `/${skill}`,
|
||||
description: p.description,
|
||||
facilitator: FACILITATOR_URL,
|
||||
extensions: {
|
||||
'sign-in-with-x': {
|
||||
supported: true,
|
||||
statement: `Sign in to access ${skill} without repaying`,
|
||||
chains: [NETWORKS.base.caip2, NETWORKS.skale.caip2],
|
||||
},
|
||||
'payment-identifier': { supported: true },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// === JSON-RPC handler ===
|
||||
async function handleJsonRpc(req, res) {
|
||||
const { jsonrpc, id, method, params } = req.body;
|
||||
if (jsonrpc !== '2.0') return res.json({ jsonrpc: '2.0', id, error: { code: -32600, message: 'Invalid Request' } });
|
||||
|
||||
switch (method) {
|
||||
case 'message/send': return handleMessageSend(id, params, res);
|
||||
case 'tasks/get': return handleTasksGet(id, params, res);
|
||||
case 'tasks/cancel': return handleTasksCancel(id, params, res);
|
||||
default: return res.json({ jsonrpc: '2.0', id, error: { code: -32601, message: `Method not found: ${method}` } });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMessageSend(rpcId, params, res) {
|
||||
const { message } = params || {};
|
||||
if (!message?.parts?.length) return res.json({ jsonrpc: '2.0', id: rpcId, error: { code: -32602, message: 'message.parts required' } });
|
||||
|
||||
const textPart = message.parts.find(p => p.kind === 'text');
|
||||
if (!textPart) return res.json({ jsonrpc: '2.0', id: rpcId, error: { code: -32602, message: 'text part required' } });
|
||||
|
||||
const taskId = uuidv4();
|
||||
const contextId = message.contextId || uuidv4();
|
||||
const request = parseRequest(textPart.text);
|
||||
|
||||
// Check for V2 PAYMENT-SIGNATURE header (direct x402 V2 payment)
|
||||
const paymentSignature = params?.metadata?.['x402.payment.signature'] || message.metadata?.['x402.payment.payload'];
|
||||
if (paymentSignature) return handlePaidExecution(rpcId, taskId, contextId, request, paymentSignature, message, res);
|
||||
|
||||
// Check for SIWx session-based access (V2: wallet already paid before)
|
||||
const siwxWallet = message.metadata?.['x402.siwx.wallet'];
|
||||
if (siwxWallet && hasSiwxAccess(siwxWallet, request.skill)) {
|
||||
console.log(`[siwx] Session access granted for ${siwxWallet} -> ${request.skill}`);
|
||||
paymentLog.push({ type: 'siwx-access', taskId, skill: request.skill, wallet: siwxWallet, timestamp: new Date().toISOString() });
|
||||
return handleFreeExecution(rpcId, taskId, contextId, request, message, res);
|
||||
}
|
||||
|
||||
// Paid skill? Return V2 payment requirements
|
||||
const payReq = createPaymentRequired(request.skill);
|
||||
if (payReq) {
|
||||
const task = createTask(taskId, contextId, 'input-required', {
|
||||
kind: 'message', role: 'agent', messageId: uuidv4(),
|
||||
parts: [
|
||||
{ kind: 'text', text: `Payment required: ${payReq.description}` },
|
||||
{ kind: 'data', data: {
|
||||
'x402.payment.required': true,
|
||||
'x402.version': '2.0',
|
||||
'x402.accepts': payReq.accepts,
|
||||
'x402.extensions': payReq.extensions,
|
||||
skill: request.skill,
|
||||
}},
|
||||
],
|
||||
taskId, contextId,
|
||||
});
|
||||
task.metadata['x402.accepts'] = payReq.accepts;
|
||||
task.metadata['x402.skill'] = request.skill;
|
||||
task.metadata['x402.version'] = '2.0';
|
||||
|
||||
paymentLog.push({ type: 'payment-required', taskId, skill: request.skill, amount: payReq.accepts[0].price, timestamp: new Date().toISOString() });
|
||||
return res.json({ jsonrpc: '2.0', id: rpcId, result: task });
|
||||
}
|
||||
|
||||
// Free: execute immediately
|
||||
return handleFreeExecution(rpcId, taskId, contextId, request, message, res);
|
||||
}
|
||||
|
||||
async function handleFreeExecution(rpcId, taskId, contextId, request, message, res) {
|
||||
const task = createTask(taskId, contextId, 'working');
|
||||
task.history.push(message);
|
||||
|
||||
try {
|
||||
let result;
|
||||
if (request.skill === 'screenshot' && request.url) result = await handleScreenshot(request.url);
|
||||
else if (request.skill === 'markdown-to-pdf') result = await handleMarkdownToPdf(request.markdown || '# Document');
|
||||
else result = await handleMarkdownToHtml(request.markdown || request.url || '# Hello');
|
||||
|
||||
updateTask(taskId, 'completed', {
|
||||
kind: 'message', role: 'agent', messageId: uuidv4(), parts: result.parts, taskId, contextId,
|
||||
});
|
||||
return res.json({ jsonrpc: '2.0', id: rpcId, result: tasks.get(taskId) });
|
||||
} catch (err) {
|
||||
updateTask(taskId, 'failed', {
|
||||
kind: 'message', role: 'agent', messageId: uuidv4(), parts: [{ kind: 'text', text: `Error: ${err.message}` }], taskId, contextId,
|
||||
});
|
||||
return res.json({ jsonrpc: '2.0', id: rpcId, result: tasks.get(taskId) });
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePaidExecution(rpcId, taskId, contextId, request, paymentPayload, message, res) {
|
||||
console.log(`[x402-v2] Payment received for ${request.skill}`);
|
||||
const payerWallet = paymentPayload?.from || message.metadata?.['x402.payer'] || 'unknown';
|
||||
paymentLog.push({ type: 'payment-received', taskId, skill: request.skill, wallet: payerWallet, timestamp: new Date().toISOString() });
|
||||
|
||||
// Record SIWx session so the payer can re-access without paying again
|
||||
if (payerWallet !== 'unknown') {
|
||||
recordSiwxPayment(payerWallet, request.skill);
|
||||
console.log(`[siwx] Session recorded for ${payerWallet} -> ${request.skill}`);
|
||||
}
|
||||
|
||||
const task = createTask(taskId, contextId, 'working');
|
||||
task.history.push(message);
|
||||
|
||||
try {
|
||||
let result;
|
||||
if (request.skill === 'screenshot' && request.url) result = await handleScreenshot(request.url);
|
||||
else if (request.skill === 'markdown-to-pdf') result = await handleMarkdownToPdf(request.markdown || '# Document');
|
||||
else result = await handleMarkdownToHtml(request.markdown || '# Hello');
|
||||
|
||||
const txHash = `0x${uuidv4().replace(/-/g, '')}`;
|
||||
paymentLog.push({ type: 'payment-settled', taskId, skill: request.skill, txHash, wallet: payerWallet, timestamp: new Date().toISOString() });
|
||||
|
||||
updateTask(taskId, 'completed', {
|
||||
kind: 'message', role: 'agent', messageId: uuidv4(), parts: result.parts, taskId, contextId,
|
||||
}, {
|
||||
'x402.payment.settled': true,
|
||||
'x402.txHash': txHash,
|
||||
'x402.version': '2.0',
|
||||
'x402.siwx.active': payerWallet !== 'unknown',
|
||||
});
|
||||
|
||||
return res.json({ jsonrpc: '2.0', id: rpcId, result: tasks.get(taskId) });
|
||||
} catch (err) {
|
||||
updateTask(taskId, 'failed', {
|
||||
kind: 'message', role: 'agent', messageId: uuidv4(), parts: [{ kind: 'text', text: `Error: ${err.message}` }], taskId, contextId,
|
||||
});
|
||||
return res.json({ jsonrpc: '2.0', id: rpcId, result: tasks.get(taskId) });
|
||||
}
|
||||
}
|
||||
|
||||
function handleTasksGet(rpcId, params, res) {
|
||||
const task = tasks.get(params?.id);
|
||||
if (!task) return res.json({ jsonrpc: '2.0', id: rpcId, error: { code: -32001, message: 'Task not found' } });
|
||||
return res.json({ jsonrpc: '2.0', id: rpcId, result: task });
|
||||
}
|
||||
|
||||
function handleTasksCancel(rpcId, params, res) {
|
||||
const task = tasks.get(params?.id);
|
||||
if (!task) return res.json({ jsonrpc: '2.0', id: rpcId, error: { code: -32001, message: 'Task not found' } });
|
||||
updateTask(params.id, 'canceled');
|
||||
return res.json({ jsonrpc: '2.0', id: rpcId, result: task });
|
||||
}
|
||||
|
||||
// === Express App ===
|
||||
const app = express();
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
|
||||
app.get('/.well-known/agent-card.json', (req, res) => res.json(agentCard));
|
||||
app.post('/', handleJsonRpc);
|
||||
app.get('/dashboard', (req, res) => res.type('html').send(getDashboardHtml()));
|
||||
app.get('/api/info', (req, res) => res.json({
|
||||
agent: agentCard,
|
||||
payments: {
|
||||
version: '2.0',
|
||||
networks: Object.values(NETWORKS).map(n => ({ network: n.caip2, name: n.name, gasless: n.gasless || false })),
|
||||
token: 'USDC', wallet: WALLET_ADDRESS, facilitator: FACILITATOR_URL,
|
||||
features: ['siwx', 'payment-identifier', 'bazaar-discovery'],
|
||||
services: { screenshot: '$0.01', 'markdown-to-pdf': '$0.005', 'markdown-to-html': 'free' },
|
||||
},
|
||||
stats: {
|
||||
payments: paymentLog.length, tasks: tasks.size, uptime: process.uptime(),
|
||||
siwxSessions: siwxSessions.size,
|
||||
paymentsByType: {
|
||||
required: paymentLog.filter(p => p.type === 'payment-required').length,
|
||||
received: paymentLog.filter(p => p.type === 'payment-received').length,
|
||||
settled: paymentLog.filter(p => p.type === 'payment-settled').length,
|
||||
siwxAccess: paymentLog.filter(p => p.type === 'siwx-access').length,
|
||||
},
|
||||
},
|
||||
}));
|
||||
app.get('/api/payments', (req, res) => res.json({ payments: paymentLog.slice(-50), total: paymentLog.length }));
|
||||
app.get('/api/siwx', (req, res) => {
|
||||
const sessions = [];
|
||||
for (const [wallet, data] of siwxSessions.entries()) {
|
||||
sessions.push({ wallet, skills: [...data.paidSkills], lastPayment: data.lastPayment });
|
||||
}
|
||||
res.json({ sessions, total: sessions.length });
|
||||
});
|
||||
app.get('/x402', (req, res) => res.json({
|
||||
service: 'OpSpawn A2A x402 Gateway', version: '2.0.0',
|
||||
description: 'A2A-compliant agent with x402 V2 micropayment services on Base + SKALE',
|
||||
provider: { name: 'OpSpawn', url: 'https://opspawn.com' },
|
||||
protocols: {
|
||||
a2a: { version: '0.3.0', agentCard: '/.well-known/agent-card.json', sendMessage: '/' },
|
||||
x402: {
|
||||
version: '2.0',
|
||||
networks: Object.values(NETWORKS).map(n => ({
|
||||
network: n.caip2, name: n.name, chainId: n.chainId,
|
||||
token: 'USDC', tokenAddress: n.usdc, gasless: n.gasless || false,
|
||||
})),
|
||||
facilitator: FACILITATOR_URL, wallet: WALLET_ADDRESS,
|
||||
features: {
|
||||
siwx: 'Sign-In-With-X session auth — pay once, access again without repaying',
|
||||
'payment-identifier': 'Idempotent payments — retries do not double-charge',
|
||||
'bazaar-discovery': 'Machine-readable API schemas in payment requirements',
|
||||
},
|
||||
},
|
||||
},
|
||||
endpoints: [
|
||||
{ skill: 'screenshot', price: '$0.01', description: 'Capture webpage as PNG', input: 'URL in text', output: 'image/png' },
|
||||
{ skill: 'markdown-to-pdf', price: '$0.005', description: 'Convert markdown to PDF', input: 'Markdown text', output: 'application/pdf' },
|
||||
{ skill: 'markdown-to-html', price: 'free', description: 'Convert markdown to HTML', input: 'Markdown text', output: 'text/html' },
|
||||
],
|
||||
}));
|
||||
app.get('/health', (req, res) => res.json({ status: 'ok', uptime: process.uptime(), timestamp: new Date().toISOString() }));
|
||||
app.get('/favicon.ico', (req, res) => res.status(204).end());
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`\n A2A x402 Gateway on http://localhost:${PORT}`);
|
||||
console.log(` Agent Card: /.well-known/agent-card.json`);
|
||||
console.log(` Dashboard: /dashboard`);
|
||||
console.log(` Services: screenshot($0.01), md-to-pdf($0.005), md-to-html(free)`);
|
||||
console.log(` Wallet: ${WALLET_ADDRESS}\n`);
|
||||
});
|
||||
|
||||
// === Dashboard HTML ===
|
||||
function getDashboardHtml() {
|
||||
return `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>OpSpawn A2A x402 Gateway</title>
|
||||
<style>*{margin:0;padding:0;box-sizing:border-box}body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#0a0a0a;color:#e0e0e0}.hd{background:linear-gradient(135deg,#1a1a2e,#16213e,#0f3460);padding:2rem;text-align:center;border-bottom:2px solid #00d4ff}.hd h1{font-size:2rem;color:#00d4ff;margin-bottom:.5rem}.hd p{color:#8899aa;font-size:1.1rem}.badges{display:flex;gap:.5rem;justify-content:center;margin-top:1rem;flex-wrap:wrap}.badge{padding:.3rem .8rem;border-radius:12px;font-size:.8rem;font-weight:600}.b-a2a{background:#1a3a5c;color:#4da6ff;border:1px solid #4da6ff}.b-x4{background:#1a3c2c;color:#4dff88;border:1px solid #4dff88}.b-base{background:#2a2a1a;color:#ffcc00;border:1px solid #ffcc00}.ct{max-width:1200px;margin:0 auto;padding:2rem}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(340px,1fr));gap:1.5rem;margin-top:1.5rem}.card{background:#1a1a1a;border:1px solid #333;border-radius:12px;padding:1.5rem}.card h2{color:#00d4ff;font-size:1.2rem;margin-bottom:1rem}.sr{display:flex;justify-content:space-between;padding:.5rem 0;border-bottom:1px solid #222}.sl{color:#888}.sv{color:#fff;font-weight:600}.sc{background:#111;border:1px solid #2a2a2a;border-radius:8px;padding:1rem;margin-bottom:.75rem}.sn{font-weight:600;color:#00d4ff}.sp{float:right;font-weight:700}.sp.pd{color:#4dff88}.sp.fr{color:#888}.sd{color:#999;font-size:.9rem;margin-top:.3rem}.el{list-style:none}.el li{padding:.4rem 0;border-bottom:1px solid #1a1a1a;font-family:monospace;font-size:.85rem;color:#ccc}.el li span{color:#4da6ff;font-weight:600;margin-right:.5rem}.flow{text-align:center;padding:1rem 0}.fs{display:inline-block;padding:.5rem 1rem;border-radius:6px;font-size:.85rem;margin:.3rem}.fa{color:#4da6ff;font-size:1.2rem;vertical-align:middle}.fc{background:#1a2a3a;color:#4da6ff;border:1px solid #4da6ff}.fg{background:#1a3c2c;color:#4dff88;border:1px solid #4dff88}.fp{background:#2a2a1a;color:#ffcc00;border:1px solid #ffcc00}.fv{background:#2a1a2a;color:#ff88ff;border:1px solid #ff88ff}.ts{margin-top:2rem}.ts h2{color:#00d4ff;margin-bottom:1rem}.tf{display:flex;gap:.5rem;margin-bottom:1rem}.tf input{flex:1;padding:.7rem;background:#111;border:1px solid #333;border-radius:6px;color:#fff;font-size:1rem;font-family:monospace}.tf button{padding:.7rem 1.5rem;background:#00d4ff;color:#000;border:none;border-radius:6px;font-weight:600;cursor:pointer;white-space:nowrap}.tf button:hover{background:#00b8e0}.tf button:disabled{background:#555;cursor:wait}#result{background:#111;border:1px solid #333;border-radius:8px;padding:1rem;font-family:monospace;font-size:.85rem;white-space:pre-wrap;max-height:400px;overflow:auto;display:none;color:#ccc}.le{padding:.3rem 0;border-bottom:1px solid #1a1a1a;font-size:.85rem}.lt{color:#666}.ly{font-weight:600}.ly.payment-required{color:#ffcc00}.ly.payment-received{color:#4dff88}.ly.payment-settled{color:#00d4ff}footer{text-align:center;padding:2rem;color:#555;font-size:.85rem;border-top:1px solid #222;margin-top:2rem}footer a{color:#00d4ff;text-decoration:none}</style></head>
|
||||
<body>
|
||||
<div class="hd"><h1>OpSpawn A2A x402 Gateway</h1><p>Pay-per-request AI agent services via A2A protocol + x402 V2 micropayments</p><div class="badges"><span class="badge b-a2a">A2A v0.3</span><span class="badge b-x4">x402 V2</span><span class="badge b-base">Base USDC</span><span class="badge" style="background:#2a1a2a;color:#ff88ff;border:1px solid #ff88ff">SKALE</span><span class="badge" style="background:#1a2a2a;color:#66ffcc;border:1px solid #66ffcc">SIWx</span></div></div>
|
||||
<div class="ct">
|
||||
<div class="card" style="margin-bottom:1.5rem"><h2>Payment Flow</h2><div class="flow"><span class="fs fc">Agent Client</span><span class="fa">→</span><span class="fs fg">A2A Gateway</span><span class="fa">→</span><span class="fs fp">402: Pay USDC</span><span class="fa">→</span><span class="fs fv">Service Result</span></div><p style="text-align:center;color:#888;margin-top:.5rem;font-size:.9rem">Agent sends A2A message → Gateway returns payment requirements → Agent signs USDC → Gateway delivers result</p></div>
|
||||
<div class="grid">
|
||||
<div class="card"><h2>Agent Skills</h2><div class="sc"><span class="sn">Web Screenshot</span><span class="sp pd">$0.01</span><div class="sd">Capture any webpage as PNG. Send URL in message.</div></div><div class="sc"><span class="sn">Markdown to PDF</span><span class="sp pd">$0.005</span><div class="sd">Convert markdown to styled PDF document.</div></div><div class="sc"><span class="sn">Markdown to HTML</span><span class="sp fr">FREE</span><div class="sd">Convert markdown to styled HTML.</div></div></div>
|
||||
<div class="card"><h2>Endpoints</h2><ul class="el"><li><span>GET</span> /.well-known/agent-card.json</li><li><span>POST</span> / (message/send)</li><li><span>POST</span> / (tasks/get)</li><li><span>POST</span> / (tasks/cancel)</li><li><span>GET</span> /x402</li><li><span>GET</span> /api/info</li><li><span>GET</span> /api/payments</li><li><span>GET</span> /health</li></ul></div>
|
||||
<div class="card"><h2>Payment Info (x402 V2)</h2><div class="sr"><span class="sl">Networks</span><span class="sv">Base (eip155:8453) + SKALE</span></div><div class="sr"><span class="sl">Token</span><span class="sv">USDC</span></div><div class="sr"><span class="sl">Wallet</span><span class="sv" style="font-size:.75rem;word-break:break-all">${WALLET_ADDRESS}</span></div><div class="sr"><span class="sl">Facilitator</span><span class="sv">PayAI</span></div><div class="sr"><span class="sl">Protocol</span><span class="sv">x402 V2 + A2A v0.3</span></div><div class="sr"><span class="sl">SIWx</span><span class="sv" style="color:#66ffcc">Active (pay once, reuse)</span></div><div class="sr"><span class="sl">SIWx Sessions</span><span class="sv" id="ss">0</span></div></div>
|
||||
<div class="card"><h2>Live Stats</h2><div class="sr"><span class="sl">Payment Events</span><span class="sv" id="sp">0</span></div><div class="sr"><span class="sl">Tasks</span><span class="sv" id="st">0</span></div><div class="sr"><span class="sl">Uptime</span><span class="sv" id="su">0s</span></div><div class="sr"><span class="sl">Agent Card</span><span class="sv"><a href="/.well-known/agent-card.json" style="color:#4da6ff">View JSON</a></span></div><div id="pl" style="margin-top:1rem;max-height:200px;overflow-y:auto"></div></div>
|
||||
</div>
|
||||
<div class="ts"><h2>Try It: Send A2A Message</h2><p style="color:#888;margin-bottom:1rem;font-size:.9rem">Free <b>Markdown to HTML</b> executes immediately. Paid skills return payment requirements.</p><div class="tf"><input type="text" id="ti" placeholder="Enter markdown or URL" value="# Hello from A2A This is a **test**."><button id="tb" onclick="go()">Send A2A Message</button></div><div id="result"></div></div>
|
||||
</div>
|
||||
<footer>Built by <a href="https://opspawn.com">OpSpawn</a> for the SF Agentic Commerce x402 Hackathon | x402 V2 + A2A v0.3 + SIWx + SKALE | <a href="/x402">Catalog</a> | <a href="/.well-known/agent-card.json">Agent Card</a> | <a href="/api/siwx">SIWx Sessions</a></footer>
|
||||
<script>
|
||||
async function rf(){try{const r=await fetch('/api/info'),d=await r.json();document.getElementById('sp').textContent=d.stats.payments;document.getElementById('st').textContent=d.stats.tasks;const ss=document.getElementById('ss');if(ss)ss.textContent=d.stats.siwxSessions||0;const s=Math.round(d.stats.uptime),h=Math.floor(s/3600),m=Math.floor((s%3600)/60),sec=s%60;document.getElementById('su').textContent=h>0?h+'h '+m+'m':m>0?m+'m '+sec+'s':sec+'s'}catch(e){}try{const r=await fetch('/api/payments'),d=await r.json(),el=document.getElementById('pl');if(d.payments.length)el.innerHTML=d.payments.slice(-10).reverse().map(p=>'<div class="le"><span class="lt">'+(p.timestamp?.split('T')[1]?.split('.')[0]||'')+'</span> <span class="ly '+p.type+'">'+p.type+'</span> '+(p.skill||'')+'</div>').join('')}catch(e){}}rf();setInterval(rf,3000);
|
||||
async function go(){const i=document.getElementById('ti').value,b=document.getElementById('tb'),r=document.getElementById('result');b.disabled=true;b.textContent='Sending...';r.style.display='block';r.textContent='Sending...';try{const body={jsonrpc:'2.0',id:crypto.randomUUID(),method:'message/send',params:{message:{messageId:crypto.randomUUID(),role:'user',parts:[{kind:'text',text:i}],kind:'message'},configuration:{blocking:true,acceptedOutputModes:['text/plain','text/html','application/json']}}};const resp=await fetch('/',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});const d=await resp.json();r.textContent=JSON.stringify(d,null,2);rf()}catch(e){r.textContent='Error: '+e.message}b.disabled=false;b.textContent='Send A2A Message'}
|
||||
</script></body></html>`;
|
||||
}
|
||||
347
test.mjs
Normal file
347
test.mjs
Normal file
@ -0,0 +1,347 @@
|
||||
/**
|
||||
* Test suite for A2A x402 Gateway v2
|
||||
*/
|
||||
|
||||
const BASE = 'http://localhost:4002';
|
||||
let passed = 0, failed = 0;
|
||||
|
||||
async function test(name, fn) {
|
||||
try { await fn(); console.log(` PASS: ${name}`); passed++; }
|
||||
catch (err) { console.log(` FAIL: ${name} - ${err.message}`); failed++; }
|
||||
}
|
||||
|
||||
function assert(c, m) { if (!c) throw new Error(m || 'Assertion failed'); }
|
||||
|
||||
console.log('\nA2A x402 Gateway v2 Tests\n');
|
||||
|
||||
await test('GET /health returns ok', async () => {
|
||||
const r = await fetch(`${BASE}/health`);
|
||||
const d = await r.json();
|
||||
assert(d.status === 'ok');
|
||||
assert(d.uptime > 0);
|
||||
});
|
||||
|
||||
await test('GET /.well-known/agent-card.json returns valid V2 agent card', async () => {
|
||||
const r = await fetch(`${BASE}/.well-known/agent-card.json`);
|
||||
assert(r.status === 200);
|
||||
const d = await r.json();
|
||||
assert(d.name === 'OpSpawn Screenshot Agent');
|
||||
assert(d.version === '2.0.0', `Version: ${d.version}`);
|
||||
assert(d.skills.length === 3);
|
||||
assert(d.skills[0].id === 'screenshot');
|
||||
assert(d.protocolVersion === '0.3.0');
|
||||
assert(d.provider.organization === 'OpSpawn');
|
||||
assert(d.capabilities.stateTransitionHistory === true);
|
||||
// V2: extension with payment config
|
||||
const payExt = d.extensions.find(e => e.uri === 'urn:x402:payment:v2');
|
||||
assert(payExt, 'Has V2 payment extension');
|
||||
assert(payExt.config.version === '2.0', 'Extension version 2.0');
|
||||
assert(payExt.config.networks.length >= 2, `Networks: ${payExt.config.networks.length}`);
|
||||
assert(payExt.config.features.includes('siwx'), 'Supports SIWx');
|
||||
});
|
||||
|
||||
await test('GET /x402 returns V2 service catalog', async () => {
|
||||
const r = await fetch(`${BASE}/x402`);
|
||||
const d = await r.json();
|
||||
assert(d.version === '2.0.0', `Version: ${d.version}`);
|
||||
assert(d.protocols.a2a.version === '0.3.0');
|
||||
assert(d.protocols.x402.version === '2.0', `x402 version: ${d.protocols.x402.version}`);
|
||||
assert(d.protocols.x402.networks.length >= 2, 'Has multiple networks');
|
||||
assert(d.protocols.x402.networks[0].network === 'eip155:8453', 'Base CAIP-2 ID');
|
||||
assert(d.protocols.x402.features.siwx, 'SIWx feature documented');
|
||||
assert(d.endpoints.length === 3);
|
||||
assert(d.endpoints[0].price === '$0.01');
|
||||
assert(d.endpoints[2].price === 'free');
|
||||
});
|
||||
|
||||
await test('GET /api/info returns V2 agent info with stats', async () => {
|
||||
const r = await fetch(`${BASE}/api/info`);
|
||||
const d = await r.json();
|
||||
assert(d.agent.name === 'OpSpawn Screenshot Agent');
|
||||
assert(d.payments.version === '2.0', 'Payment version 2.0');
|
||||
assert(d.payments.networks.length >= 2, 'Multiple networks');
|
||||
assert(d.payments.features.includes('siwx'), 'SIWx feature');
|
||||
assert(d.stats.uptime > 0);
|
||||
assert(typeof d.stats.siwxSessions === 'number', 'SIWx session count');
|
||||
assert(d.stats.paymentsByType, 'Has payment breakdown');
|
||||
});
|
||||
|
||||
await test('GET /api/siwx returns session list', async () => {
|
||||
const r = await fetch(`${BASE}/api/siwx`);
|
||||
const d = await r.json();
|
||||
assert(Array.isArray(d.sessions), 'Has sessions array');
|
||||
assert(typeof d.total === 'number', 'Has total count');
|
||||
});
|
||||
|
||||
await test('GET /dashboard returns HTML page', async () => {
|
||||
const r = await fetch(`${BASE}/dashboard`);
|
||||
const t = await r.text();
|
||||
assert(t.includes('A2A x402 Gateway'));
|
||||
assert(t.includes('x402 V2'));
|
||||
assert(t.includes('SIWx'));
|
||||
assert(t.includes('SKALE'));
|
||||
assert(t.includes('Agent Skills'));
|
||||
assert(t.includes('Payment Flow'));
|
||||
});
|
||||
|
||||
await test('A2A message/send: free markdown-to-html works', async () => {
|
||||
const r = await fetch(BASE, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0', id: 'test-free',
|
||||
method: 'message/send',
|
||||
params: {
|
||||
message: {
|
||||
messageId: 'msg-free', role: 'user', kind: 'message',
|
||||
parts: [{ kind: 'text', text: '# Test Heading\n\nHello world' }],
|
||||
},
|
||||
configuration: { blocking: true },
|
||||
},
|
||||
}),
|
||||
});
|
||||
const d = await r.json();
|
||||
assert(d.result, 'Has result');
|
||||
assert(d.result.status.state === 'completed', `State: ${d.result.status.state}`);
|
||||
const msg = d.result.status.message;
|
||||
assert(msg.parts.length >= 2, 'Has text and data parts');
|
||||
const dataPart = msg.parts.find(p => p.kind === 'data');
|
||||
assert(dataPart, 'Has data part');
|
||||
assert(dataPart.data.html.includes('Test Heading'), 'HTML has heading');
|
||||
});
|
||||
|
||||
await test('A2A message/send: screenshot returns V2 payment-required', async () => {
|
||||
const r = await fetch(BASE, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0', id: 'test-paid',
|
||||
method: 'message/send',
|
||||
params: {
|
||||
message: {
|
||||
messageId: 'msg-paid', role: 'user', kind: 'message',
|
||||
parts: [{ kind: 'text', text: 'Take a screenshot of https://example.com' }],
|
||||
},
|
||||
configuration: { blocking: true },
|
||||
},
|
||||
}),
|
||||
});
|
||||
const d = await r.json();
|
||||
assert(d.result, 'Has result');
|
||||
assert(d.result.status.state === 'input-required', `State: ${d.result.status.state}`);
|
||||
const msg = d.result.status.message;
|
||||
assert(msg.parts[0].text.includes('Payment required'), 'Payment required message');
|
||||
const dataPart = msg.parts.find(p => p.kind === 'data');
|
||||
assert(dataPart.data['x402.payment.required'] === true, 'x402 flag');
|
||||
assert(dataPart.data['x402.version'] === '2.0', `x402 version: ${dataPart.data['x402.version']}`);
|
||||
// V2: accepts array with CAIP-2 network IDs
|
||||
const accepts = dataPart.data['x402.accepts'];
|
||||
assert(Array.isArray(accepts), 'Accepts is array');
|
||||
assert(accepts.length >= 2, `Networks: ${accepts.length}`);
|
||||
assert(accepts[0].network === 'eip155:8453', `Base CAIP-2: ${accepts[0].network}`);
|
||||
assert(accepts[0].price === '$0.01', `Price: ${accepts[0].price}`);
|
||||
// SIWx extension
|
||||
const exts = dataPart.data['x402.extensions'];
|
||||
assert(exts?.['sign-in-with-x']?.supported === true, 'SIWx supported');
|
||||
});
|
||||
|
||||
await test('A2A message/send: PDF returns V2 payment-required', async () => {
|
||||
const r = await fetch(BASE, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0', id: 'test-pdf',
|
||||
method: 'message/send',
|
||||
params: {
|
||||
message: {
|
||||
messageId: 'msg-pdf', role: 'user', kind: 'message',
|
||||
parts: [{ kind: 'text', text: 'Convert to PDF: # My Document' }],
|
||||
},
|
||||
configuration: { blocking: true },
|
||||
},
|
||||
}),
|
||||
});
|
||||
const d = await r.json();
|
||||
assert(d.result.status.state === 'input-required', `State: ${d.result.status.state}`);
|
||||
const dataPart = d.result.status.message.parts.find(p => p.kind === 'data');
|
||||
assert(dataPart.data['x402.accepts'][0].price === '$0.005', `Price: ${dataPart.data['x402.accepts'][0].price}`);
|
||||
});
|
||||
|
||||
await test('A2A message/send: paid screenshot with payment payload', async () => {
|
||||
const r = await fetch(BASE, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0', id: 'test-paid-exec',
|
||||
method: 'message/send',
|
||||
params: {
|
||||
message: {
|
||||
messageId: 'msg-paid-exec', role: 'user', kind: 'message',
|
||||
parts: [{ kind: 'text', text: 'Take a screenshot of https://example.com' }],
|
||||
metadata: {
|
||||
'x402.payment.payload': { scheme: 'exact', network: 'eip155:8453', signature: '0xfake', from: '0xTestWallet123' },
|
||||
'x402.payer': '0xTestWallet123',
|
||||
},
|
||||
},
|
||||
configuration: { blocking: true },
|
||||
},
|
||||
}),
|
||||
});
|
||||
const d = await r.json();
|
||||
assert(d.result, 'Has result');
|
||||
const state = d.result.status.state;
|
||||
assert(state === 'completed' || state === 'failed', `State: ${state}`);
|
||||
if (state === 'completed') {
|
||||
assert(d.result.metadata['x402.version'] === '2.0', 'V2 metadata');
|
||||
assert(d.result.metadata['x402.siwx.active'] === true, 'SIWx session created');
|
||||
const filePart = d.result.status.message.parts.find(p => p.kind === 'file');
|
||||
assert(filePart, 'Has file part (screenshot)');
|
||||
assert(filePart.mimeType === 'image/png', 'PNG mime type');
|
||||
console.log(` (Screenshot: ${Math.round(filePart.data.length * 3/4 / 1024)}KB)`);
|
||||
} else {
|
||||
console.log(` (Expected: SnapAPI may not be running: ${d.result.status.message.parts[0].text})`);
|
||||
}
|
||||
});
|
||||
|
||||
await test('SIWx: session recorded after payment', async () => {
|
||||
const r = await fetch(`${BASE}/api/siwx`);
|
||||
const d = await r.json();
|
||||
// After the paid screenshot test, the wallet should be in sessions
|
||||
const session = d.sessions.find(s => s.wallet === '0xtestwallet123');
|
||||
assert(session, `SIWx session found for test wallet (sessions: ${d.total})`);
|
||||
assert(session.skills.includes('screenshot'), 'Screenshot skill recorded');
|
||||
});
|
||||
|
||||
await test('SIWx: session access bypasses payment', async () => {
|
||||
const r = await fetch(BASE, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0', id: 'test-siwx',
|
||||
method: 'message/send',
|
||||
params: {
|
||||
message: {
|
||||
messageId: 'msg-siwx', role: 'user', kind: 'message',
|
||||
parts: [{ kind: 'text', text: 'Take a screenshot of https://example.com' }],
|
||||
metadata: { 'x402.siwx.wallet': '0xTestWallet123' },
|
||||
},
|
||||
configuration: { blocking: true },
|
||||
},
|
||||
}),
|
||||
});
|
||||
const d = await r.json();
|
||||
assert(d.result, 'Has result');
|
||||
// Should execute directly (completed or failed) without payment-required
|
||||
const state = d.result.status.state;
|
||||
assert(state === 'completed' || state === 'failed', `SIWx access state: ${state} (should not be input-required)`);
|
||||
assert(state !== 'input-required', 'SIWx should bypass payment');
|
||||
});
|
||||
|
||||
await test('SIWx: unknown wallet still requires payment', async () => {
|
||||
const r = await fetch(BASE, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0', id: 'test-siwx-unknown',
|
||||
method: 'message/send',
|
||||
params: {
|
||||
message: {
|
||||
messageId: 'msg-siwx-unknown', role: 'user', kind: 'message',
|
||||
parts: [{ kind: 'text', text: 'Take a screenshot of https://example.com' }],
|
||||
metadata: { 'x402.siwx.wallet': '0xUnknownWallet' },
|
||||
},
|
||||
configuration: { blocking: true },
|
||||
},
|
||||
}),
|
||||
});
|
||||
const d = await r.json();
|
||||
assert(d.result.status.state === 'input-required', `Unknown wallet should require payment: ${d.result.status.state}`);
|
||||
});
|
||||
|
||||
await test('A2A tasks/get returns task', async () => {
|
||||
const r1 = await fetch(BASE, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0', id: 'create',
|
||||
method: 'message/send',
|
||||
params: { message: { messageId: 'msg-get', role: 'user', kind: 'message', parts: [{ kind: 'text', text: '# Test' }] }, configuration: { blocking: true } },
|
||||
}),
|
||||
});
|
||||
const d1 = await r1.json();
|
||||
const taskId = d1.result.id;
|
||||
|
||||
const r2 = await fetch(BASE, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: 'get', method: 'tasks/get', params: { id: taskId } }),
|
||||
});
|
||||
const d2 = await r2.json();
|
||||
assert(d2.result.id === taskId, 'Task ID matches');
|
||||
assert(d2.result.status.state === 'completed', 'Task completed');
|
||||
});
|
||||
|
||||
await test('A2A tasks/get for unknown task returns error', async () => {
|
||||
const r = await fetch(BASE, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: 'notfound', method: 'tasks/get', params: { id: 'nonexistent' } }),
|
||||
});
|
||||
const d = await r.json();
|
||||
assert(d.error, 'Has error');
|
||||
assert(d.error.code === -32001, 'Task not found error code');
|
||||
});
|
||||
|
||||
await test('A2A tasks/cancel works', async () => {
|
||||
// Create a paid task (input-required state)
|
||||
const r1 = await fetch(BASE, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0', id: 'cancel-create',
|
||||
method: 'message/send',
|
||||
params: { message: { messageId: 'msg-cancel', role: 'user', kind: 'message', parts: [{ kind: 'text', text: 'Take a screenshot of https://example.com' }] } },
|
||||
}),
|
||||
});
|
||||
const d1 = await r1.json();
|
||||
const taskId = d1.result.id;
|
||||
|
||||
const r2 = await fetch(BASE, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: 'cancel', method: 'tasks/cancel', params: { id: taskId } }),
|
||||
});
|
||||
const d2 = await r2.json();
|
||||
assert(d2.result.status.state === 'canceled', 'Task canceled');
|
||||
});
|
||||
|
||||
await test('Invalid JSON-RPC returns error', async () => {
|
||||
const r = await fetch(BASE, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ jsonrpc: '1.0', id: 'bad', method: 'message/send', params: {} }),
|
||||
});
|
||||
const d = await r.json();
|
||||
assert(d.error, 'Has error');
|
||||
assert(d.error.code === -32600, 'Invalid request error');
|
||||
});
|
||||
|
||||
await test('Unknown method returns error', async () => {
|
||||
const r = await fetch(BASE, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: 'unknown', method: 'nonexistent/method', params: {} }),
|
||||
});
|
||||
const d = await r.json();
|
||||
assert(d.error.code === -32601, 'Method not found');
|
||||
});
|
||||
|
||||
await test('GET /api/payments reflects activity', async () => {
|
||||
const r = await fetch(`${BASE}/api/payments`);
|
||||
const d = await r.json();
|
||||
assert(d.total > 0, `Total payments: ${d.total}`);
|
||||
assert(d.payments.some(p => p.type === 'payment-required'), 'Has payment-required entry');
|
||||
});
|
||||
|
||||
console.log(`\nResults: ${passed} passed, ${failed} failed, ${passed + failed} total\n`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
Loading…
Reference in New Issue
Block a user