@celorodrigues/x402-conformance 2.1.0 → 2.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -6
- package/bin/cli.js +1 -1
- package/mcp-server.js +80 -46
- package/package.json +3 -2
- package/smithery.yaml +5 -5
- package/tx-simulator.js +226 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @celorodrigues/x402-conformance
|
|
2
2
|
|
|
3
|
-
[](https://automaton-api.bfzovw.easypanel.host/leaderboard)
|
|
4
4
|
[](https://opensource.org/licenses/MIT)
|
|
5
5
|
|
|
6
6
|
> **Autonomous Linter and Conformance Suite for Official x402 Micropayments & EIP-3009**
|
|
@@ -47,9 +47,34 @@ The same package runs as an MCP stdio server:
|
|
|
47
47
|
- Claude Desktop: Settings → Developer → Edit Config (`claude_desktop_config.json`), then restart.
|
|
48
48
|
- Cursor: `.cursor/mcp.json` in the project, or `~/.cursor/mcp.json`.
|
|
49
49
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
50
|
+
### Available MCP Tools (15 Tools)
|
|
51
|
+
|
|
52
|
+
#### 🆓 Free Diagnostics & Local Execution (No payment required)
|
|
53
|
+
| Tool | Description |
|
|
54
|
+
|---|---|
|
|
55
|
+
| `simulate_base_transaction` | EVM transaction dry-run with exact gas estimation and revert reason decoding on Base L2. |
|
|
56
|
+
| `x402_conformance_check` | Full 12-point conformance audit of any HTTP 402 API endpoint against the official spec. |
|
|
57
|
+
| `health` | Real-time service diagnostics, node health, and payment recipient address. |
|
|
58
|
+
| `pricing` | Live pricing directory across all hosted x402 services and settlement rails. |
|
|
59
|
+
| `read_ledger` | Query the immutable, signed ledger of attested operations. |
|
|
60
|
+
| `verify_attestation` | Verify cryptographic signatures and proofs offline or online. |
|
|
61
|
+
| `attestation_pubkey` | Retrieve the agent's public key for independent trustless verification. |
|
|
62
|
+
| `merkle_verify` | Validate cryptographic Merkle tree inclusion proofs. |
|
|
63
|
+
|
|
64
|
+
#### ⚡ Hosted Intelligence & Consensus (x402 Micro-payments on Base — 3 Free Trials/Day/IP)
|
|
65
|
+
| Tool | Price | Description |
|
|
66
|
+
|---|---|---|
|
|
67
|
+
| `oracle_base` | 0.001 USDC | Multi-RPC consensus oracle on Base (block, gas, clock, balance). |
|
|
68
|
+
| `security_scan` | 0.002 USDC | EVM bytecode scanner, honeypot detection, fee-traps & contract risk analysis. |
|
|
69
|
+
| `sentiment_analysis` | 0.001 USDC | Fast agentic financial sentiment scoring for market data. |
|
|
70
|
+
| `attest` | 0.05 USDC | Generate on-chain attestations signed by Agent #95791. |
|
|
71
|
+
| `merkle_prove` | 0.05 USDC | Cryptographic Merkle batch proof generation. |
|
|
72
|
+
| `hash_sha256` | 0.0001 USDC | Fast deterministic SHA-256 computation. |
|
|
73
|
+
| `uuid` | 0.0001 USDC | Cryptographically secure UUID generation. |
|
|
74
|
+
|
|
75
|
+
Optional environment variables:
|
|
76
|
+
- `VALUE_API_BASE`: Default `https://automaton-api.bfzovw.easypanel.host` (or edge mirror `https://api.automaton-sovereign.workers.dev`).
|
|
77
|
+
- `VALUE_API_PAYMENT`: Optional Base USDC transaction hash or EIP-3009 authorization payload.
|
|
53
78
|
|
|
54
79
|
---
|
|
55
80
|
|
|
@@ -77,13 +102,13 @@ Optional env: `VALUE_API_BASE` (default `https://api.automaton-sovereign.workers
|
|
|
77
102
|
Services that achieve a **CONFORMANT** verdict (Grade A or A+) can request an on-chain signed attestation and permanent verification badge:
|
|
78
103
|
|
|
79
104
|
```bash
|
|
80
|
-
curl -X POST https://
|
|
105
|
+
curl -X POST https://automaton-api.bfzovw.easypanel.host/v2/conformance/certify \
|
|
81
106
|
-H "Content-Type: application/json" \
|
|
82
107
|
-H "X-PAYMENT: <txHash-or-EIP3009>" \
|
|
83
108
|
-d '{"url":"https://your-service.com/v1/paid"}'
|
|
84
109
|
```
|
|
85
110
|
|
|
86
|
-
* Certified services appear on the public [x402 Leaderboard](https://
|
|
111
|
+
* Certified services appear on the public [x402 Leaderboard](https://automaton-api.bfzovw.easypanel.host/leaderboard).
|
|
87
112
|
* Fee: 0.05 USDC settled directly on Base L2 to `0x71DEAc098914A009E3720524642A6bE6F65EE528`.
|
|
88
113
|
|
|
89
114
|
---
|
package/bin/cli.js
CHANGED
package/mcp-server.js
CHANGED
|
@@ -22,8 +22,9 @@ const https = require('https');
|
|
|
22
22
|
const { URL } = require('url');
|
|
23
23
|
const PKG = require('./package.json');
|
|
24
24
|
const linter = require('./index.js');
|
|
25
|
+
const simulator = require('./tx-simulator.js');
|
|
25
26
|
|
|
26
|
-
const BASE = (process.env.VALUE_API_BASE || 'https://
|
|
27
|
+
const BASE = (process.env.VALUE_API_BASE || 'https://automaton-api.bfzovw.easypanel.host').replace(/\/+$/, '');
|
|
27
28
|
const PAYMENT = process.env.VALUE_API_PAYMENT || '';
|
|
28
29
|
|
|
29
30
|
function httpJson(method, urlStr, headers, body) {
|
|
@@ -49,9 +50,11 @@ function httpJson(method, urlStr, headers, body) {
|
|
|
49
50
|
const TOOLS = [
|
|
50
51
|
{ name: 'x402_conformance_check', description: 'FREE, runs locally. Lint any x402 paid endpoint for protocol conformance: HTTP 402 challenge, x402Version, accepts[] (exact scheme), Base network and addresses, EIP-712 domain, and rejection of malformed, forged, expired, underpaid and wrong-recipient EIP-3009 payments. Returns verdict, grade and per-check results.',
|
|
51
52
|
inputSchema: { type: 'object', properties: { url: { type: 'string', description: 'Full URL of the x402-protected endpoint to check.' } }, required: ['url'] } },
|
|
52
|
-
{ name: '
|
|
53
|
+
{ name: 'simulate_base_transaction', description: 'FREE, runs locally against Base Mainnet RPC. Dry-run a transaction before sending it: eth_call + eth_estimateGas at the latest block. Predicts whether it will revert and decodes the reason (Error(string), Panic(uint256) codes, custom-error selector, or node rejection such as insufficient funds). Returns { ok, willRevert, revertReason, estimatedGas, returnData }.',
|
|
54
|
+
inputSchema: { type: 'object', properties: { to: { type: 'string', description: 'Target contract or recipient address on Base (0x...).' }, data: { type: 'string', description: 'Calldata as 0x-hex (default 0x for a plain ETH transfer).' }, value: { type: 'string', description: 'ETH value in wei, decimal or 0x-hex (default 0).' }, from: { type: 'string', description: 'Optional sender address; needed for balance/allowance-dependent calls.' } }, required: ['to'] } },
|
|
55
|
+
{ name: 'security_scan', description: 'PAID 0.002 USDC/call, or free trial (3/day/IP). Base token safety analysis: honeypot detection, mint traps, selfdestruct/delegatecall, proxy and tax risks from contract bytecode, with a risk score and signed verdict. On 402, pay the accepts[] terms then retry with the payment tx hash.',
|
|
53
56
|
inputSchema: { type: 'object', properties: { address: { type: 'string', description: 'Token or contract address on Base (0x...).' }, payment: { type: 'string', description: 'Optional X-PAYMENT base tx hash of the USDC payment.' } }, required: ['address'] } },
|
|
54
|
-
{ name: 'attest', description: 'Create a SIGNED, hash-chained, append-only attestation (verifiable proof-of-existence) for a payload. PAID: 0.
|
|
57
|
+
{ name: 'attest', description: 'Create a SIGNED, hash-chained, append-only attestation (verifiable proof-of-existence) for a payload. PAID: 0.05 USDC/call, or free trial (3/day/IP). On 402, pay the accepts[] terms then retry with the payment tx hash.',
|
|
55
58
|
inputSchema: { type: 'object', properties: { data: { type: 'string', description: 'Payload to attest (any string).' }, payment: { type: 'string', description: 'Optional X-PAYMENT base tx hash of the USDC payment.' } }, required: ['data'] } },
|
|
56
59
|
{ name: 'verify_attestation', description: 'FREE. Verify a ledger entry by index or by data hash: recomputes hash, checks ECDSA signature and chain link.',
|
|
57
60
|
inputSchema: { type: 'object', properties: { index: { type: 'integer' }, dataHash: { type: 'string' } } } },
|
|
@@ -77,63 +80,94 @@ const TOOLS = [
|
|
|
77
80
|
|
|
78
81
|
function q(o) { return Object.entries(o || {}).filter(([, v]) => v !== undefined && v !== null && v !== '').map(([k, v]) => k + '=' + encodeURIComponent(v)).join('&'); }
|
|
79
82
|
|
|
80
|
-
|
|
83
|
+
// opts.base / opts.headers let an embedding server (HTTP /mcp) call its own API directly and
|
|
84
|
+
// forward the caller's IP / public host.
|
|
85
|
+
async function callTool(name, args, opts) {
|
|
81
86
|
args = args || {};
|
|
82
|
-
|
|
87
|
+
opts = opts || {};
|
|
88
|
+
const B = (opts.base || BASE).replace(/\/+$/, '');
|
|
89
|
+
const fwd = opts.headers || {};
|
|
90
|
+
const payHeader = Object.assign({}, fwd, (args.payment || PAYMENT) ? { 'X-PAYMENT': args.payment || PAYMENT } : {});
|
|
83
91
|
switch (name) {
|
|
84
92
|
case 'x402_conformance_check': {
|
|
85
93
|
if (!/^https?:\/\//i.test(args.url || '')) throw new Error('url must be an absolute http(s) URL');
|
|
86
94
|
return { status: 200, body: await linter.run(args.url) };
|
|
87
95
|
}
|
|
88
|
-
case '
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
case '
|
|
93
|
-
case '
|
|
94
|
-
case '
|
|
95
|
-
case '
|
|
96
|
-
case '
|
|
97
|
-
case '
|
|
98
|
-
case '
|
|
99
|
-
case '
|
|
100
|
-
case '
|
|
96
|
+
case 'simulate_base_transaction': {
|
|
97
|
+
const r = await simulator.simulate({ to: args.to, data: args.data, value: args.value, from: args.from });
|
|
98
|
+
return { status: r.ok ? 200 : 400, body: r };
|
|
99
|
+
}
|
|
100
|
+
case 'security_scan': return httpJson('GET', B + '/v2/security/scan?' + q({ address: args.address }), payHeader);
|
|
101
|
+
case 'attest': return httpJson('POST', B + '/v2/attest', payHeader, { data: args.data });
|
|
102
|
+
case 'verify_attestation': return httpJson('GET', B + '/v2/verify?' + q({ index: args.index, dataHash: args.dataHash }), fwd);
|
|
103
|
+
case 'attestation_pubkey': return httpJson('GET', B + '/v2/pubkey', fwd);
|
|
104
|
+
case 'read_ledger': return httpJson('GET', B + '/v2/ledger?' + q({ from: args.from, limit: args.limit }), fwd);
|
|
105
|
+
case 'oracle_base': return httpJson('GET', B + '/v2/oracle/base', payHeader);
|
|
106
|
+
case 'merkle_prove': return httpJson('POST', B + '/v2/merkle/prove', payHeader, { items: args.items, target: args.target });
|
|
107
|
+
case 'merkle_verify': return httpJson('POST', B + '/v2/merkle/verify', fwd, { item: args.item, proof: args.proof, root: args.root });
|
|
108
|
+
case 'sentiment_analysis': return httpJson('GET', B + '/v2/sentiment?' + q({ asset: args.asset }), payHeader);
|
|
109
|
+
case 'hash_sha256': return httpJson('GET', B + '/v1/hash?' + q({ input: args.input }), payHeader);
|
|
110
|
+
case 'uuid': return httpJson('GET', B + '/v1/uuid', payHeader);
|
|
111
|
+
case 'pricing': return httpJson('GET', B + '/pricing', fwd);
|
|
112
|
+
case 'health': return httpJson('GET', B + '/health', fwd);
|
|
101
113
|
default: throw new Error('unknown tool: ' + name);
|
|
102
114
|
}
|
|
103
115
|
}
|
|
104
116
|
|
|
105
|
-
|
|
106
|
-
function replyErr(id, code, message, data) { process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id, error: { code, message, data } }) + '\n'); }
|
|
117
|
+
const PROTOCOL_VERSIONS = ['2025-06-18', '2025-03-26', '2024-11-05'];
|
|
107
118
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
119
|
+
/**
|
|
120
|
+
* Handles one JSON-RPC 2.0 message. Resolves to the response object, or null for
|
|
121
|
+
* notifications (no id). Shared by the stdio transport and the HTTP /mcp endpoint.
|
|
122
|
+
*/
|
|
123
|
+
async function handleMessage(msg, opts) {
|
|
124
|
+
if (!msg || msg.jsonrpc !== '2.0' || typeof msg.method !== 'string') {
|
|
125
|
+
return { jsonrpc: '2.0', id: (msg && msg.id !== undefined) ? msg.id : null, error: { code: -32600, message: 'invalid request' } };
|
|
126
|
+
}
|
|
127
|
+
const isNotification = msg.id === undefined;
|
|
128
|
+
const ok = (result) => isNotification ? null : { jsonrpc: '2.0', id: msg.id, result };
|
|
129
|
+
switch (msg.method) {
|
|
130
|
+
case 'initialize': {
|
|
131
|
+
const asked = msg.params && msg.params.protocolVersion;
|
|
132
|
+
return ok({ protocolVersion: PROTOCOL_VERSIONS.includes(asked) ? asked : PROTOCOL_VERSIONS[0], capabilities: { tools: {} },
|
|
118
133
|
serverInfo: { name: 'automaton-x402', version: PKG.version } });
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
} else if (msg.method === 'tools/list') {
|
|
124
|
-
reply(msg.id, { tools: TOOLS });
|
|
125
|
-
} else if (msg.method === 'tools/call') {
|
|
134
|
+
}
|
|
135
|
+
case 'ping': return ok({});
|
|
136
|
+
case 'tools/list': return ok({ tools: TOOLS });
|
|
137
|
+
case 'tools/call': {
|
|
126
138
|
const { name, arguments: a } = msg.params || {};
|
|
127
|
-
|
|
128
|
-
|
|
139
|
+
try {
|
|
140
|
+
if (opts && opts.beforeCall) await opts.beforeCall(name, a || {});
|
|
141
|
+
const r = await callTool(name, a, opts);
|
|
142
|
+
const isErr = r.status >= 400;
|
|
129
143
|
// MCP expects content[]; surface status + body, and mark 402 as error with actionable text
|
|
130
144
|
const text = typeof r.body === 'string' ? r.body : JSON.stringify(r.body, null, 2);
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
replyErr(msg.id, -32601, 'method not found: ' + msg.method);
|
|
145
|
+
return ok({ content: [{ type: 'text', text: (isErr ? 'HTTP ' + r.status + '\n' : '') + text }], isError: isErr });
|
|
146
|
+
} catch (e) {
|
|
147
|
+
return ok({ content: [{ type: 'text', text: 'error: ' + e.message }], isError: true });
|
|
148
|
+
}
|
|
136
149
|
}
|
|
150
|
+
default:
|
|
151
|
+
if (msg.method.startsWith('notifications/')) return null;
|
|
152
|
+
return isNotification ? null : { jsonrpc: '2.0', id: msg.id, error: { code: -32601, message: 'method not found: ' + msg.method } };
|
|
137
153
|
}
|
|
138
|
-
}
|
|
139
|
-
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function startStdio() {
|
|
157
|
+
let buf = '';
|
|
158
|
+
process.stdin.on('data', (chunk) => {
|
|
159
|
+
buf += chunk.toString('utf8');
|
|
160
|
+
let nl;
|
|
161
|
+
while ((nl = buf.indexOf('\n')) >= 0) {
|
|
162
|
+
const line = buf.slice(0, nl).trim(); buf = buf.slice(nl + 1);
|
|
163
|
+
if (!line) continue;
|
|
164
|
+
let msg; try { msg = JSON.parse(line); } catch (e) { continue; }
|
|
165
|
+
handleMessage(msg).then((r) => { if (r) process.stdout.write(JSON.stringify(r) + '\n'); });
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
process.stdin.on('end', () => process.exit(0));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
module.exports = { TOOLS, callTool, handleMessage, startStdio };
|
|
172
|
+
|
|
173
|
+
if (require.main === module) startStdio();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@celorodrigues/x402-conformance",
|
|
3
|
-
"version": "2.1
|
|
3
|
+
"version": "2.2.1",
|
|
4
4
|
"description": "Zero-dependency CLI, MCP server and testing suite for x402 AI agent micropayment protocol & EIP-3009 compliance",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
"index.js",
|
|
11
11
|
"mcp-server.js",
|
|
12
|
+
"tx-simulator.js",
|
|
12
13
|
"bin/",
|
|
13
14
|
"README.md",
|
|
14
15
|
"LICENSE",
|
|
@@ -37,7 +38,7 @@
|
|
|
37
38
|
"type": "git",
|
|
38
39
|
"url": "git+https://github.com/baianomarceloeduardo-jpg/automaton-x402.git"
|
|
39
40
|
},
|
|
40
|
-
"homepage": "https://
|
|
41
|
+
"homepage": "https://automaton-api.bfzovw.easypanel.host/leaderboard",
|
|
41
42
|
"bugs": {
|
|
42
43
|
"url": "https://github.com/baianomarceloeduardo-jpg/automaton-x402/issues"
|
|
43
44
|
},
|
package/smithery.yaml
CHANGED
|
@@ -7,20 +7,20 @@ startCommand:
|
|
|
7
7
|
properties:
|
|
8
8
|
valueApiBase:
|
|
9
9
|
type: string
|
|
10
|
-
default: https://
|
|
10
|
+
default: https://automaton-api.bfzovw.easypanel.host
|
|
11
11
|
description: Base URL of the Automaton-Sovereign Value API used by the paid/free API tools.
|
|
12
12
|
valueApiPayment:
|
|
13
13
|
type: string
|
|
14
|
-
description: Optional x402 payment (Base USDC tx hash) attached
|
|
14
|
+
description: Optional x402 payment (Base USDC tx hash or EIP-3009 authorization) attached to paid tool calls.
|
|
15
15
|
commandFunction:
|
|
16
16
|
|-
|
|
17
17
|
(config) => ({
|
|
18
18
|
command: 'npx',
|
|
19
|
-
args: ['-y', '@celorodrigues/x402-conformance', '--mcp'],
|
|
19
|
+
args: ['-y', '@celorodrigues/x402-conformance@2.2.0', '--mcp'],
|
|
20
20
|
env: Object.assign(
|
|
21
|
-
{ VALUE_API_BASE: config.valueApiBase || 'https://
|
|
21
|
+
{ VALUE_API_BASE: config.valueApiBase || 'https://automaton-api.bfzovw.easypanel.host' },
|
|
22
22
|
config.valueApiPayment ? { VALUE_API_PAYMENT: config.valueApiPayment } : {}
|
|
23
23
|
)
|
|
24
24
|
})
|
|
25
25
|
exampleConfig:
|
|
26
|
-
valueApiBase: https://
|
|
26
|
+
valueApiBase: https://automaton-api.bfzovw.easypanel.host
|
package/tx-simulator.js
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* Base Mainnet transaction simulator (zero deps).
|
|
4
|
+
*
|
|
5
|
+
* simulate({ to, data, value, from }) runs eth_call + eth_estimateGas against a Base RPC at a
|
|
6
|
+
* pinned block and predicts whether the tx will revert, decoding Error(string) (0x08c379a0),
|
|
7
|
+
* Panic(uint256) (0x4e487b71) and surfacing custom-error selectors.
|
|
8
|
+
*
|
|
9
|
+
* Returns { ok, willRevert, revertReason, estimatedGas, returnData, ... }.
|
|
10
|
+
* ok true when the simulation ran (RPC answered); false on bad input / RPC failure.
|
|
11
|
+
* willRevert true when the call reverts or the node rejects it (e.g. insufficient funds).
|
|
12
|
+
*/
|
|
13
|
+
const https = require('https');
|
|
14
|
+
const http = require('http');
|
|
15
|
+
const { URL } = require('url');
|
|
16
|
+
|
|
17
|
+
const DEFAULT_RPC = process.env.SIM_RPC_URL || process.env.BASE_RPC_URL || 'https://mainnet.base.org';
|
|
18
|
+
const MAX_DATA_BYTES = 65536; // well above any real tx; bounds upstream work
|
|
19
|
+
const MAX_DATA_HEX = 2 + MAX_DATA_BYTES * 2;
|
|
20
|
+
const MAX_RPC_RESPONSE = 2 * 1024 * 1024; // cap memory per upstream response
|
|
21
|
+
const BASE_CHAIN_ID = '0x2105'; // 8453
|
|
22
|
+
|
|
23
|
+
const PANIC_CODES = {
|
|
24
|
+
0x00: 'generic compiler panic',
|
|
25
|
+
0x01: 'assertion failed',
|
|
26
|
+
0x11: 'arithmetic overflow or underflow',
|
|
27
|
+
0x12: 'division or modulo by zero',
|
|
28
|
+
0x21: 'invalid enum value',
|
|
29
|
+
0x22: 'corrupted storage byte array',
|
|
30
|
+
0x31: 'pop() on empty array',
|
|
31
|
+
0x32: 'array index out of bounds',
|
|
32
|
+
0x41: 'out of memory / allocation too large',
|
|
33
|
+
0x51: 'call to zero-initialized function pointer'
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
function rpcCall(rpcUrl, method, params) {
|
|
37
|
+
return new Promise((resolve, reject) => {
|
|
38
|
+
const u = new URL(rpcUrl);
|
|
39
|
+
const lib = u.protocol === 'http:' ? http : https;
|
|
40
|
+
const payload = JSON.stringify({ jsonrpc: '2.0', id: 1, method, params });
|
|
41
|
+
const req = lib.request({ hostname: u.hostname, port: u.port || (u.protocol === 'http:' ? 80 : 443), path: u.pathname + u.search, method: 'POST',
|
|
42
|
+
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload), 'User-Agent': 'automaton-simulator/1.0' } }, (res) => {
|
|
43
|
+
let d = '';
|
|
44
|
+
res.on('data', (c) => { d += c; if (d.length > MAX_RPC_RESPONSE) req.destroy(new Error('rpc_response_too_large')); });
|
|
45
|
+
res.on('end', () => {
|
|
46
|
+
let j; try { j = JSON.parse(d); } catch (e) { return reject(new Error('bad_rpc_response (HTTP ' + res.statusCode + ')')); }
|
|
47
|
+
resolve(j); // caller inspects j.result / j.error (a revert arrives as j.error)
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
req.on('error', reject);
|
|
51
|
+
req.setTimeout(15000, () => req.destroy(new Error('rpc_timeout')));
|
|
52
|
+
req.write(payload); req.end();
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isAddr(a) { return typeof a === 'string' && /^0x[0-9a-fA-F]{40}$/.test(a); }
|
|
57
|
+
|
|
58
|
+
// Accepts wei as decimal string/number, 0x-hex, or bigint. Returns a JSON-RPC quantity.
|
|
59
|
+
function toQuantity(v) {
|
|
60
|
+
if (v === undefined || v === null || v === '' || v === 0 || v === '0') return '0x0';
|
|
61
|
+
let n;
|
|
62
|
+
if (typeof v === 'bigint') n = v;
|
|
63
|
+
else if (typeof v === 'number') { if (!Number.isSafeInteger(v) || v < 0) throw new Error('value must be a non-negative integer (wei)'); n = BigInt(v); }
|
|
64
|
+
else if (typeof v === 'string' && /^0x[0-9a-fA-F]+$/.test(v)) n = BigInt(v);
|
|
65
|
+
else if (typeof v === 'string' && /^\d+$/.test(v)) n = BigInt(v);
|
|
66
|
+
else throw new Error('value must be wei as a decimal or 0x-hex integer');
|
|
67
|
+
if (n < 0n) throw new Error('value must be non-negative');
|
|
68
|
+
return '0x' + n.toString(16);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function decodeRevert(hex) {
|
|
72
|
+
const data = typeof hex === 'string' ? hex.toLowerCase() : '';
|
|
73
|
+
if (!data || data === '0x') return { errorType: 'empty', reason: 'reverted without a reason' };
|
|
74
|
+
const sel = data.slice(0, 10);
|
|
75
|
+
const body = data.slice(10);
|
|
76
|
+
try {
|
|
77
|
+
if (sel === '0x08c379a0' && body.length >= 128) {
|
|
78
|
+
const off = Number(BigInt('0x' + body.slice(0, 64))) * 2;
|
|
79
|
+
const len = Number(BigInt('0x' + body.slice(off, off + 64)));
|
|
80
|
+
const str = Buffer.from(body.slice(off + 64, off + 64 + len * 2), 'hex').toString('utf8');
|
|
81
|
+
return { errorType: 'Error(string)', reason: str };
|
|
82
|
+
}
|
|
83
|
+
if (sel === '0x4e487b71' && body.length >= 64) {
|
|
84
|
+
const code = Number(BigInt('0x' + body.slice(0, 64)));
|
|
85
|
+
return { errorType: 'Panic(uint256)', panicCode: '0x' + code.toString(16).padStart(2, '0'),
|
|
86
|
+
reason: 'Panic(0x' + code.toString(16).padStart(2, '0') + '): ' + (PANIC_CODES[code] || 'unknown panic code') };
|
|
87
|
+
}
|
|
88
|
+
} catch (e) { /* fall through to raw selector */ }
|
|
89
|
+
return { errorType: 'custom', selector: sel, reason: 'custom error ' + sel + ' (not ABI-decoded)' };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Node errors carry revert data in error.data (string) or error.data.data depending on client.
|
|
93
|
+
function revertDataOf(err) {
|
|
94
|
+
if (!err) return null;
|
|
95
|
+
const d = err.data;
|
|
96
|
+
if (typeof d === 'string' && /^0x[0-9a-fA-F]*$/.test(d)) return d;
|
|
97
|
+
if (d && typeof d === 'object' && typeof d.data === 'string') return d.data;
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// JSON-RPC error -> 'revert' (execution reverted), 'rejected' (node refuses the tx itself:
|
|
102
|
+
// funds, gas, nonce) or 'transient' (rate limit, capacity, internal: says nothing about the tx).
|
|
103
|
+
function classify(err) {
|
|
104
|
+
if (!err) return null;
|
|
105
|
+
if (revertDataOf(err)) return 'revert';
|
|
106
|
+
const msg = String(err.message || '');
|
|
107
|
+
if (err.code === 3 || /revert/i.test(msg)) return 'revert';
|
|
108
|
+
if (/insufficient funds|OutOfFunds|gas required exceeds|intrinsic gas|gas too low|exceeds block gas limit|nonce|invalid opcode|out of gas|stack (over|under)flow|InvalidFEOpcode|OpcodeNotFound/i.test(msg)) return 'rejected';
|
|
109
|
+
return 'transient';
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Returns an error string for unusable input, or null. Pure and cheap: callers run it before
|
|
113
|
+
// charging a payment or spending an RPC call.
|
|
114
|
+
function validate(input) {
|
|
115
|
+
input = input || {};
|
|
116
|
+
if (!isAddr(input.to)) return 'to must be a 0x-prefixed 20-byte address';
|
|
117
|
+
if (input.from !== undefined && input.from !== null && input.from !== '' && !isAddr(input.from)) return 'from must be a 0x-prefixed 20-byte address';
|
|
118
|
+
const data = input.data === undefined || input.data === null || input.data === '' ? '0x' : input.data;
|
|
119
|
+
if (typeof data !== 'string') return 'data must be a 0x-hex string';
|
|
120
|
+
if (data.length > MAX_DATA_HEX) return 'calldata too large (max ' + MAX_DATA_BYTES + ' bytes)';
|
|
121
|
+
if (!/^0x([0-9a-fA-F]{2})*$/.test(data)) return 'data must be 0x-prefixed even-length hex';
|
|
122
|
+
try { toQuantity(input.value); } catch (e) { return e.message; }
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Process-wide limiter: the free MCP tool must not turn this host into an open Base RPC proxy
|
|
127
|
+
// (each simulation costs 4 upstream calls, and the same public RPC verifies our payments).
|
|
128
|
+
const RATE_PER_MIN = parseInt(process.env.SIM_RATE_PER_MIN || '120', 10);
|
|
129
|
+
const MAX_CONCURRENT = parseInt(process.env.SIM_MAX_CONCURRENT || '8', 10);
|
|
130
|
+
let tokens = RATE_PER_MIN, refillAt = Date.now(), inFlight = 0;
|
|
131
|
+
function acquire() {
|
|
132
|
+
const now = Date.now();
|
|
133
|
+
tokens = Math.min(RATE_PER_MIN, tokens + ((now - refillAt) / 60000) * RATE_PER_MIN);
|
|
134
|
+
refillAt = now;
|
|
135
|
+
if (inFlight >= MAX_CONCURRENT) return 'rate_limited: too many concurrent simulations, retry shortly';
|
|
136
|
+
if (tokens < 1) return 'rate_limited: simulation quota exceeded (' + RATE_PER_MIN + '/min), retry shortly';
|
|
137
|
+
tokens -= 1; inFlight++;
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function simulate(input, opts) {
|
|
142
|
+
input = input || {};
|
|
143
|
+
opts = opts || {};
|
|
144
|
+
const out = { ok: false, network: 'base', chainId: 8453, willRevert: null, revertReason: null, estimatedGas: null, returnData: null };
|
|
145
|
+
const invalid = validate(input);
|
|
146
|
+
if (invalid) { out.error = invalid; return out; }
|
|
147
|
+
// Paid HTTP calls (already metered by x402) skip the free-tier limiter but not the concurrency cap.
|
|
148
|
+
if (!opts.bypassLimit) {
|
|
149
|
+
const limited = acquire();
|
|
150
|
+
if (limited) { out.error = limited; out.rateLimited = true; return out; }
|
|
151
|
+
} else inFlight++;
|
|
152
|
+
try { return await run(input, opts, out); } finally { inFlight--; }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function run(input, opts, out) {
|
|
156
|
+
const rpcUrl = opts.rpcUrl || DEFAULT_RPC;
|
|
157
|
+
const data = input.data === undefined || input.data === null || input.data === '' ? '0x' : input.data;
|
|
158
|
+
const value = toQuantity(input.value);
|
|
159
|
+
|
|
160
|
+
const tx = { to: input.to, data, value };
|
|
161
|
+
if (input.from) tx.from = input.from;
|
|
162
|
+
|
|
163
|
+
let chain, head;
|
|
164
|
+
try {
|
|
165
|
+
[chain, head] = await Promise.all([rpcCall(rpcUrl, 'eth_chainId', []), rpcCall(rpcUrl, 'eth_blockNumber', [])]);
|
|
166
|
+
} catch (e) { out.error = 'rpc_unreachable: ' + e.message; return out; }
|
|
167
|
+
if (!chain.result || String(chain.result).toLowerCase() !== BASE_CHAIN_ID) { out.error = 'rpc is not Base Mainnet (chainId ' + (chain.result || '?') + ')'; return out; }
|
|
168
|
+
if (!head.result) { out.error = 'rpc_error: ' + ((head.error && head.error.message) || 'no block number'); return out; }
|
|
169
|
+
const block = head.result;
|
|
170
|
+
out.blockNumber = parseInt(block, 16);
|
|
171
|
+
out.tx = tx;
|
|
172
|
+
|
|
173
|
+
let call, gas;
|
|
174
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
175
|
+
try {
|
|
176
|
+
[call, gas] = await Promise.all([rpcCall(rpcUrl, 'eth_call', [tx, block]), rpcCall(rpcUrl, 'eth_estimateGas', [tx, block])]);
|
|
177
|
+
} catch (e) { out.error = 'rpc_unreachable: ' + e.message; return out; }
|
|
178
|
+
// Rate limits / capacity errors are not reverts: retry once, then report ok:false.
|
|
179
|
+
if (classify(call.error) !== 'transient' && classify(gas.error) !== 'transient') break;
|
|
180
|
+
if (attempt === 0) await new Promise((r) => setTimeout(r, 400));
|
|
181
|
+
}
|
|
182
|
+
if (classify(call.error) === 'transient') {
|
|
183
|
+
out.error = 'rpc_error: ' + String(call.error.message || call.error.code || 'unknown').slice(0, 200);
|
|
184
|
+
return out;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
out.ok = true;
|
|
188
|
+
if (call.error) {
|
|
189
|
+
const rd = revertDataOf(call.error);
|
|
190
|
+
out.willRevert = true;
|
|
191
|
+
out.returnData = rd;
|
|
192
|
+
if (rd) {
|
|
193
|
+
const dec = decodeRevert(rd);
|
|
194
|
+
out.revertReason = dec.reason; out.errorType = dec.errorType;
|
|
195
|
+
if (dec.panicCode) out.panicCode = dec.panicCode;
|
|
196
|
+
if (dec.selector) out.errorSelector = dec.selector;
|
|
197
|
+
} else {
|
|
198
|
+
// No revert payload: node-level rejection (insufficient funds, bad nonce, ...) or bare revert.
|
|
199
|
+
const msg = String(call.error.message || 'execution failed');
|
|
200
|
+
out.revertReason = msg;
|
|
201
|
+
out.errorType = /revert/i.test(msg) ? 'empty' : 'node_rejected';
|
|
202
|
+
}
|
|
203
|
+
} else {
|
|
204
|
+
out.willRevert = false;
|
|
205
|
+
out.returnData = call.result || '0x';
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (gas.result) out.estimatedGas = parseInt(gas.result, 16);
|
|
209
|
+
else if (gas.error) {
|
|
210
|
+
out.estimateGasError = String(gas.error.message || 'estimateGas failed');
|
|
211
|
+
// eth_call can pass while estimateGas fails (e.g. gas-dependent logic): still a revert on-chain.
|
|
212
|
+
// A transient RPC error on estimateGas alone says nothing about the tx, so it does not flip it.
|
|
213
|
+
if (out.willRevert === false && classify(gas.error) !== 'transient') {
|
|
214
|
+
out.willRevert = true;
|
|
215
|
+
const rd = revertDataOf(gas.error);
|
|
216
|
+
const dec = rd ? decodeRevert(rd) : null;
|
|
217
|
+
out.revertReason = dec ? dec.reason : out.estimateGasError;
|
|
218
|
+
out.errorType = dec ? dec.errorType : 'estimate_gas_failed';
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
out.success = out.willRevert === false;
|
|
222
|
+
out.simulatedAt = new Date().toISOString();
|
|
223
|
+
return out;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
module.exports = { simulate, validate, decodeRevert, toQuantity, PANIC_CODES };
|