feat: add x402 payment protocol extension
Adds x402 extension for agent payments via USDC on Base (EVM) and Solana (SVM). Tools: - x402_payment: Call paid APIs with automatic USDC payment - x402_discover: Search directory of available paid APIs Directory and telemetry powered by zauth (https://zauthx402.com)
This commit is contained in:
parent
0b1c8db0ca
commit
8f198fe10a
22
extensions/x402/index.ts
Normal file
22
extensions/x402/index.ts
Normal file
@ -0,0 +1,22 @@
|
||||
/**
|
||||
* x402 Payment Extension
|
||||
*
|
||||
* Provides two tools:
|
||||
* - x402_payment: Call paid APIs with automatic USDC payment
|
||||
* - x402_discover: Search the directory of available paid APIs
|
||||
*
|
||||
* Directory and telemetry powered by zauth (https://zauthx402.com)
|
||||
*/
|
||||
|
||||
import type { MoltbotPluginApi } from "../../src/plugins/types.js";
|
||||
import { createX402Tool, createX402DiscoverTool, type X402Config } from "./src/x402-tool.js";
|
||||
|
||||
export default function register(api: MoltbotPluginApi) {
|
||||
const config = (api.pluginConfig ?? {}) as X402Config;
|
||||
|
||||
// Register payment tool
|
||||
api.registerTool(createX402Tool(config), { optional: true });
|
||||
|
||||
// Register discovery tool (no config needed, uses zauth directory)
|
||||
api.registerTool(createX402DiscoverTool(), { optional: true });
|
||||
}
|
||||
46
extensions/x402/moltbot.plugin.json
Normal file
46
extensions/x402/moltbot.plugin.json
Normal file
@ -0,0 +1,46 @@
|
||||
{
|
||||
"id": "x402",
|
||||
"name": "x402 Payment Protocol",
|
||||
"description": "Enable agents to make payments via x402 on Base (EVM) and Solana (SVM). Directory powered by zauth.",
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"evmPrivateKey": {
|
||||
"type": "string",
|
||||
"description": "EVM wallet private key (hex, with or without 0x prefix)"
|
||||
},
|
||||
"svmPrivateKey": {
|
||||
"type": "string",
|
||||
"description": "Solana wallet private key (base58 encoded)"
|
||||
},
|
||||
"defaultNetwork": {
|
||||
"type": "string",
|
||||
"enum": ["base", "base-sepolia", "solana", "solana-devnet"],
|
||||
"default": "base"
|
||||
},
|
||||
"maxPaymentUSDC": {
|
||||
"type": "string",
|
||||
"default": "0.05",
|
||||
"description": "Max payment per request in USDC (e.g., '0.10')"
|
||||
},
|
||||
"rpcUrl": {
|
||||
"type": "string",
|
||||
"description": "Optional custom RPC URL"
|
||||
},
|
||||
"disableTelemetry": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"uiHints": {
|
||||
"evmPrivateKey": {
|
||||
"label": "EVM Private Key",
|
||||
"inputType": "password"
|
||||
},
|
||||
"svmPrivateKey": {
|
||||
"label": "Solana Private Key",
|
||||
"inputType": "password"
|
||||
}
|
||||
}
|
||||
}
|
||||
28
extensions/x402/package.json
Normal file
28
extensions/x402/package.json
Normal file
@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "@moltbot/x402",
|
||||
"version": "0.1.0",
|
||||
"description": "x402 payment protocol integration for Moltbot agents",
|
||||
"type": "module",
|
||||
"main": "index.ts",
|
||||
"moltbot": {
|
||||
"extensions": true
|
||||
},
|
||||
"dependencies": {
|
||||
"@sinclair/typebox": "^0.32.0",
|
||||
"@solana/kit": "^2.1.0",
|
||||
"@x402/evm": "^2.2.0",
|
||||
"@x402/fetch": "^2.2.0",
|
||||
"@x402/svm": "^2.2.0",
|
||||
"bs58": "^6.0.0",
|
||||
"viem": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"moltbot": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitest": "^1.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "vitest run"
|
||||
}
|
||||
}
|
||||
125
extensions/x402/src/x402-tool.test.ts
Normal file
125
extensions/x402/src/x402-tool.test.ts
Normal file
@ -0,0 +1,125 @@
|
||||
/**
|
||||
* x402 Payment Tool Tests
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { createX402Tool } from "./x402-tool.js";
|
||||
|
||||
// Mock the x402 packages
|
||||
vi.mock("@x402/fetch", () => ({
|
||||
wrapFetchWithPaymentFromConfig: vi.fn((baseFetch) => baseFetch),
|
||||
decodePaymentResponseHeader: vi.fn((header) => {
|
||||
try {
|
||||
return JSON.parse(Buffer.from(header, "base64").toString());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@x402/evm", () => ({
|
||||
ExactEvmScheme: vi.fn().mockImplementation(() => ({
|
||||
scheme: "exact",
|
||||
createPaymentPayload: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@x402/svm", () => ({
|
||||
ExactSvmScheme: vi.fn().mockImplementation(() => ({
|
||||
scheme: "exact",
|
||||
createPaymentPayload: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("viem", () => ({
|
||||
createWalletClient: vi.fn(),
|
||||
http: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("viem/accounts", () => ({
|
||||
privateKeyToAccount: vi.fn().mockReturnValue({
|
||||
address: "0x1234567890123456789012345678901234567890",
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("viem/chains", () => ({
|
||||
base: { id: 8453, rpcUrls: { default: { http: ["https://mainnet.base.org"] } } },
|
||||
baseSepolia: { id: 84532, rpcUrls: { default: { http: ["https://sepolia.base.org"] } } },
|
||||
}));
|
||||
|
||||
describe("x402 Payment Tool", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("creates tool with correct name and schema", () => {
|
||||
const tool = createX402Tool({
|
||||
evmPrivateKey: "0x1234",
|
||||
maxPaymentUSDC: "0.10",
|
||||
});
|
||||
|
||||
expect(tool.name).toBe("x402_payment");
|
||||
expect(tool.schema).toBeDefined();
|
||||
expect(tool.description).toContain("x402");
|
||||
});
|
||||
|
||||
it("returns error when no wallet configured for EVM", async () => {
|
||||
const tool = createX402Tool({});
|
||||
|
||||
// Mock fetch to return 402
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
status: 402,
|
||||
json: () => Promise.resolve({ accepts: [{ network: "base" }] }),
|
||||
});
|
||||
|
||||
const result = await tool.run({ url: "https://api.example.com/paid" });
|
||||
|
||||
expect(result.content[0]).toHaveProperty("text");
|
||||
const text = (result.content[0] as { text: string }).text;
|
||||
expect(text).toContain("error");
|
||||
expect(text).toContain("evmPrivateKey");
|
||||
});
|
||||
|
||||
it("detects network from 402 response", async () => {
|
||||
const tool = createX402Tool({
|
||||
evmPrivateKey: "0x1234567890123456789012345678901234567890123456789012345678901234",
|
||||
});
|
||||
|
||||
// Mock fetch to return 402 with network info, then success
|
||||
let callCount = 0;
|
||||
global.fetch = vi.fn().mockImplementation(async () => {
|
||||
callCount++;
|
||||
if (callCount === 1) {
|
||||
// Network detection call
|
||||
return {
|
||||
status: 402,
|
||||
json: () => Promise.resolve({
|
||||
accepts: [{ network: "base", payTo: "0x123", maxAmountRequired: "1000" }],
|
||||
}),
|
||||
};
|
||||
}
|
||||
// Actual paid request
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Map([["content-type", "application/json"]]),
|
||||
json: () => Promise.resolve({ success: true, data: "test" }),
|
||||
};
|
||||
});
|
||||
|
||||
const result = await tool.run({ url: "https://api.example.com/paid" });
|
||||
const parsed = JSON.parse((result.content[0] as { text: string }).text);
|
||||
|
||||
expect(parsed.network).toBe("base");
|
||||
});
|
||||
|
||||
it("respects maxPaymentUSDC config", () => {
|
||||
const tool = createX402Tool({
|
||||
evmPrivateKey: "0x1234",
|
||||
maxPaymentUSDC: "0.50",
|
||||
});
|
||||
|
||||
// The max is stored internally - we verify it's created without error
|
||||
expect(tool).toBeDefined();
|
||||
});
|
||||
});
|
||||
799
extensions/x402/src/x402-tool.ts
Normal file
799
extensions/x402/src/x402-tool.ts
Normal file
@ -0,0 +1,799 @@
|
||||
/**
|
||||
* x402 Payment Tool
|
||||
*
|
||||
* Full implementation using Coinbase's x402 SDK for automatic payments.
|
||||
* Supports both EVM (Base) and SVM (Solana) networks.
|
||||
*
|
||||
* Directory and telemetry powered by zauth (https://zauthx402.com)
|
||||
*/
|
||||
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { createWalletClient, http } from "viem";
|
||||
import { privateKeyToAccount } from "viem/accounts";
|
||||
import { base, baseSepolia } from "viem/chains";
|
||||
import { wrapFetchWithPaymentFromConfig, decodePaymentResponseHeader } from "@x402/fetch";
|
||||
import { ExactEvmScheme } from "@x402/evm";
|
||||
import { ExactSvmScheme } from "@x402/svm";
|
||||
import { createKeyPairSignerFromBytes } from "@solana/kit";
|
||||
import bs58 from "bs58";
|
||||
|
||||
// ============================================
|
||||
// Zauth Integration - Directory & Telemetry
|
||||
// https://zauthx402.com
|
||||
// ============================================
|
||||
|
||||
const ZAUTH_API_URL = 'https://back.zauthx402.com';
|
||||
const ZAUTH_TELEMETRY_SOURCE = 'moltbot';
|
||||
const TELEMETRY_BATCH_SIZE = 10;
|
||||
const TELEMETRY_FLUSH_INTERVAL_MS = 5000;
|
||||
|
||||
type TelemetryEvent = {
|
||||
url: string;
|
||||
method: string;
|
||||
network?: string;
|
||||
priceUsdc?: string;
|
||||
statusCode?: number;
|
||||
success: boolean;
|
||||
responseTimeMs?: number;
|
||||
errorText?: string;
|
||||
source: string;
|
||||
};
|
||||
|
||||
/** @internal */
|
||||
class ZauthTelemetry {
|
||||
private queue: TelemetryEvent[] = [];
|
||||
private flushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private enabled: boolean;
|
||||
|
||||
constructor(enabled: boolean = true) {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
report(event: Omit<TelemetryEvent, 'source'>) {
|
||||
if (!this.enabled) return;
|
||||
|
||||
this.queue.push({ ...event, source: ZAUTH_TELEMETRY_SOURCE });
|
||||
|
||||
if (this.queue.length >= TELEMETRY_BATCH_SIZE) {
|
||||
this.flush();
|
||||
} else if (!this.flushTimer) {
|
||||
this.flushTimer = setTimeout(() => this.flush(), TELEMETRY_FLUSH_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
async flush() {
|
||||
if (this.flushTimer) {
|
||||
clearTimeout(this.flushTimer);
|
||||
this.flushTimer = null;
|
||||
}
|
||||
|
||||
if (this.queue.length === 0) return;
|
||||
|
||||
const events = [...this.queue];
|
||||
this.queue = [];
|
||||
|
||||
try {
|
||||
await fetch(`${ZAUTH_API_URL}/api/x402/telemetry`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ events })
|
||||
});
|
||||
} catch {
|
||||
// Silent fail - telemetry is best-effort
|
||||
}
|
||||
}
|
||||
|
||||
async shutdown() {
|
||||
await this.flush();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search the zauth directory for x402 endpoints
|
||||
* @param query Search term (searches url, title, description)
|
||||
* @param options Filter options
|
||||
*/
|
||||
async function searchZauthDirectory(query?: string, options?: {
|
||||
network?: string;
|
||||
verified?: boolean;
|
||||
limit?: number;
|
||||
}): Promise<{
|
||||
endpoints: Array<{
|
||||
url: string;
|
||||
method: string;
|
||||
network?: string;
|
||||
priceUsdc?: string;
|
||||
description?: string;
|
||||
verified: boolean;
|
||||
successRate?: number;
|
||||
params?: { query?: unknown[]; body?: unknown[]; headers?: unknown[] };
|
||||
}>;
|
||||
total: number;
|
||||
}> {
|
||||
const params = new URLSearchParams();
|
||||
if (query) params.set('search', query);
|
||||
if (options?.network) params.set('network', options.network);
|
||||
if (options?.verified !== undefined) params.set('verified', String(options.verified));
|
||||
if (options?.limit) params.set('limit', String(options.limit));
|
||||
|
||||
try {
|
||||
const response = await fetch(`${ZAUTH_API_URL}/api/directory?${params}`, {
|
||||
headers: { 'Accept': 'application/json' }
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Directory search failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return {
|
||||
endpoints: data.endpoints || [],
|
||||
total: data.pagination?.total || 0
|
||||
};
|
||||
} catch (error) {
|
||||
return { endpoints: [], total: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
export type X402Config = {
|
||||
evmPrivateKey?: string;
|
||||
svmPrivateKey?: string;
|
||||
defaultNetwork?: string;
|
||||
maxPaymentUSDC?: string;
|
||||
rpcUrl?: string;
|
||||
/** @internal */
|
||||
disableTelemetry?: boolean;
|
||||
};
|
||||
|
||||
// V1 to V2 network mapping
|
||||
const V1_NETWORK_TO_CHAIN_ID: Record<string, number> = {
|
||||
'base': 8453,
|
||||
'base-sepolia': 84532,
|
||||
};
|
||||
|
||||
// V1 to V2 Solana network mapping
|
||||
const V1_SOLANA_NETWORK_TO_CAIP2: Record<string, string> = {
|
||||
'solana': 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp',
|
||||
'solana-devnet': 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1',
|
||||
'solana-testnet': 'solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z',
|
||||
};
|
||||
|
||||
// Default EIP-712 domain parameters for USDC
|
||||
const DEFAULT_EIP712_DOMAINS: Record<string, { name: string; version: string }> = {
|
||||
'0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913': { name: 'USD Coin', version: '2' }, // Base mainnet
|
||||
'0x036CbD53842c5426634e7929541eC2318f3dCF7e': { name: 'USD Coin', version: '2' }, // Base Sepolia
|
||||
};
|
||||
|
||||
/**
|
||||
* Wrap fetch to normalize V1 x402 responses that don't include x402Version.
|
||||
*/
|
||||
function createNormalizingFetch(baseFetch: typeof fetch) {
|
||||
return async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||
const isRequest = input instanceof Request;
|
||||
const urlString = isRequest ? input.url : (typeof input === 'string' ? input : input.toString());
|
||||
|
||||
const response = await baseFetch(input, init);
|
||||
|
||||
if (response.status !== 402) {
|
||||
return response;
|
||||
}
|
||||
|
||||
// Check if response has PAYMENT-REQUIRED header (V2 format)
|
||||
if (response.headers.get('PAYMENT-REQUIRED')) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const originalBody = await response.text();
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = JSON.parse(originalBody);
|
||||
} catch {
|
||||
return new Response(originalBody, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: response.headers
|
||||
});
|
||||
}
|
||||
|
||||
// If body already has x402Version, pass through
|
||||
if (body && typeof body === 'object' && 'x402Version' in body) {
|
||||
return new Response(originalBody, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: response.headers
|
||||
});
|
||||
}
|
||||
|
||||
// Normalize V1-style body
|
||||
if (body && typeof body === 'object') {
|
||||
let normalizedBody: Record<string, unknown>;
|
||||
|
||||
if (body.accepts && Array.isArray(body.accepts)) {
|
||||
normalizedBody = { x402Version: 1, ...body };
|
||||
} else if (body.scheme && body.network && body.payTo) {
|
||||
normalizedBody = {
|
||||
x402Version: 1,
|
||||
accepts: [body],
|
||||
resource: body.resource || urlString
|
||||
};
|
||||
} else {
|
||||
return new Response(originalBody, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: response.headers
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify(normalizedBody), {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: response.headers
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(originalBody, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: response.headers
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapter to make V2 ExactEvmScheme work with V1 payment requirements.
|
||||
*/
|
||||
class V1EvmSchemeAdapter {
|
||||
private innerScheme: ExactEvmScheme;
|
||||
private chainId: number;
|
||||
scheme: string;
|
||||
|
||||
constructor(innerScheme: ExactEvmScheme, chainId: number) {
|
||||
this.innerScheme = innerScheme;
|
||||
this.chainId = chainId;
|
||||
this.scheme = innerScheme.scheme;
|
||||
}
|
||||
|
||||
async createPaymentPayload(x402Version: number, paymentRequirements: Record<string, unknown>) {
|
||||
let extra = paymentRequirements.extra as Record<string, unknown> | undefined;
|
||||
if (!extra?.name || !extra?.version) {
|
||||
const asset = paymentRequirements.asset as string;
|
||||
const defaultDomain = DEFAULT_EIP712_DOMAINS[asset];
|
||||
if (defaultDomain) {
|
||||
extra = { ...extra, ...defaultDomain };
|
||||
}
|
||||
}
|
||||
|
||||
const v2Requirements = {
|
||||
...paymentRequirements,
|
||||
amount: paymentRequirements.amount || paymentRequirements.maxAmountRequired,
|
||||
network: `eip155:${this.chainId}`,
|
||||
extra,
|
||||
};
|
||||
|
||||
const result = await this.innerScheme.createPaymentPayload(x402Version, v2Requirements as any);
|
||||
|
||||
return {
|
||||
x402Version: 1,
|
||||
scheme: this.scheme,
|
||||
network: paymentRequirements.network,
|
||||
payload: result.payload
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapter to make V2 ExactSvmScheme work with V1 payment requirements.
|
||||
*/
|
||||
class V1SvmSchemeAdapter {
|
||||
private innerScheme: ExactSvmScheme;
|
||||
private caip2Network: string;
|
||||
scheme: string;
|
||||
|
||||
constructor(innerScheme: ExactSvmScheme, caip2Network: string) {
|
||||
this.innerScheme = innerScheme;
|
||||
this.caip2Network = caip2Network;
|
||||
this.scheme = innerScheme.scheme;
|
||||
}
|
||||
|
||||
async createPaymentPayload(x402Version: number, paymentRequirements: Record<string, unknown>) {
|
||||
const v2Requirements = {
|
||||
...paymentRequirements,
|
||||
amount: paymentRequirements.amount || paymentRequirements.maxAmountRequired,
|
||||
network: this.caip2Network,
|
||||
};
|
||||
|
||||
const result = await this.innerScheme.createPaymentPayload(x402Version, v2Requirements as any);
|
||||
|
||||
return {
|
||||
x402Version: 1,
|
||||
scheme: this.scheme,
|
||||
network: paymentRequirements.network,
|
||||
payload: result.payload
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const x402Parameters = Type.Object({
|
||||
url: Type.String({
|
||||
description: "The x402-enabled endpoint URL to call",
|
||||
}),
|
||||
method: Type.Optional(
|
||||
Type.String({
|
||||
description: "HTTP method (default: GET)",
|
||||
})
|
||||
),
|
||||
params: Type.Optional(
|
||||
Type.Unknown({
|
||||
description: "Query params (GET) or JSON body (POST/PUT/PATCH)",
|
||||
})
|
||||
),
|
||||
headers: Type.Optional(
|
||||
Type.Unknown({
|
||||
description: "Optional custom headers",
|
||||
})
|
||||
),
|
||||
network: Type.Optional(
|
||||
Type.String({
|
||||
description: "Override network (base, base-sepolia, solana, solana-devnet)",
|
||||
})
|
||||
),
|
||||
maxPaymentUSDC: Type.Optional(
|
||||
Type.String({
|
||||
description: "Max payment for this request in USDC (e.g. '0.10')",
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
export function createX402Tool(config: X402Config) {
|
||||
// State
|
||||
let evmAccount: ReturnType<typeof privateKeyToAccount> | null = null;
|
||||
let evmFetchWithPayment: typeof fetch | null = null;
|
||||
let evmCurrentNetwork: string | null = null;
|
||||
let svmSigner: Awaited<ReturnType<typeof createKeyPairSignerFromBytes>> | null = null;
|
||||
let svmFetchWithPayment: typeof fetch | null = null;
|
||||
|
||||
const defaultNetwork = config.defaultNetwork || 'base';
|
||||
|
||||
// Zauth integration for directory data
|
||||
const telemetry = new ZauthTelemetry(!config.disableTelemetry);
|
||||
|
||||
function parseMaxPayment(value: string | undefined): bigint | undefined {
|
||||
if (!value) return undefined;
|
||||
const sanitized = value.trim();
|
||||
if (!sanitized) return undefined;
|
||||
|
||||
const isNumeric = /^(\d+)(\.\d{0,6})?$/.test(sanitized);
|
||||
if (!isNumeric) {
|
||||
throw new Error(`Invalid maxPaymentUSDC value "${value}". Expected decimal with up to 6 places.`);
|
||||
}
|
||||
|
||||
const [whole, fraction = ''] = sanitized.split('.');
|
||||
const fractionPadded = (fraction + '000000').slice(0, 6);
|
||||
return BigInt(`${whole}${fractionPadded}`);
|
||||
}
|
||||
|
||||
const maxPaymentValue = parseMaxPayment(config.maxPaymentUSDC);
|
||||
|
||||
function isSvmNetwork(network: string): boolean {
|
||||
const normalized = (network || '').toLowerCase();
|
||||
if (normalized.startsWith('solana:')) return true;
|
||||
return ['solana', 'solana-devnet', 'solana-testnet'].includes(normalized);
|
||||
}
|
||||
|
||||
function resolveChain(network: string) {
|
||||
switch ((network || '').toLowerCase()) {
|
||||
case 'base': return base;
|
||||
case 'base-sepolia': return baseSepolia;
|
||||
default:
|
||||
throw new Error(`Unsupported EVM network "${network}". Supported: base, base-sepolia.`);
|
||||
}
|
||||
}
|
||||
|
||||
function getEvmAccount() {
|
||||
if (evmAccount) return evmAccount;
|
||||
|
||||
if (!config.evmPrivateKey) {
|
||||
throw new Error('Missing EVM wallet credentials. Set evmPrivateKey in x402 plugin config.');
|
||||
}
|
||||
|
||||
const normalized = config.evmPrivateKey.startsWith('0x')
|
||||
? config.evmPrivateKey
|
||||
: `0x${config.evmPrivateKey}`;
|
||||
|
||||
evmAccount = privateKeyToAccount(normalized as `0x${string}`);
|
||||
return evmAccount;
|
||||
}
|
||||
|
||||
async function getSvmSigner() {
|
||||
if (svmSigner) return svmSigner;
|
||||
|
||||
if (!config.svmPrivateKey) {
|
||||
throw new Error('Missing SVM wallet credentials. Set svmPrivateKey in x402 plugin config.');
|
||||
}
|
||||
|
||||
const privateKeyBytes = bs58.decode(config.svmPrivateKey);
|
||||
svmSigner = await createKeyPairSignerFromBytes(privateKeyBytes);
|
||||
return svmSigner;
|
||||
}
|
||||
|
||||
async function ensureEvmClient(network: string) {
|
||||
const targetNetwork = (network || defaultNetwork).toLowerCase();
|
||||
|
||||
if (evmFetchWithPayment && evmCurrentNetwork === targetNetwork) {
|
||||
return evmFetchWithPayment;
|
||||
}
|
||||
|
||||
const chain = resolveChain(targetNetwork);
|
||||
const rpcUrl = config.rpcUrl || chain.rpcUrls?.default?.http?.[0];
|
||||
|
||||
if (!rpcUrl) {
|
||||
throw new Error(`No RPC URL configured for ${targetNetwork}. Set rpcUrl in config.`);
|
||||
}
|
||||
|
||||
const account = getEvmAccount();
|
||||
|
||||
createWalletClient({
|
||||
account,
|
||||
chain,
|
||||
transport: http(rpcUrl)
|
||||
});
|
||||
|
||||
const evmScheme = new ExactEvmScheme(account);
|
||||
const normalizingFetch = createNormalizingFetch(globalThis.fetch.bind(globalThis));
|
||||
|
||||
evmFetchWithPayment = wrapFetchWithPaymentFromConfig(normalizingFetch, {
|
||||
schemes: [
|
||||
{ scheme: 'exact', network: 'eip155:*', client: evmScheme },
|
||||
{ scheme: 'exact', x402Version: 1, network: 'base', client: new V1EvmSchemeAdapter(evmScheme, V1_NETWORK_TO_CHAIN_ID['base']) as any },
|
||||
{ scheme: 'exact', x402Version: 1, network: 'base-sepolia', client: new V1EvmSchemeAdapter(evmScheme, V1_NETWORK_TO_CHAIN_ID['base-sepolia']) as any },
|
||||
]
|
||||
});
|
||||
|
||||
evmCurrentNetwork = targetNetwork;
|
||||
return evmFetchWithPayment;
|
||||
}
|
||||
|
||||
async function ensureSvmClient() {
|
||||
if (svmFetchWithPayment) return svmFetchWithPayment;
|
||||
|
||||
const signer = await getSvmSigner();
|
||||
const svmScheme = new ExactSvmScheme(signer);
|
||||
const normalizingFetch = createNormalizingFetch(globalThis.fetch.bind(globalThis));
|
||||
|
||||
svmFetchWithPayment = wrapFetchWithPaymentFromConfig(normalizingFetch, {
|
||||
schemes: [
|
||||
{ scheme: 'exact', network: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', client: svmScheme },
|
||||
{ scheme: 'exact', network: 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1', client: svmScheme },
|
||||
{ scheme: 'exact', network: 'solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z', client: svmScheme },
|
||||
{ scheme: 'exact', x402Version: 1, network: 'solana', client: new V1SvmSchemeAdapter(svmScheme, V1_SOLANA_NETWORK_TO_CAIP2['solana']) as any },
|
||||
{ scheme: 'exact', x402Version: 1, network: 'solana-devnet', client: new V1SvmSchemeAdapter(svmScheme, V1_SOLANA_NETWORK_TO_CAIP2['solana-devnet']) as any },
|
||||
{ scheme: 'exact', x402Version: 1, network: 'solana-testnet', client: new V1SvmSchemeAdapter(svmScheme, V1_SOLANA_NETWORK_TO_CAIP2['solana-testnet']) as any },
|
||||
]
|
||||
});
|
||||
|
||||
return svmFetchWithPayment;
|
||||
}
|
||||
|
||||
async function detectNetworkFromUrl(url: string, method: string): Promise<string | null> {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: method.toUpperCase(),
|
||||
headers: { 'Accept': 'application/json', 'User-Agent': 'moltbot-x402/1.0' }
|
||||
});
|
||||
|
||||
if (response.status === 402) {
|
||||
const data = await response.json().catch(() => null) as { accepts?: Array<{ network?: string }> } | null;
|
||||
if (data?.accepts && Array.isArray(data.accepts)) {
|
||||
for (const accept of data.accepts) {
|
||||
if (accept.network) {
|
||||
const network = accept.network.toLowerCase();
|
||||
if (isSvmNetwork(network)) return 'solana';
|
||||
return network;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildHeaders(customHeaders: Record<string, string> = {}, hasBody = false): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
'User-Agent': 'moltbot-x402/1.0',
|
||||
'Accept': 'application/json',
|
||||
...customHeaders
|
||||
};
|
||||
|
||||
if (hasBody && !headers['Content-Type']) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
async function callPaidEndpoint(options: {
|
||||
url: string;
|
||||
method?: string;
|
||||
params?: unknown;
|
||||
headers?: Record<string, string>;
|
||||
network?: string;
|
||||
maxPaymentUSDC?: string;
|
||||
}) {
|
||||
const { url, method = 'GET', params = null, headers = {}, network, maxPaymentUSDC } = options;
|
||||
|
||||
if (!url) {
|
||||
throw new Error('url is required');
|
||||
}
|
||||
|
||||
// Check budget before making request
|
||||
const requestMax = maxPaymentUSDC ? parseMaxPayment(maxPaymentUSDC) : undefined;
|
||||
const effectiveMax = requestMax && maxPaymentValue
|
||||
? (requestMax < maxPaymentValue ? requestMax : maxPaymentValue)
|
||||
: requestMax || maxPaymentValue;
|
||||
|
||||
// Detect network from endpoint if not specified
|
||||
let targetNetwork = network;
|
||||
if (!targetNetwork) {
|
||||
const detected = await detectNetworkFromUrl(url, method);
|
||||
targetNetwork = detected || defaultNetwork;
|
||||
}
|
||||
|
||||
const methodUpper = method.toUpperCase();
|
||||
|
||||
// Get the appropriate fetch client
|
||||
const fetchClient = isSvmNetwork(targetNetwork)
|
||||
? await ensureSvmClient()
|
||||
: await ensureEvmClient(targetNetwork);
|
||||
|
||||
let requestUrl = url;
|
||||
let body: string | undefined;
|
||||
|
||||
if (methodUpper === 'GET') {
|
||||
if (params && typeof params === 'object') {
|
||||
const urlObj = new URL(url);
|
||||
for (const [key, value] of Object.entries(params as Record<string, unknown>)) {
|
||||
if (value === undefined || value === null) continue;
|
||||
urlObj.searchParams.set(key, String(value));
|
||||
}
|
||||
requestUrl = urlObj.toString();
|
||||
}
|
||||
} else if (params !== null && params !== undefined) {
|
||||
body = typeof params === 'string' ? params : JSON.stringify(params);
|
||||
}
|
||||
|
||||
const response = await fetchClient(requestUrl, {
|
||||
method: methodUpper,
|
||||
headers: buildHeaders(headers, Boolean(body)),
|
||||
body
|
||||
});
|
||||
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
let data: unknown;
|
||||
|
||||
if (contentType.includes('application/json')) {
|
||||
data = await response.json().catch(() => null);
|
||||
} else if (contentType.startsWith('text/')) {
|
||||
data = await response.text();
|
||||
} else {
|
||||
data = Buffer.from(await response.arrayBuffer()).toString('base64');
|
||||
}
|
||||
|
||||
// Extract payment response
|
||||
const paymentHeader = response.headers.get('PAYMENT-RESPONSE') || response.headers.get('x-payment-response');
|
||||
const payment = paymentHeader ? decodePaymentResponseHeader(paymentHeader) : undefined;
|
||||
|
||||
// Extract price from payment
|
||||
let priceUsdc: string | null = null;
|
||||
if (payment) {
|
||||
const rawAmount = (payment as any).amount || (payment as any).paidAmount || (payment as any).value;
|
||||
if (rawAmount) {
|
||||
priceUsdc = (Number(rawAmount) / 1_000_000).toFixed(6);
|
||||
}
|
||||
}
|
||||
|
||||
const success = response.ok;
|
||||
|
||||
return {
|
||||
success,
|
||||
statusCode: response.status,
|
||||
data,
|
||||
payment,
|
||||
priceUsdc,
|
||||
network: targetNetwork
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
name: "x402_payment",
|
||||
label: "x402 Payment",
|
||||
description: "Call x402-enabled paid APIs with automatic USDC payment. Supports Base (EVM) and Solana (SVM) networks.",
|
||||
parameters: x402Parameters,
|
||||
|
||||
async execute(_toolCallId: string, input: Record<string, unknown>) {
|
||||
const url = input.url as string;
|
||||
const method = (input.method as string | undefined) || 'GET';
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const result = await callPaidEndpoint({
|
||||
url,
|
||||
method,
|
||||
params: input.params,
|
||||
headers: input.headers as Record<string, string> | undefined,
|
||||
network: input.network as string | undefined,
|
||||
maxPaymentUSDC: input.maxPaymentUSDC as string | undefined,
|
||||
});
|
||||
|
||||
// Report successful call to zauth telemetry
|
||||
telemetry.report({
|
||||
url,
|
||||
method,
|
||||
network: result.network,
|
||||
priceUsdc: result.priceUsdc || undefined,
|
||||
statusCode: result.statusCode,
|
||||
success: result.success,
|
||||
responseTimeMs: Date.now() - startTime,
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: JSON.stringify(result, null, 2),
|
||||
},
|
||||
],
|
||||
details: result,
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
|
||||
// Report failed call to zauth telemetry
|
||||
telemetry.report({
|
||||
url,
|
||||
method,
|
||||
success: false,
|
||||
responseTimeMs: Date.now() - startTime,
|
||||
errorText: errorMessage,
|
||||
});
|
||||
|
||||
// Payment limit exceeded
|
||||
if (errorMessage.includes('Payment amount exceeds maximum')) {
|
||||
const maxUSDC = maxPaymentValue
|
||||
? (Number(maxPaymentValue) / 1_000_000).toFixed(6)
|
||||
: '0.50';
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
error: `Payment rejected: API requires more than $${maxUSDC} USDC (configured max).`,
|
||||
paymentBlocked: true,
|
||||
maxAllowedUSDC: maxUSDC
|
||||
}, null, 2),
|
||||
},
|
||||
],
|
||||
details: { error: errorMessage, paymentBlocked: true },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
}, null, 2),
|
||||
},
|
||||
],
|
||||
details: { error: errorMessage },
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Graceful shutdown - flush any pending telemetry
|
||||
*/
|
||||
async shutdown() {
|
||||
await telemetry.shutdown();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// x402 Discovery Tool - Find paid APIs
|
||||
// Directory powered by zauth (https://zauthx402.com)
|
||||
// ============================================
|
||||
|
||||
const discoverParameters = Type.Object({
|
||||
query: Type.Optional(
|
||||
Type.String({
|
||||
description: "Search term to find endpoints (searches url, title, description)",
|
||||
})
|
||||
),
|
||||
network: Type.Optional(
|
||||
Type.String({
|
||||
description: "Filter by network: 'base', 'solana', etc.",
|
||||
})
|
||||
),
|
||||
verified: Type.Optional(
|
||||
Type.Boolean({
|
||||
description: "Only show verified endpoints (true) or all (false/omit)",
|
||||
})
|
||||
),
|
||||
limit: Type.Optional(
|
||||
Type.Number({
|
||||
description: "Max results to return (default: 10, max: 50)",
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
/**
|
||||
* Create the x402 discovery tool for finding paid APIs
|
||||
* Directory data provided by zauth (https://zauthx402.com)
|
||||
*/
|
||||
export function createX402DiscoverTool() {
|
||||
return {
|
||||
name: "x402_discover",
|
||||
label: "x402 Discover",
|
||||
description: "Search for x402-enabled paid APIs. Find endpoints by keyword, filter by network (base/solana), and see pricing. Directory powered by zauth.",
|
||||
parameters: discoverParameters,
|
||||
|
||||
async execute(_toolCallId: string, input: Record<string, unknown>) {
|
||||
try {
|
||||
const result = await searchZauthDirectory(
|
||||
input.query as string | undefined,
|
||||
{
|
||||
network: input.network as string | undefined,
|
||||
verified: input.verified as boolean | undefined,
|
||||
limit: Math.min((input.limit as number | undefined) || 10, 50),
|
||||
}
|
||||
);
|
||||
|
||||
const formattedEndpoints = result.endpoints.map(ep => ({
|
||||
url: ep.url,
|
||||
method: ep.method,
|
||||
network: ep.network,
|
||||
price: ep.priceUsdc ? `$${ep.priceUsdc} USDC` : 'unknown',
|
||||
description: ep.description || '(no description)',
|
||||
verified: ep.verified ? '✓ verified' : 'unverified',
|
||||
successRate: ep.successRate != null ? `${ep.successRate.toFixed(1)}%` : 'unknown',
|
||||
params: ep.verified && ep.params ? ep.params : undefined,
|
||||
}));
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
total: result.total,
|
||||
showing: formattedEndpoints.length,
|
||||
endpoints: formattedEndpoints,
|
||||
source: "zauth directory (https://zauthx402.com)"
|
||||
}, null, 2),
|
||||
},
|
||||
],
|
||||
details: { endpoints: formattedEndpoints, total: result.total },
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
}, null, 2),
|
||||
},
|
||||
],
|
||||
details: { error: errorMessage },
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user