@scriptmasterlabs/x402-paywall 0.1.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/README.md +40 -0
- package/examples/express.mjs +40 -0
- package/package.json +20 -0
- package/src/index.js +191 -0
package/README.md
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# @scriptmasterlabs/x402-paywall
|
|
2
|
+
|
|
3
|
+
**One line of code. AI agents pay. No Stripe. No API keys. No accounts.**
|
|
4
|
+
|
|
5
|
+
```js
|
|
6
|
+
import { x402 } from '@scriptmasterlabs/x402-paywall'
|
|
7
|
+
|
|
8
|
+
app.use('/premium', x402({
|
|
9
|
+
price: '0.001',
|
|
10
|
+
payTo: '0xYourBaseAddress',
|
|
11
|
+
freeForHumans: true, // bots/agents pay; browsers free
|
|
12
|
+
}))
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm i @scriptmasterlabs/x402-paywall
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## What happens
|
|
22
|
+
|
|
23
|
+
1. Agent hits your route → **HTTP 402** + machine-readable `accepts[]`
|
|
24
|
+
2. Agent sends **0.001 USDC** on Base to `payTo`
|
|
25
|
+
3. Agent retries with header: `X-PAYMENT-TX: 0x<txHash>`
|
|
26
|
+
4. Middleware verifies the on-chain USDC transfer → your handler runs
|
|
27
|
+
|
|
28
|
+
## CRAWLTOLL mode
|
|
29
|
+
|
|
30
|
+
`freeForHumans: true` → Chrome/Safari free; GPTBot/ClaudeBot/curl/mcp pay.
|
|
31
|
+
|
|
32
|
+
## Live $0.001 snacks (already deployed)
|
|
33
|
+
|
|
34
|
+
See: https://www.scriptmasterlabs.com/x402-snacks.html
|
|
35
|
+
|
|
36
|
+
## Related
|
|
37
|
+
|
|
38
|
+
- MCP drop-in: `npx @scriptmasterlabs/mcp-x402`
|
|
39
|
+
- SDK for tool authors: `@scriptmasterlabs/mcp-x402-sdk`
|
|
40
|
+
- CRAWLTOLL product page: https://www.scriptmasterlabs.com/crawltoll.html
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import { x402 } from '../src/index.js';
|
|
3
|
+
|
|
4
|
+
const app = express();
|
|
5
|
+
const PAY_TO = process.env.PAY_TO || '0x4e14B249D9A4c9c9352D780eCEB508A8eB7a7700';
|
|
6
|
+
|
|
7
|
+
// Humans free, agents/bots pay $0.001 (CRAWLTOLL mode)
|
|
8
|
+
app.use(
|
|
9
|
+
'/premium',
|
|
10
|
+
x402({ price: '0.001', payTo: PAY_TO, freeForHumans: true, description: 'premium demo' }),
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
app.get('/premium/secret', (req, res) => {
|
|
14
|
+
res.json({
|
|
15
|
+
ok: true,
|
|
16
|
+
message: 'You paid (or you are human).',
|
|
17
|
+
payment: req.x402 || null,
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// Always-paid API path
|
|
22
|
+
app.use('/api/paid', x402({ price: '0.001', payTo: PAY_TO }));
|
|
23
|
+
app.get('/api/paid/ping', (req, res) => {
|
|
24
|
+
res.json({ pong: true, tx: req.x402?.txHash });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
app.get('/', (_req, res) => {
|
|
28
|
+
res.type('html').send(`<pre>
|
|
29
|
+
@scriptmasterlabs/x402-paywall demo
|
|
30
|
+
|
|
31
|
+
GET /premium/secret — humans free, bots 402
|
|
32
|
+
GET /api/paid/ping — always 402 until X-PAYMENT-TX
|
|
33
|
+
|
|
34
|
+
payTo: ${PAY_TO}
|
|
35
|
+
price: 0.001 USDC on Base
|
|
36
|
+
</pre>`);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const port = process.env.PORT || 8787;
|
|
40
|
+
app.listen(port, () => console.log(`x402 demo http://127.0.0.1:${port}`));
|
package/package.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@scriptmasterlabs/x402-paywall",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "One-line x402 paywall: app.use('/premium', x402({ price: '0.001', payTo: '0x...' })). Agents pay USDC on Base. Humans optional free.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": ["src", "README.md", "examples"],
|
|
11
|
+
"keywords": ["x402", "http-402", "paywall", "usdc", "base", "ai-agents", "middleware", "express", "micropayments"],
|
|
12
|
+
"author": "ScriptMasterLabs",
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "https://github.com/Timwal78/SML_Portfolio.git",
|
|
17
|
+
"directory": "packages/x402-paywall"
|
|
18
|
+
},
|
|
19
|
+
"engines": { "node": ">=18" }
|
|
20
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @scriptmasterlabs/x402-paywall
|
|
3
|
+
*
|
|
4
|
+
* One line:
|
|
5
|
+
* app.use('/premium', x402({ price: '0.001', payTo: '0xYourAddress' }))
|
|
6
|
+
*
|
|
7
|
+
* Agents hit 402 → pay USDC on Base → retry with X-PAYMENT-TX (or X-PAYMENT).
|
|
8
|
+
* Set freeForHumans: true to toll bots/agents only (CRAWLTOLL mode).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const USDC_BASE = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913';
|
|
12
|
+
const NETWORK = 'eip155:8453';
|
|
13
|
+
|
|
14
|
+
const BOT_RE =
|
|
15
|
+
/GPTBot|ClaudeBot|Claude-Web|Anthropic|PerplexityBot|Google-Extended|Bytespider|CCBot|Amazonbot|Applebot-Extended|meta-externalagent|ChatGPT|OpenAI|mcp-client|x402|curl\/|python-requests|axios|Go-http-client|Java\/|httpx|aiohttp|node-fetch|undici|wget/i;
|
|
16
|
+
|
|
17
|
+
function toAmountAtomic(price) {
|
|
18
|
+
// price in USDC dollars → 6 decimals
|
|
19
|
+
const n = Number(price);
|
|
20
|
+
if (!Number.isFinite(n) || n <= 0) throw new Error('x402: price must be a positive number string');
|
|
21
|
+
return String(Math.round(n * 1e6));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function isHuman(req, freeForHumans) {
|
|
25
|
+
if (!freeForHumans) return false;
|
|
26
|
+
if (req.get('x-payment') || req.get('x-payment-tx')) return false; // paying client = agent path
|
|
27
|
+
const ua = req.get('user-agent') || '';
|
|
28
|
+
if (!ua) return false;
|
|
29
|
+
if (BOT_RE.test(ua)) return false;
|
|
30
|
+
// browser-ish
|
|
31
|
+
return /Mozilla|Chrome|Safari|Firefox|Edg/i.test(ua);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function buildAccepts({ price, payTo, description }) {
|
|
35
|
+
const amount = toAmountAtomic(price);
|
|
36
|
+
return [
|
|
37
|
+
{
|
|
38
|
+
scheme: 'exact',
|
|
39
|
+
network: NETWORK,
|
|
40
|
+
amount,
|
|
41
|
+
maxAmountRequired: amount,
|
|
42
|
+
asset: USDC_BASE,
|
|
43
|
+
payTo,
|
|
44
|
+
resource: description || 'x402-protected resource',
|
|
45
|
+
description: description || `Pay ${price} USDC on Base (x402). Retry with X-PAYMENT-TX: <txHash>`,
|
|
46
|
+
mimeType: 'application/json',
|
|
47
|
+
maxTimeoutSeconds: 120,
|
|
48
|
+
extra: {
|
|
49
|
+
name: 'USDC',
|
|
50
|
+
version: '2',
|
|
51
|
+
rail: 'sovereign-x-payment-tx',
|
|
52
|
+
chain: 'base',
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Verify a Base USDC transfer to payTo for at least `amount` atomic units.
|
|
60
|
+
* Uses public RPC + eth_getTransactionReceipt. No API key.
|
|
61
|
+
*/
|
|
62
|
+
async function verifySovereignTx({ txHash, payTo, amountAtomic, rpcUrl }) {
|
|
63
|
+
if (!txHash || !/^0x[a-fA-F0-9]{64}$/.test(txHash)) {
|
|
64
|
+
return { ok: false, reason: 'invalid_tx_hash' };
|
|
65
|
+
}
|
|
66
|
+
const rpc = rpcUrl || process.env.BASE_RPC_URL || 'https://mainnet.base.org';
|
|
67
|
+
const body = {
|
|
68
|
+
jsonrpc: '2.0',
|
|
69
|
+
id: 1,
|
|
70
|
+
method: 'eth_getTransactionReceipt',
|
|
71
|
+
params: [txHash],
|
|
72
|
+
};
|
|
73
|
+
const res = await fetch(rpc, {
|
|
74
|
+
method: 'POST',
|
|
75
|
+
headers: { 'content-type': 'application/json' },
|
|
76
|
+
body: JSON.stringify(body),
|
|
77
|
+
});
|
|
78
|
+
const json = await res.json();
|
|
79
|
+
const receipt = json.result;
|
|
80
|
+
if (!receipt || receipt.status !== '0x1') {
|
|
81
|
+
return { ok: false, reason: 'tx_not_found_or_failed' };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ERC-20 Transfer(address,address,uint256)
|
|
85
|
+
const TRANSFER_TOPIC =
|
|
86
|
+
'0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef';
|
|
87
|
+
const payToTopic = '0x' + payTo.toLowerCase().replace(/^0x/, '').padStart(64, '0');
|
|
88
|
+
const usdc = USDC_BASE.toLowerCase();
|
|
89
|
+
const need = BigInt(amountAtomic);
|
|
90
|
+
|
|
91
|
+
for (const log of receipt.logs || []) {
|
|
92
|
+
if ((log.address || '').toLowerCase() !== usdc) continue;
|
|
93
|
+
if (!log.topics || log.topics[0] !== TRANSFER_TOPIC) continue;
|
|
94
|
+
if ((log.topics[2] || '').toLowerCase() !== payToTopic) continue;
|
|
95
|
+
const value = BigInt(log.data || '0x0');
|
|
96
|
+
if (value >= need) {
|
|
97
|
+
return {
|
|
98
|
+
ok: true,
|
|
99
|
+
txHash,
|
|
100
|
+
from: '0x' + (log.topics[1] || '').slice(26),
|
|
101
|
+
amount: value.toString(),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return { ok: false, reason: 'no_matching_usdc_transfer' };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// simple in-memory replay guard (process-local)
|
|
109
|
+
const seenTx = new Map();
|
|
110
|
+
function rememberTx(tx) {
|
|
111
|
+
seenTx.set(tx, Date.now());
|
|
112
|
+
if (seenTx.size > 5000) {
|
|
113
|
+
const first = seenTx.keys().next().value;
|
|
114
|
+
seenTx.delete(first);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Express / Connect middleware factory.
|
|
120
|
+
* @param {object} opts
|
|
121
|
+
* @param {string} opts.price - USDC amount as decimal string, e.g. '0.001'
|
|
122
|
+
* @param {string} opts.payTo - destination Base address (0x...)
|
|
123
|
+
* @param {boolean} [opts.freeForHumans=false] - CRAWLTOLL mode: browsers free, bots pay
|
|
124
|
+
* @param {string} [opts.description]
|
|
125
|
+
* @param {string} [opts.rpcUrl] - Base JSON-RPC
|
|
126
|
+
*/
|
|
127
|
+
export function x402(opts = {}) {
|
|
128
|
+
const { price, payTo, freeForHumans = false, description, rpcUrl } = opts;
|
|
129
|
+
if (!price) throw new Error('x402({ price }) required');
|
|
130
|
+
if (!payTo || !/^0x[a-fA-F0-9]{40}$/.test(payTo)) {
|
|
131
|
+
throw new Error('x402({ payTo }) must be a 0x address');
|
|
132
|
+
}
|
|
133
|
+
const amountAtomic = toAmountAtomic(price);
|
|
134
|
+
const accepts = buildAccepts({ price, payTo, description });
|
|
135
|
+
|
|
136
|
+
return async function x402Middleware(req, res, next) {
|
|
137
|
+
try {
|
|
138
|
+
if (isHuman(req, freeForHumans)) return next();
|
|
139
|
+
|
|
140
|
+
const txHeader =
|
|
141
|
+
req.get('x-payment-tx') ||
|
|
142
|
+
req.get('X-PAYMENT-TX') ||
|
|
143
|
+
(req.get('x-payment') || '').replace(/^tx:/i, '');
|
|
144
|
+
|
|
145
|
+
if (txHeader) {
|
|
146
|
+
if (seenTx.has(txHeader)) {
|
|
147
|
+
// allow same tx only once per process (anti double-spend on replay)
|
|
148
|
+
// soft: still verify once then mark — if already seen, reject
|
|
149
|
+
res.status(402).json({
|
|
150
|
+
x402Version: 1,
|
|
151
|
+
error: 'tx_already_used',
|
|
152
|
+
accepts,
|
|
153
|
+
});
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const ver = await verifySovereignTx({
|
|
157
|
+
txHash: txHeader,
|
|
158
|
+
payTo,
|
|
159
|
+
amountAtomic,
|
|
160
|
+
rpcUrl,
|
|
161
|
+
});
|
|
162
|
+
if (ver.ok) {
|
|
163
|
+
rememberTx(txHeader);
|
|
164
|
+
req.x402 = ver;
|
|
165
|
+
res.setHeader('X-PAYMENT-RESPONSE', JSON.stringify({ success: true, tx: txHeader }));
|
|
166
|
+
return next();
|
|
167
|
+
}
|
|
168
|
+
res.status(402).json({
|
|
169
|
+
x402Version: 1,
|
|
170
|
+
error: ver.reason || 'payment_invalid',
|
|
171
|
+
accepts,
|
|
172
|
+
});
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Challenge
|
|
177
|
+
res.setHeader('Content-Type', 'application/json');
|
|
178
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
179
|
+
res.status(402).json({
|
|
180
|
+
x402Version: 1,
|
|
181
|
+
error: 'Payment Required',
|
|
182
|
+
accepts,
|
|
183
|
+
resource: req.originalUrl || req.url,
|
|
184
|
+
});
|
|
185
|
+
} catch (err) {
|
|
186
|
+
res.status(500).json({ error: 'x402_middleware_error', message: String(err?.message || err) });
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export default x402;
|