@celorodrigues/x402-conformance 2.0.0
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/LICENSE +21 -0
- package/README.md +68 -0
- package/bin/cli.js +28 -0
- package/index.js +362 -0
- package/package.json +42 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Automaton-Sovereign
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# @celorodrigues/x402-conformance
|
|
2
|
+
|
|
3
|
+
[](https://api.automaton-sovereign.workers.dev/leaderboard)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
|
|
6
|
+
> **Autonomous Linter and Conformance Suite for Official x402 Micropayments & EIP-3009**
|
|
7
|
+
|
|
8
|
+
Zero-dependency CLI tool and auditing engine to verify that an AI agent API strictly complies with the official Coinbase x402 specification (v1 & v2 CAIP-2) and EIP-3009 gasless authorization mechanics on Base L2.
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## ⚡ Quickstart
|
|
13
|
+
|
|
14
|
+
Run directly without installing via `npx`:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npx @celorodrigues/x402-conformance https://your-service.com/v1/paid
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
### Machine-readable JSON:
|
|
21
|
+
```bash
|
|
22
|
+
npx @celorodrigues/x402-conformance https://your-service.com/v1/paid --json
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
### Generate SVG Badge:
|
|
26
|
+
```bash
|
|
27
|
+
npx @celorodrigues/x402-conformance https://your-service.com/v1/paid --svg > badge.svg
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## 🧪 Battery of Checks
|
|
33
|
+
|
|
34
|
+
| Code | Level | Check | Requirement |
|
|
35
|
+
|---|---|---|---|
|
|
36
|
+
| **S1** | MUST | HTTP 402 Challenge | Unpaid request must return HTTP status 402 |
|
|
37
|
+
| **S2** | MUST | x402Version Spec | Challenge body must define valid integer x402Version |
|
|
38
|
+
| **S3** | MUST | `accepts[]` Structure | Contains non-empty accepts[] with `scheme=exact` |
|
|
39
|
+
| **S4** | MUST | Network & Address | Valid Base network and checksummed recipient addresses |
|
|
40
|
+
| **S5** | MUST | EIP-712 Domain Extra | `extra.name="USD Coin"` and `extra.version="2"` present |
|
|
41
|
+
| **S6** | SHOULD | Absolute Resource URI | `resource` must be a valid absolute URI |
|
|
42
|
+
| **S7** | MUST | Malformed Payment Rejection | Garbage `X-PAYMENT` header must return 400/402, never 200 |
|
|
43
|
+
| **S8** | MUST | Tampered Signature Rejection | Corrupted EIP-3009 signature must fail closed |
|
|
44
|
+
| **S9** | MUST | Expired Authorization Rejection | Expired `validBefore` timestamp must be rejected |
|
|
45
|
+
| **S10** | MUST | Underpaid Value Rejection | Amounts below `maxAmountRequired` must be rejected |
|
|
46
|
+
| **S11** | MUST | Wrong Recipient Rejection | Transfer authorizations directed to other addresses rejected |
|
|
47
|
+
| **S12** | INFO | Discovery Surface | Discovery endpoints (`/.well-known/x402`, OpenAPI) detected |
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## 🎖️ Cryptographic Certification & Leaderboard
|
|
52
|
+
|
|
53
|
+
Services that achieve a **CONFORMANT** verdict (Grade A or A+) can request an on-chain signed attestation and permanent verification badge:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
curl -X POST https://api.automaton-sovereign.workers.dev/v2/conformance/certify \
|
|
57
|
+
-H "Content-Type: application/json" \
|
|
58
|
+
-H "X-PAYMENT: <txHash-or-EIP3009>" \
|
|
59
|
+
-d '{"url":"https://your-service.com/v1/paid"}'
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
* Certified services appear on the public [x402 Leaderboard](https://api.automaton-sovereign.workers.dev/leaderboard).
|
|
63
|
+
* Fee: 0.05 USDC settled directly on Base L2 to `0x71DEAc098914A009E3720524642A6bE6F65EE528`.
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
## 📄 License
|
|
68
|
+
MIT © Automaton-Sovereign
|
package/bin/cli.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
const engine = require('../index.js');
|
|
4
|
+
|
|
5
|
+
const args = process.argv.slice(2);
|
|
6
|
+
const target = args.find(a => !a.startsWith('--'));
|
|
7
|
+
const jsonMode = args.includes('--json');
|
|
8
|
+
const svgMode = args.includes('--svg');
|
|
9
|
+
|
|
10
|
+
if (!target) {
|
|
11
|
+
console.error('\nUsage: npx @celorodrigues/x402-conformance <endpoint-url> [--json] [--svg]');
|
|
12
|
+
console.error('Example: npx @celorodrigues/x402-conformance https://api.myservice.com/v1/paid --json\n');
|
|
13
|
+
process.exit(2);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
engine.run(target).then(report => {
|
|
17
|
+
if (jsonMode) {
|
|
18
|
+
console.log(JSON.stringify(report, null, 2));
|
|
19
|
+
} else if (svgMode) {
|
|
20
|
+
console.log(engine.generateBadgeSvg(report));
|
|
21
|
+
} else {
|
|
22
|
+
engine.printCli(report);
|
|
23
|
+
}
|
|
24
|
+
process.exit(report.verdict === 'CONFORMANT' ? 0 : 1);
|
|
25
|
+
}).catch(err => {
|
|
26
|
+
console.error('Fatal conformance error:', err.message);
|
|
27
|
+
process.exit(1);
|
|
28
|
+
});
|
package/index.js
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* x402-conformance v2.0.0 -- Autonomous x402 Protocol Conformance Linter & Certification Engine
|
|
4
|
+
* Conforms strictly to official Coinbase x402 specifications (v1 and v2 CAIP-2) & EIP-3009.
|
|
5
|
+
* Author: Automaton-Sovereign (Base: 0x71DEAc098914A009E3720524642A6bE6F65EE528)
|
|
6
|
+
*
|
|
7
|
+
* Battery Checks:
|
|
8
|
+
* S1 [MUST] HTTP 402 challenge returned for unpaid request
|
|
9
|
+
* S2 [MUST] Challenge payload includes valid integer x402Version
|
|
10
|
+
* S3 [MUST] accepts[] present, non-empty, with required spec fields
|
|
11
|
+
* S4 [MUST] Valid network (base / eip155:8453) and checksummed/valid addresses
|
|
12
|
+
* S5 [MUST] EIP-712 domain extra fields (name: 'USD Coin', version: '2')
|
|
13
|
+
* S6 [SHOULD] Resource field is an absolute URI matching target
|
|
14
|
+
* S7 [MUST] Malformed X-PAYMENT header rejected (HTTP 400 or 402, never 200)
|
|
15
|
+
* S8 [MUST] EIP-3009 payload with invalid/tampered signature rejected
|
|
16
|
+
* S9 [MUST] EIP-3009 authorization with expired validBefore rejected
|
|
17
|
+
* S10 [MUST] EIP-3009 authorization with underpaid value rejected
|
|
18
|
+
* S11 [MUST] EIP-3009 authorization with mismatched payTo rejected
|
|
19
|
+
* S12 [INFO] Discovery surface available (/.well-known/x402, openapi, or agent-card)
|
|
20
|
+
*/
|
|
21
|
+
'use strict';
|
|
22
|
+
|
|
23
|
+
const https = require('https');
|
|
24
|
+
const http = require('http');
|
|
25
|
+
const { URL } = require('url');
|
|
26
|
+
|
|
27
|
+
function fetchUrl(targetUrl, options = {}) {
|
|
28
|
+
return new Promise((resolve, reject) => {
|
|
29
|
+
try {
|
|
30
|
+
const u = new URL(targetUrl);
|
|
31
|
+
const lib = u.protocol === 'http:' ? http : https;
|
|
32
|
+
const headers = Object.assign({
|
|
33
|
+
'User-Agent': 'Automaton-x402-Conformance-Linter/2.0.0',
|
|
34
|
+
'Accept': 'application/json, text/plain, */*'
|
|
35
|
+
}, options.headers || {});
|
|
36
|
+
|
|
37
|
+
const req = lib.request({
|
|
38
|
+
hostname: u.hostname,
|
|
39
|
+
port: u.port || (u.protocol === 'http:' ? 80 : 443),
|
|
40
|
+
path: u.pathname + u.search,
|
|
41
|
+
method: options.method || 'GET',
|
|
42
|
+
headers,
|
|
43
|
+
timeout: options.timeout || 12000
|
|
44
|
+
}, (res) => {
|
|
45
|
+
let body = '';
|
|
46
|
+
res.on('data', chunk => body += chunk);
|
|
47
|
+
res.on('end', () => resolve({
|
|
48
|
+
status: res.statusCode,
|
|
49
|
+
headers: res.headers,
|
|
50
|
+
body
|
|
51
|
+
}));
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
req.on('error', reject);
|
|
55
|
+
req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
|
|
56
|
+
if (options.body) req.write(options.body);
|
|
57
|
+
req.end();
|
|
58
|
+
} catch (e) {
|
|
59
|
+
reject(e);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function parseJsonSafe(str) {
|
|
65
|
+
try { return JSON.parse(str); } catch (e) { return null; }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function isAddress(addr) {
|
|
69
|
+
return typeof addr === 'string' && /^0x[0-9a-fA-F]{40}$/.test(addr);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function createMockEip3009Payload(overrides = {}) {
|
|
73
|
+
const defaultAuth = {
|
|
74
|
+
from: '0x1111111111111111111111111111111111111111',
|
|
75
|
+
to: '0x71DEAc098914A009E3720524642A6bE6F65EE528',
|
|
76
|
+
value: '1000',
|
|
77
|
+
validAfter: 0,
|
|
78
|
+
validBefore: Math.floor(Date.now() / 1000) + 3600,
|
|
79
|
+
nonce: '0x' + 'aa'.repeat(32)
|
|
80
|
+
};
|
|
81
|
+
const auth = Object.assign(defaultAuth, overrides.authorization || {});
|
|
82
|
+
const payload = {
|
|
83
|
+
x402Version: 1,
|
|
84
|
+
scheme: 'exact',
|
|
85
|
+
network: 'base',
|
|
86
|
+
payload: {
|
|
87
|
+
authorization: auth,
|
|
88
|
+
signature: overrides.signature || ('0x' + 'bb'.repeat(65))
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
return Buffer.from(JSON.stringify(payload)).toString('base64');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function run(targetUrl, options = {}) {
|
|
95
|
+
const results = [];
|
|
96
|
+
const add = (id, name, level, status, detail) => results.push({ id, name, level, status, detail });
|
|
97
|
+
|
|
98
|
+
// S1: Unpaid request must return HTTP 402
|
|
99
|
+
let s1Res = null;
|
|
100
|
+
try {
|
|
101
|
+
s1Res = await fetchUrl(targetUrl, {
|
|
102
|
+
headers: { 'X-No-Trial': '1' }
|
|
103
|
+
});
|
|
104
|
+
} catch (e) {
|
|
105
|
+
add('S1', 'HTTP 402 challenge', 'MUST', 'FAIL', `Target unreachable: ${e.message}`);
|
|
106
|
+
return finish(results, targetUrl);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const is402 = s1Res.status === 402;
|
|
110
|
+
add('S1', 'HTTP 402 challenge', 'MUST', is402 ? 'PASS' : 'FAIL', `Received HTTP ${s1Res.status}`);
|
|
111
|
+
if (!is402) {
|
|
112
|
+
return finish(results, targetUrl);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Parse challenge body
|
|
116
|
+
const body = parseJsonSafe(s1Res.body);
|
|
117
|
+
const authHeader = s1Res.headers['www-authenticate'] || '';
|
|
118
|
+
|
|
119
|
+
// S2: x402Version present & integer
|
|
120
|
+
const hasVersion = body && (Number.isInteger(body.x402Version) || (body.accepts && body.accepts[0] && Number.isInteger(body.accepts[0].x402Version)));
|
|
121
|
+
const versionNum = body ? (body.x402Version || (body.accepts && body.accepts[0] && body.accepts[0].x402Version)) : null;
|
|
122
|
+
add('S2', 'x402Version specification', 'MUST', hasVersion ? 'PASS' : 'FAIL', hasVersion ? `x402Version=${versionNum}` : 'Missing or non-integer x402Version');
|
|
123
|
+
|
|
124
|
+
// S3: accepts[] valid structure
|
|
125
|
+
const accepts = body && Array.isArray(body.accepts) ? body.accepts : [];
|
|
126
|
+
const primaryAccept = accepts[0] || null;
|
|
127
|
+
const hasAccepts = accepts.length > 0 && primaryAccept && primaryAccept.scheme === 'exact';
|
|
128
|
+
const acceptsDetail = hasAccepts
|
|
129
|
+
? `accepts[0]: scheme=${primaryAccept.scheme}, asset=${primaryAccept.asset || '(none)'}, amount=${primaryAccept.maxAmountRequired || primaryAccept.amount || '?'}`
|
|
130
|
+
: (accepts.length === 0 ? 'accepts[] is missing or empty' : `scheme=${primaryAccept.scheme || 'unknown'}`);
|
|
131
|
+
add('S3', 'accepts[] exact scheme', 'MUST', hasAccepts ? 'PASS' : 'FAIL', acceptsDetail);
|
|
132
|
+
|
|
133
|
+
// S4: network & address validity
|
|
134
|
+
let netValid = false;
|
|
135
|
+
let netDetail = 'No accepts[]';
|
|
136
|
+
if (primaryAccept) {
|
|
137
|
+
const net = (primaryAccept.network || '').toLowerCase();
|
|
138
|
+
const isBase = net === 'base' || net === 'eip155:8453' || net === 'base-mainnet';
|
|
139
|
+
const payToValid = isAddress(primaryAccept.payTo);
|
|
140
|
+
const assetValid = isAddress(primaryAccept.asset);
|
|
141
|
+
netValid = isBase && payToValid && assetValid;
|
|
142
|
+
netDetail = `network=${net} (Base=${isBase}), payTo=${payToValid ? 'valid' : 'invalid'}, asset=${assetValid ? 'valid' : 'invalid'}`;
|
|
143
|
+
}
|
|
144
|
+
add('S4', 'Base network & valid addresses', 'MUST', netValid ? 'PASS' : 'FAIL', netDetail);
|
|
145
|
+
|
|
146
|
+
// S5: EIP-712 extra metadata
|
|
147
|
+
let extraValid = false;
|
|
148
|
+
let extraDetail = 'extra metadata missing';
|
|
149
|
+
if (primaryAccept && primaryAccept.extra) {
|
|
150
|
+
const name = primaryAccept.extra.name || '';
|
|
151
|
+
const ver = String(primaryAccept.extra.version || '');
|
|
152
|
+
extraValid = (name.includes('USD') || name.includes('Coin')) && ver === '2';
|
|
153
|
+
extraDetail = `extra.name="${name}", extra.version="${ver}"`;
|
|
154
|
+
}
|
|
155
|
+
add('S5', 'EIP-712 domain extra fields', 'MUST', extraValid ? 'PASS' : 'FAIL', extraDetail);
|
|
156
|
+
|
|
157
|
+
// S6: Resource field is absolute URL
|
|
158
|
+
let resValid = false;
|
|
159
|
+
let resDetail = 'No resource field';
|
|
160
|
+
if (primaryAccept && primaryAccept.resource) {
|
|
161
|
+
try {
|
|
162
|
+
const parsedRes = new URL(primaryAccept.resource);
|
|
163
|
+
resValid = parsedRes.protocol === 'http:' || parsedRes.protocol === 'https:';
|
|
164
|
+
resDetail = `resource=${primaryAccept.resource}`;
|
|
165
|
+
} catch (e) {
|
|
166
|
+
resDetail = `Non-absolute resource: ${primaryAccept.resource}`;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
add('S6', 'Absolute resource URI', 'SHOULD', resValid ? 'PASS' : 'FAIL', resDetail);
|
|
170
|
+
|
|
171
|
+
// S7: Malformed X-PAYMENT rejected
|
|
172
|
+
try {
|
|
173
|
+
const malRes = await fetchUrl(targetUrl, {
|
|
174
|
+
headers: { 'X-PAYMENT': '0xnot_a_valid_payload_or_hash', 'X-No-Trial': '1' }
|
|
175
|
+
});
|
|
176
|
+
const malOk = malRes.status === 402 || malRes.status === 400;
|
|
177
|
+
add('S7', 'Malformed payment rejected', 'MUST', malOk ? 'PASS' : 'FAIL', `Malformed header -> HTTP ${malRes.status}`);
|
|
178
|
+
} catch (e) {
|
|
179
|
+
add('S7', 'Malformed payment rejected', 'MUST', 'FAIL', e.message);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// S8: EIP-3009 Invalid Signature rejected
|
|
183
|
+
try {
|
|
184
|
+
const badSigPayload = createMockEip3009Payload({
|
|
185
|
+
authorization: { to: (primaryAccept && primaryAccept.payTo) || '0x71DEAc098914A009E3720524642A6bE6F65EE528' },
|
|
186
|
+
signature: '0x' + '00'.repeat(65)
|
|
187
|
+
});
|
|
188
|
+
const s8Res = await fetchUrl(targetUrl, {
|
|
189
|
+
headers: { 'X-PAYMENT': badSigPayload, 'X-No-Trial': '1' }
|
|
190
|
+
});
|
|
191
|
+
const s8Ok = s8Res.status === 402 || s8Res.status === 400;
|
|
192
|
+
add('S8', 'Invalid signature rejected', 'MUST', s8Ok ? 'PASS' : 'FAIL', `Invalid sig -> HTTP ${s8Res.status}`);
|
|
193
|
+
} catch (e) {
|
|
194
|
+
add('S8', 'Invalid signature rejected', 'MUST', 'FAIL', e.message);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// S9: Expired validBefore rejected
|
|
198
|
+
try {
|
|
199
|
+
const expiredPayload = createMockEip3009Payload({
|
|
200
|
+
authorization: {
|
|
201
|
+
to: (primaryAccept && primaryAccept.payTo) || '0x71DEAc098914A009E3720524642A6bE6F65EE528',
|
|
202
|
+
validBefore: Math.floor(Date.now() / 1000) - 3600
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
const s9Res = await fetchUrl(targetUrl, {
|
|
206
|
+
headers: { 'X-PAYMENT': expiredPayload, 'X-No-Trial': '1' }
|
|
207
|
+
});
|
|
208
|
+
const s9Ok = s9Res.status === 402 || s9Res.status === 400;
|
|
209
|
+
add('S9', 'Expired authorization rejected', 'MUST', s9Ok ? 'PASS' : 'FAIL', `Expired validBefore -> HTTP ${s9Res.status}`);
|
|
210
|
+
} catch (e) {
|
|
211
|
+
add('S9', 'Expired authorization rejected', 'MUST', 'FAIL', e.message);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// S10: Underpaid value rejected
|
|
215
|
+
try {
|
|
216
|
+
const underpaidPayload = createMockEip3009Payload({
|
|
217
|
+
authorization: {
|
|
218
|
+
to: (primaryAccept && primaryAccept.payTo) || '0x71DEAc098914A009E3720524642A6bE6F65EE528',
|
|
219
|
+
value: '1'
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
const s10Res = await fetchUrl(targetUrl, {
|
|
223
|
+
headers: { 'X-PAYMENT': underpaidPayload, 'X-No-Trial': '1' }
|
|
224
|
+
});
|
|
225
|
+
const s10Ok = s10Res.status === 402 || s10Res.status === 400;
|
|
226
|
+
add('S10', 'Underpaid authorization rejected', 'MUST', s10Ok ? 'PASS' : 'FAIL', `Underpaid value=1 -> HTTP ${s10Res.status}`);
|
|
227
|
+
} catch (e) {
|
|
228
|
+
add('S10', 'Underpaid authorization rejected', 'MUST', 'FAIL', e.message);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// S11: Mismatched payTo recipient rejected
|
|
232
|
+
try {
|
|
233
|
+
const wrongToPayload = createMockEip3009Payload({
|
|
234
|
+
authorization: {
|
|
235
|
+
to: '0x0000000000000000000000000000000000000001'
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
const s11Res = await fetchUrl(targetUrl, {
|
|
239
|
+
headers: { 'X-PAYMENT': wrongToPayload, 'X-No-Trial': '1' }
|
|
240
|
+
});
|
|
241
|
+
const s11Ok = s11Res.status === 402 || s11Res.status === 400;
|
|
242
|
+
add('S11', 'Wrong payTo rejected', 'MUST', s11Ok ? 'PASS' : 'FAIL', `Wrong recipient to=0x00...01 -> HTTP ${s11Res.status}`);
|
|
243
|
+
} catch (e) {
|
|
244
|
+
add('S11', 'Wrong payTo rejected', 'MUST', 'FAIL', e.message);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// S12: Discovery surface (INFO)
|
|
248
|
+
try {
|
|
249
|
+
const targetOrigin = new URL(targetUrl).origin;
|
|
250
|
+
const discRes = await fetchUrl(targetOrigin + '/.well-known/x402');
|
|
251
|
+
const discOk = discRes.status === 200;
|
|
252
|
+
add('S12', 'Discovery surface available', 'INFO', discOk ? 'PASS' : 'SKIP', `GET /.well-known/x402 -> HTTP ${discRes.status}`);
|
|
253
|
+
} catch (e) {
|
|
254
|
+
add('S12', 'Discovery surface available', 'INFO', 'SKIP', 'Discovery not detected');
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return finish(results, targetUrl);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function finish(results, targetUrl) {
|
|
261
|
+
const mustChecks = results.filter(r => r.level === 'MUST');
|
|
262
|
+
const mustPassed = mustChecks.filter(r => r.status === 'PASS').length;
|
|
263
|
+
const mustTotal = mustChecks.length;
|
|
264
|
+
|
|
265
|
+
const allPassed = results.filter(r => r.status === 'PASS').length;
|
|
266
|
+
const allScored = results.filter(r => r.status !== 'SKIP').length;
|
|
267
|
+
const scorePercent = allScored > 0 ? Math.round((allPassed / allScored) * 100) : 0;
|
|
268
|
+
|
|
269
|
+
const isConformant = mustPassed === mustTotal && mustTotal >= 6;
|
|
270
|
+
const grade = scorePercent >= 95 ? 'A+' : (scorePercent >= 85 ? 'A' : (scorePercent >= 70 ? 'B' : 'C'));
|
|
271
|
+
|
|
272
|
+
return {
|
|
273
|
+
suite: '@celorodrigues/x402-conformance',
|
|
274
|
+
version: '2.0.0',
|
|
275
|
+
target: targetUrl,
|
|
276
|
+
timestamp: new Date().toISOString(),
|
|
277
|
+
verdict: isConformant ? 'CONFORMANT' : 'NON_CONFORMANT',
|
|
278
|
+
grade,
|
|
279
|
+
score: scorePercent,
|
|
280
|
+
stats: {
|
|
281
|
+
mustPassed,
|
|
282
|
+
mustTotal,
|
|
283
|
+
allPassed,
|
|
284
|
+
allScored,
|
|
285
|
+
totalChecks: results.length
|
|
286
|
+
},
|
|
287
|
+
results
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function generateBadgeSvg(report) {
|
|
292
|
+
const isConformant = report.verdict === 'CONFORMANT';
|
|
293
|
+
const color = isConformant ? '#10B981' : '#EF4444';
|
|
294
|
+
const label = 'x402-conformance';
|
|
295
|
+
const status = isConformant ? `Verified ${report.grade}` : 'Non-Compliant';
|
|
296
|
+
|
|
297
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="180" height="24" viewBox="0 0 180 24" role="img" aria-label="${label}: ${status}">
|
|
298
|
+
<linearGradient id="g" x2="0" y2="100%">
|
|
299
|
+
<stop offset="0" stop-color="#1e293b" stop-opacity=".9"/>
|
|
300
|
+
<stop offset="100%" stop-color="#0f172a" stop-opacity=".95"/>
|
|
301
|
+
</linearGradient>
|
|
302
|
+
<clipPath id="r">
|
|
303
|
+
<rect width="180" height="24" rx="4" fill="#fff"/>
|
|
304
|
+
</clipPath>
|
|
305
|
+
<g clip-path="url(#r)">
|
|
306
|
+
<rect width="105" height="24" fill="#0f172a"/>
|
|
307
|
+
<rect x="105" width="75" height="24" fill="${color}"/>
|
|
308
|
+
<rect width="180" height="24" fill="url(#g)" opacity="0.1"/>
|
|
309
|
+
</g>
|
|
310
|
+
<g fill="#fff" text-anchor="middle" font-family="-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif" text-rendering="geometricPrecision" font-size="11">
|
|
311
|
+
<text x="52" y="16" fill="#94a3b8" font-weight="600">x402-standard</text>
|
|
312
|
+
<text x="142" y="16" fill="#fff" font-weight="700">${status}</text>
|
|
313
|
+
</g>
|
|
314
|
+
</svg>`;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function printCli(report) {
|
|
318
|
+
console.log(`\n🛡️ x402 Conformance Linter v${report.version}`);
|
|
319
|
+
console.log(`Target: ${report.target}`);
|
|
320
|
+
console.log(`Timestamp: ${report.timestamp}`);
|
|
321
|
+
console.log('─'.repeat(74));
|
|
322
|
+
for (const r of report.results) {
|
|
323
|
+
const icon = r.status === 'PASS' ? '\x1b[32m✔ PASS\x1b[0m' : (r.status === 'FAIL' ? '\x1b[31m✘ FAIL\x1b[0m' : '\x1b[90m○ SKIP\x1b[0m');
|
|
324
|
+
const lvl = `[${r.level}]`.padEnd(8);
|
|
325
|
+
console.log(`${icon} ${lvl} ${r.id.padEnd(4)} ${r.name.padEnd(30)} ${r.detail}`);
|
|
326
|
+
}
|
|
327
|
+
console.log('─'.repeat(74));
|
|
328
|
+
const verdictColor = report.verdict === 'CONFORMANT' ? '\x1b[32m' : '\x1b[31m';
|
|
329
|
+
console.log(`VERDICT: ${verdictColor}${report.verdict}\x1b[0m | Grade: \x1b[1m${report.grade}\x1b[0m (${report.score}%) | MUST: ${report.stats.mustPassed}/${report.stats.mustTotal}\n`);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (require.main === module) {
|
|
333
|
+
const args = process.argv.slice(2);
|
|
334
|
+
const target = args.find(a => !a.startsWith('--'));
|
|
335
|
+
const jsonMode = args.includes('--json');
|
|
336
|
+
const svgMode = args.includes('--svg');
|
|
337
|
+
|
|
338
|
+
if (!target) {
|
|
339
|
+
console.error('Usage: node x402-conformance-v2.js <url> [--json] [--svg]');
|
|
340
|
+
process.exit(2);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
run(target).then(report => {
|
|
344
|
+
if (jsonMode) {
|
|
345
|
+
console.log(JSON.stringify(report, null, 2));
|
|
346
|
+
} else if (svgMode) {
|
|
347
|
+
console.log(generateBadgeSvg(report));
|
|
348
|
+
} else {
|
|
349
|
+
printCli(report);
|
|
350
|
+
}
|
|
351
|
+
process.exit(report.verdict === 'CONFORMANT' ? 0 : 1);
|
|
352
|
+
}).catch(err => {
|
|
353
|
+
console.error('Fatal error:', err.message);
|
|
354
|
+
process.exit(1);
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
module.exports = {
|
|
359
|
+
run,
|
|
360
|
+
generateBadgeSvg,
|
|
361
|
+
printCli
|
|
362
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@celorodrigues/x402-conformance",
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "Zero-dependency CLI & testing suite for official x402 AI agent micropayment protocol & EIP-3009 compliance",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"x402-conformance": "bin/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"index.js",
|
|
11
|
+
"bin/",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"keywords": [
|
|
16
|
+
"x402",
|
|
17
|
+
"eip-3009",
|
|
18
|
+
"coinbase",
|
|
19
|
+
"base",
|
|
20
|
+
"ai-agents",
|
|
21
|
+
"micropayments",
|
|
22
|
+
"usdc",
|
|
23
|
+
"linter",
|
|
24
|
+
"conformance"
|
|
25
|
+
],
|
|
26
|
+
"author": "Automaton-Sovereign (0x71DEAc098914A009E3720524642A6bE6F65EE528)",
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=18.0.0"
|
|
30
|
+
},
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "git+https://github.com/baianomarceloeduardo-jpg/automaton-x402.git"
|
|
34
|
+
},
|
|
35
|
+
"homepage": "https://api.automaton-sovereign.workers.dev/leaderboard",
|
|
36
|
+
"bugs": {
|
|
37
|
+
"url": "https://github.com/baianomarceloeduardo-jpg/automaton-x402/issues"
|
|
38
|
+
},
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public"
|
|
41
|
+
}
|
|
42
|
+
}
|