@mingderwang/x402 0.1.0 → 0.1.2
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 +23 -2
- package/dist/cli.js +24596 -0
- package/package.json +11 -4
- package/src/cli.ts +342 -0
- package/src/index.ts +307 -0
package/package.json
CHANGED
|
@@ -1,16 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mingderwang/x402",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "x402 (HTTP 402 Payment Required) client + server helpers:
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "x402 (HTTP 402 Payment Required) client + server helpers: EIP-3009 signing, X-PAYMENT, verification and settlement. Ships an `x402` CLI.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "./dist/index.js",
|
|
8
8
|
"types": "./dist/index.d.ts",
|
|
9
|
+
"bin": {
|
|
10
|
+
"x402": "./dist/cli.js"
|
|
11
|
+
},
|
|
9
12
|
"exports": {
|
|
10
|
-
".":
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"default": "./dist/index.js"
|
|
16
|
+
}
|
|
11
17
|
},
|
|
12
18
|
"files": [
|
|
13
19
|
"dist",
|
|
20
|
+
"src",
|
|
14
21
|
"README.md"
|
|
15
22
|
],
|
|
16
23
|
"engines": {
|
|
@@ -20,7 +27,7 @@
|
|
|
20
27
|
"build": "node ./scripts/build.mjs"
|
|
21
28
|
},
|
|
22
29
|
"dependencies": {
|
|
23
|
-
"@mingderwang/wallet": "^0.1.
|
|
30
|
+
"@mingderwang/wallet": "^0.1.1",
|
|
24
31
|
"ethers": "^6.13.4"
|
|
25
32
|
},
|
|
26
33
|
"keywords": [
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { readKeystore, loadWallet, processEnvPassword } from '@mingderwang/wallet';
|
|
6
|
+
import {
|
|
7
|
+
decodeJson,
|
|
8
|
+
detectPaymentRequired,
|
|
9
|
+
makePaymentPayload,
|
|
10
|
+
verifyPaymentPayload,
|
|
11
|
+
settlePayment,
|
|
12
|
+
xPaymentHeader,
|
|
13
|
+
PAYMENT_REQUIRED_HEADER,
|
|
14
|
+
type PaymentRequirements,
|
|
15
|
+
type PaymentRequirementsResponse,
|
|
16
|
+
type PaymentPayload,
|
|
17
|
+
} from './index';
|
|
18
|
+
|
|
19
|
+
const VERSION = '0.1.2';
|
|
20
|
+
|
|
21
|
+
const C = {
|
|
22
|
+
bold: (s: string) => `\x1b[1m${s}\x1b[0m`,
|
|
23
|
+
green: (s: string) => `\x1b[32m${s}\x1b[0m`,
|
|
24
|
+
yellow: (s: string) => `\x1b[33m${s}\x1b[0m`,
|
|
25
|
+
red: (s: string) => `\x1b[31m${s}\x1b[0m`,
|
|
26
|
+
gray: (s: string) => `\x1b[90m${s}\x1b[0m`,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function defaultKeystore(): string {
|
|
30
|
+
return process.env.X402_WALLET_PATH ?? join(homedir(), '.wallet', 'wallet.json');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function promptHidden(question: string): Promise<string> {
|
|
34
|
+
const env = processEnvPassword();
|
|
35
|
+
if (env) {
|
|
36
|
+
process.stderr.write(`${question}[using WALLET_PASSWORD env]\n`);
|
|
37
|
+
return Promise.resolve(env);
|
|
38
|
+
}
|
|
39
|
+
process.stderr.write(question);
|
|
40
|
+
return new Promise((resolve) => {
|
|
41
|
+
const stdin = process.stdin;
|
|
42
|
+
const wasRaw = !!stdin.isRaw;
|
|
43
|
+
stdin.setRawMode(true);
|
|
44
|
+
stdin.resume();
|
|
45
|
+
let input = '';
|
|
46
|
+
const onData = (buf: Buffer) => {
|
|
47
|
+
const code = buf[0];
|
|
48
|
+
if (code === 3) {
|
|
49
|
+
stdin.removeListener('data', onData);
|
|
50
|
+
stdin.setRawMode(false);
|
|
51
|
+
stdin.pause();
|
|
52
|
+
process.exit(130);
|
|
53
|
+
}
|
|
54
|
+
if (code === 13 || code === 10) {
|
|
55
|
+
stdin.removeListener('data', onData);
|
|
56
|
+
stdin.setRawMode(false);
|
|
57
|
+
stdin.pause();
|
|
58
|
+
process.stderr.write('\n');
|
|
59
|
+
resolve(input);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (code === 127 || code === 8) input = input.slice(0, -1);
|
|
63
|
+
else input += buf.toString('utf8');
|
|
64
|
+
};
|
|
65
|
+
stdin.on('data', onData);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function parseRequirement(spec: string): PaymentRequirements {
|
|
70
|
+
const raw = existsSync(spec) ? readFileSync(spec, 'utf8') : spec;
|
|
71
|
+
const parsed = JSON.parse(raw) as PaymentRequirementsResponse | PaymentRequirements;
|
|
72
|
+
if (Array.isArray((parsed as PaymentRequirementsResponse).accepts)) {
|
|
73
|
+
const first = (parsed as PaymentRequirementsResponse).accepts[0];
|
|
74
|
+
if (!first) throw new Error('PaymentRequirementsResponse has no accepts[] entries.');
|
|
75
|
+
return first;
|
|
76
|
+
}
|
|
77
|
+
return parsed as PaymentRequirements;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function parsePayload(spec: string): PaymentPayload {
|
|
81
|
+
if (/^[A-Za-z0-9+/=]+$/.test(spec) && spec.length > 64) return decodeJson<PaymentPayload>(spec);
|
|
82
|
+
const raw = existsSync(spec) ? readFileSync(spec, 'utf8') : spec;
|
|
83
|
+
return JSON.parse(raw) as PaymentPayload;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function loadSigner(action: string, keystorePath: string): Promise<import('ethers').Wallet> {
|
|
87
|
+
if (!existsSync(keystorePath)) {
|
|
88
|
+
console.log(C.red(`No keystore at ${keystorePath}`) + ` — create one with the \`wallet\` CLI.`);
|
|
89
|
+
process.exit(2);
|
|
90
|
+
}
|
|
91
|
+
const keystore = readKeystore(keystorePath);
|
|
92
|
+
for (let attempt = 1; ; attempt++) {
|
|
93
|
+
const password = await promptHidden(`Keystore password (${action}): `);
|
|
94
|
+
try {
|
|
95
|
+
return loadWallet(keystore, password);
|
|
96
|
+
} catch {
|
|
97
|
+
if (attempt >= 3) {
|
|
98
|
+
console.log(C.red('Incorrect password. Exiting.'));
|
|
99
|
+
process.exit(1);
|
|
100
|
+
}
|
|
101
|
+
console.log(C.red('Incorrect password, try again.'));
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function printHeaders(set: Headers): void {
|
|
107
|
+
const h = set.get(PAYMENT_REQUIRED_HEADER);
|
|
108
|
+
if (h) console.log(` ${C.gray('payment-required')} ${h.slice(0, 60)}…`);
|
|
109
|
+
const retry = set.get('retry-after');
|
|
110
|
+
if (retry) console.log(` ${C.gray('retry-after')} ${retry}`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function requestWithBody(url: string, method: string, headers: Record<string, string>): Promise<Response> {
|
|
114
|
+
return fetch(url, { method, headers });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ---------- commands ----------
|
|
118
|
+
|
|
119
|
+
async function cmdRequirements(url: string, method: string, hdrs: Record<string, string>): Promise<void> {
|
|
120
|
+
const res = await requestWithBody(url, method, hdrs);
|
|
121
|
+
const text = await res.text();
|
|
122
|
+
if (res.status !== 402) {
|
|
123
|
+
console.log(C.yellow(`Response is ${res.status}, not 402 — no payment required.`));
|
|
124
|
+
console.log(text.slice(0, 400));
|
|
125
|
+
process.exit(0);
|
|
126
|
+
}
|
|
127
|
+
const reqs = detectPaymentRequired({ status: res.status, headers: res.headers, text });
|
|
128
|
+
if (res.headers && res.headers.size) printHeaders(res.headers);
|
|
129
|
+
console.log(JSON.stringify(reqs, null, 2));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function cmdSign(requirementArg: string, keystorePath: string, json: boolean): Promise<void> {
|
|
133
|
+
const requirement = parseRequirement(requirementArg);
|
|
134
|
+
const wallet = await loadSigner('sign', keystorePath);
|
|
135
|
+
const payload = await makePaymentPayload({ wallet, requirement });
|
|
136
|
+
const header = xPaymentHeader(payload);
|
|
137
|
+
if (json) {
|
|
138
|
+
console.log(JSON.stringify({ payload, header }, null, 2));
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
console.log(` ${C.bold('Signer')} ${wallet.address}`);
|
|
142
|
+
console.log(` ${C.bold('Network')} ${requirement.network}`);
|
|
143
|
+
console.log(` ${C.bold('Amount')} ${requirement.maxAmountRequired} (${requirement.asset})`);
|
|
144
|
+
console.log('');
|
|
145
|
+
console.log(` ${C.green('X-PAYMENT header')}:`);
|
|
146
|
+
console.log(' ' + header);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function cmdPay(url: string, method: string, hdrs: Record<string, string>, keystorePath: string, json: boolean): Promise<void> {
|
|
150
|
+
const first = await requestWithBody(url, method, hdrs);
|
|
151
|
+
const firstText = await first.text();
|
|
152
|
+
|
|
153
|
+
if (first.status !== 402) {
|
|
154
|
+
console.log(C.yellow(`No payment required (${first.status}) — nothing to do.`));
|
|
155
|
+
console.log(firstText.slice(0, 400));
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const reqs = detectPaymentRequired({ status: first.status, headers: first.headers, text: firstText });
|
|
159
|
+
if (!reqs) {
|
|
160
|
+
console.log(C.red('402 response had no parseable payment requirements.'));
|
|
161
|
+
process.exit(1);
|
|
162
|
+
}
|
|
163
|
+
const requirement = reqs.accepts[0];
|
|
164
|
+
if (!requirement) {
|
|
165
|
+
console.log(C.red('PaymentRequirementsResponse has no accepts[] entries.'));
|
|
166
|
+
process.exit(1);
|
|
167
|
+
}
|
|
168
|
+
const wallet = await loadSigner('pay', keystorePath);
|
|
169
|
+
console.log(` ${C.bold('Paying')} ${requirement.maxAmountRequired} ${requirement.asset} on ${requirement.network}`);
|
|
170
|
+
console.log(` ${C.gray('from')} ${wallet.address} ${C.gray('→')} ${requirement.payTo}\n`);
|
|
171
|
+
const payload = await makePaymentPayload({ wallet, requirement });
|
|
172
|
+
const retried = await requestWithBody(url, method, { ...hdrs, 'X-PAYMENT': xPaymentHeader(payload) });
|
|
173
|
+
const body = await retried.text();
|
|
174
|
+
if (retried.status === 402) {
|
|
175
|
+
console.log(C.red(`Still 402 after payment:`));
|
|
176
|
+
console.log(body.slice(0, 400));
|
|
177
|
+
process.exit(1);
|
|
178
|
+
}
|
|
179
|
+
if (json) {
|
|
180
|
+
let parsed = null;
|
|
181
|
+
try { parsed = JSON.parse(body); } catch { /* non-json */ }
|
|
182
|
+
console.log(JSON.stringify({ status: retried.status, headers: Object.fromEntries(retried.headers.entries()), body: parsed ?? body }, null, 2));
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
console.log(C.green(`${retried.status} ${retried.statusText}`));
|
|
186
|
+
try {
|
|
187
|
+
console.log(JSON.stringify(JSON.parse(body), null, 2));
|
|
188
|
+
} catch {
|
|
189
|
+
console.log(body.slice(0, 800));
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function cmdDecode(spec: string): Promise<void> {
|
|
194
|
+
const payload = parsePayload(spec);
|
|
195
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function cmdVerify(payloadArg: string, requirementArg: string): Promise<void> {
|
|
199
|
+
const payload = parsePayload(payloadArg);
|
|
200
|
+
const requirement = parseRequirement(requirementArg);
|
|
201
|
+
const result = verifyPaymentPayload(payload, requirement);
|
|
202
|
+
if (result.isValid) console.log(JSON.stringify({ isValid: true, payer: result.payer }, null, 2));
|
|
203
|
+
else console.log(JSON.stringify({ isValid: false, invalidReason: result.invalidReason }, null, 2));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function cmdSettle(payloadArg: string, requirementArg: string, gasKey?: string, rpcUrl?: string): Promise<void> {
|
|
207
|
+
const payload = parsePayload(payloadArg);
|
|
208
|
+
const requirement = parseRequirement(requirementArg);
|
|
209
|
+
if (gasKey) process.env.X402_GAS_PRIVATE_KEY = gasKey;
|
|
210
|
+
if (rpcUrl) process.env.X402_RPC_URL = rpcUrl;
|
|
211
|
+
const result = await settlePayment(payload, requirement, {});
|
|
212
|
+
console.log(JSON.stringify(result, null, 2));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ---------- help ----------
|
|
216
|
+
|
|
217
|
+
const HELP = `x402 v${VERSION}
|
|
218
|
+
|
|
219
|
+
x402 (HTTP 402 Payment Required) toolkit: sign, pay, verify and settle
|
|
220
|
+
EIP-3009 USDC payments.
|
|
221
|
+
|
|
222
|
+
Usage:
|
|
223
|
+
x402 requirements <url> [--method GET] [--header K:V]...
|
|
224
|
+
Print the PaymentRequirementsResponse if the URL responds 402.
|
|
225
|
+
x402 sign <requirements.json> [--wallet <keystore>]
|
|
226
|
+
Sign a payment for the first accepts[] entry; print the X-PAYMENT header base64.
|
|
227
|
+
x402 pay <url> [--method GET] [--header K:V]... [--wallet <keystore>]
|
|
228
|
+
Full round trip: hit the URL, pay the 402, retry with X-PAYMENT, print the result.
|
|
229
|
+
x402 decode <base64-or-file | payload.json>
|
|
230
|
+
Decode an X-PAYMENT header value into the PaymentPayload JSON.
|
|
231
|
+
x402 verify <payload> <requirements.json>
|
|
232
|
+
Offline signature/amount/time sanity check. payload = base64 header or JSON file.
|
|
233
|
+
x402 settle <payload> <requirements.json> [--gas-key <private>] [--rpc <url>]
|
|
234
|
+
Broadcast transferWithAuthorization (uses X402_GAS_PRIVATE_KEY / X402_RPC_URL env if set).
|
|
235
|
+
|
|
236
|
+
Options:
|
|
237
|
+
--wallet <keystore> Keystore file (default ~/.wallet/wallet.json)
|
|
238
|
+
--json Machine-readable output
|
|
239
|
+
--header K:V Extra HTTP header (repeatable)
|
|
240
|
+
--method <verB> HTTP method (default GET)
|
|
241
|
+
|
|
242
|
+
Password:
|
|
243
|
+
Prompts interactively, or set WALLET_PASSWORD / XSIFT_WALLET_PASSWORD in the environment.`;
|
|
244
|
+
|
|
245
|
+
function printHelp(): void {
|
|
246
|
+
console.log(HELP);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ---------- entry ----------
|
|
250
|
+
|
|
251
|
+
interface Parsed {
|
|
252
|
+
cmd: string;
|
|
253
|
+
positional: string[];
|
|
254
|
+
opts: Record<string, string>;
|
|
255
|
+
headers: Record<string, string>;
|
|
256
|
+
json: boolean;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function parseArgs(args: string[]): Parsed {
|
|
260
|
+
const positional: string[] = [];
|
|
261
|
+
const opts: Record<string, string> = {};
|
|
262
|
+
const headers: Record<string, string> = {};
|
|
263
|
+
let json = false;
|
|
264
|
+
for (let i = 0; i < args.length; i++) {
|
|
265
|
+
const a = args[i];
|
|
266
|
+
if (a === '--json') {
|
|
267
|
+
json = true;
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
if (a === '--header') {
|
|
271
|
+
const kv = args[++i];
|
|
272
|
+
const idx = kv ? kv.indexOf(':') : -1;
|
|
273
|
+
if (!kv || idx < 0) {
|
|
274
|
+
console.log(C.red('--header expects K:V'));
|
|
275
|
+
process.exit(1);
|
|
276
|
+
}
|
|
277
|
+
headers[kv.slice(0, idx).trim()] = kv.slice(idx + 1).trim();
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
if (a === '--wallet' || a === '--gas-key' || a === '--rpc' || a === '--method' || a === '--path') {
|
|
281
|
+
opts[a.slice(2)] = args[++i] ?? '';
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
if (!a.startsWith('--')) positional.push(a);
|
|
285
|
+
}
|
|
286
|
+
const cmd = positional[0] ?? 'help';
|
|
287
|
+
return { cmd, positional: positional.slice(1), opts, headers, json };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async function main(): Promise<void> {
|
|
291
|
+
const { cmd, positional, opts, headers, json } = parseArgs(process.argv.slice(2));
|
|
292
|
+
const keystore = opts.wallet ?? defaultKeystore();
|
|
293
|
+
const method = (opts.method ?? 'GET').toUpperCase();
|
|
294
|
+
switch (cmd) {
|
|
295
|
+
case 'requirements': {
|
|
296
|
+
const url = positional[0];
|
|
297
|
+
if (!url) throw new Error('usage: x402 requirements <url>');
|
|
298
|
+
return cmdRequirements(url, method, headers);
|
|
299
|
+
}
|
|
300
|
+
case 'sign': {
|
|
301
|
+
const spec = positional[0];
|
|
302
|
+
if (!spec) throw new Error('usage: x402 sign <requirements.json> [--wallet <keystore>]');
|
|
303
|
+
return cmdSign(spec, keystore, json);
|
|
304
|
+
}
|
|
305
|
+
case 'pay': {
|
|
306
|
+
const url = positional[0];
|
|
307
|
+
if (!url) throw new Error('usage: x402 pay <url> [--wallet <keystore>]');
|
|
308
|
+
return cmdPay(url, method, headers, keystore, json);
|
|
309
|
+
}
|
|
310
|
+
case 'decode': {
|
|
311
|
+
const spec = positional[0];
|
|
312
|
+
if (!spec) throw new Error('usage: x402 decode <base64-or-file>');
|
|
313
|
+
return cmdDecode(spec);
|
|
314
|
+
}
|
|
315
|
+
case 'verify': {
|
|
316
|
+
const [payloadArg, reqArg] = positional;
|
|
317
|
+
if (!payloadArg || !reqArg) throw new Error('usage: x402 verify <payload> <requirements.json>');
|
|
318
|
+
return cmdVerify(payloadArg, reqArg);
|
|
319
|
+
}
|
|
320
|
+
case 'settle': {
|
|
321
|
+
const [payloadArg, reqArg] = positional;
|
|
322
|
+
if (!payloadArg || !reqArg) throw new Error('usage: x402 settle <payload> <requirements.json>');
|
|
323
|
+
return cmdSettle(payloadArg, reqArg, opts['gas-key'], opts.rpc);
|
|
324
|
+
}
|
|
325
|
+
case 'help':
|
|
326
|
+
case '-h':
|
|
327
|
+
case '--help':
|
|
328
|
+
return printHelp();
|
|
329
|
+
case '-v':
|
|
330
|
+
case '--version':
|
|
331
|
+
return console.log(VERSION);
|
|
332
|
+
default:
|
|
333
|
+
console.log(C.red(`Unknown command: ${cmd}`) + '\n');
|
|
334
|
+
printHelp();
|
|
335
|
+
process.exit(1);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
main().catch((e) => {
|
|
340
|
+
console.error(e instanceof Error ? e.message : e);
|
|
341
|
+
process.exit(1);
|
|
342
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import { randomBytesHex, NETWORKS } from '@mingderwang/wallet';
|
|
2
|
+
import { verifyTypedData, Contract, JsonRpcProvider, Wallet } from 'ethers';
|
|
3
|
+
|
|
4
|
+
// ---------- types ----------
|
|
5
|
+
|
|
6
|
+
export interface PaymentRequirements {
|
|
7
|
+
scheme: string;
|
|
8
|
+
network: string;
|
|
9
|
+
maxAmountRequired: string;
|
|
10
|
+
asset: string;
|
|
11
|
+
payTo: string;
|
|
12
|
+
resource: string;
|
|
13
|
+
description: string;
|
|
14
|
+
mimeType?: string;
|
|
15
|
+
outputSchema?: unknown;
|
|
16
|
+
maxTimeoutSeconds: number;
|
|
17
|
+
extra?: Record<string, unknown>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface PaymentRequirementsResponse {
|
|
21
|
+
x402Version: 1;
|
|
22
|
+
error: string;
|
|
23
|
+
accepts: PaymentRequirements[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface Authorization {
|
|
27
|
+
from: string;
|
|
28
|
+
to: string;
|
|
29
|
+
value: string;
|
|
30
|
+
validAfter: string;
|
|
31
|
+
validBefore: string;
|
|
32
|
+
nonce: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface PaymentPayload {
|
|
36
|
+
x402Version: 1;
|
|
37
|
+
scheme: 'exact';
|
|
38
|
+
network: string;
|
|
39
|
+
payload: {
|
|
40
|
+
signature: string;
|
|
41
|
+
authorization: Authorization;
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface SettlementResponse {
|
|
46
|
+
success: boolean;
|
|
47
|
+
errorReason?: string;
|
|
48
|
+
transaction: string;
|
|
49
|
+
network: string;
|
|
50
|
+
payer: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const PAYMENT_REQUIRED_HEADER = 'payment-required';
|
|
54
|
+
export const PAYMENT_SIGNATURE_HEADER = 'x-payment';
|
|
55
|
+
export const PAYMENT_RESPONSE_HEADER = 'payment-response';
|
|
56
|
+
|
|
57
|
+
export const ERROR = {
|
|
58
|
+
insufficientFunds: 'insufficient_funds',
|
|
59
|
+
invalidValidAfter: 'invalid_exact_evm_payload_authorization_valid_after',
|
|
60
|
+
invalidValidBefore: 'invalid_exact_evm_payload_authorization_valid_before',
|
|
61
|
+
invalidValue: 'invalid_exact_evm_payload_authorization_value',
|
|
62
|
+
invalidSignature: 'invalid_exact_evm_payload_signature',
|
|
63
|
+
recipientMismatch: 'invalid_exact_evm_payload_recipient_mismatch',
|
|
64
|
+
invalidNetwork: 'invalid_network',
|
|
65
|
+
invalidPayload: 'invalid_payload',
|
|
66
|
+
invalidPaymentRequirements: 'invalid_payment_requirements',
|
|
67
|
+
invalidScheme: 'invalid_scheme',
|
|
68
|
+
invalidVersion: 'invalid_x402_version',
|
|
69
|
+
invalidTxState: 'invalid_transaction_state',
|
|
70
|
+
} as const;
|
|
71
|
+
|
|
72
|
+
// ---------- encoding ----------
|
|
73
|
+
|
|
74
|
+
export function encodeJson(obj: unknown): string {
|
|
75
|
+
return Buffer.from(JSON.stringify(obj)).toString('base64');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function decodeJson<T>(b64: string): T {
|
|
79
|
+
return JSON.parse(Buffer.from(b64, 'base64').toString('utf8')) as T;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ---------- server: payment requirements ----------
|
|
83
|
+
|
|
84
|
+
export function makePaymentRequired(
|
|
85
|
+
opts: {
|
|
86
|
+
resource: string;
|
|
87
|
+
description: string;
|
|
88
|
+
network: string;
|
|
89
|
+
amount: string; // atomic units, e.g. "500000" for 0.5 USDC
|
|
90
|
+
asset: string;
|
|
91
|
+
payTo: string;
|
|
92
|
+
maxTimeoutSeconds?: number;
|
|
93
|
+
mimeType?: string;
|
|
94
|
+
extra?: Record<string, unknown>;
|
|
95
|
+
}
|
|
96
|
+
): PaymentRequirementsResponse {
|
|
97
|
+
return {
|
|
98
|
+
x402Version: 1,
|
|
99
|
+
error: `${PAYMENT_SIGNATURE_HEADER.toUpperCase()} header is required`,
|
|
100
|
+
accepts: [
|
|
101
|
+
{
|
|
102
|
+
scheme: 'exact',
|
|
103
|
+
network: opts.network,
|
|
104
|
+
maxAmountRequired: opts.amount,
|
|
105
|
+
asset: opts.asset.toLowerCase(),
|
|
106
|
+
payTo: opts.payTo.toLowerCase(),
|
|
107
|
+
resource: opts.resource,
|
|
108
|
+
description: opts.description,
|
|
109
|
+
mimeType: opts.mimeType ?? 'application/json',
|
|
110
|
+
outputSchema: null,
|
|
111
|
+
maxTimeoutSeconds: opts.maxTimeoutSeconds ?? 300,
|
|
112
|
+
extra: opts.extra ?? { name: 'USDC', version: '2' },
|
|
113
|
+
},
|
|
114
|
+
],
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ---------- client: 402 detection + payment construction ----------
|
|
119
|
+
|
|
120
|
+
export function detectPaymentRequired(response: { status: number; headers?: Headers; text?: string }): PaymentRequirementsResponse | null {
|
|
121
|
+
if (response.status !== 402) return null;
|
|
122
|
+
const header = response.headers?.get?.(PAYMENT_REQUIRED_HEADER);
|
|
123
|
+
if (header) {
|
|
124
|
+
try {
|
|
125
|
+
return decodeJson<PaymentRequirementsResponse>(header);
|
|
126
|
+
} catch {
|
|
127
|
+
/* fall through to body */
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (response.text) {
|
|
131
|
+
try {
|
|
132
|
+
const parsed = JSON.parse(response.text);
|
|
133
|
+
if (parsed && Array.isArray(parsed.accepts)) return parsed as PaymentRequirementsResponse;
|
|
134
|
+
} catch {
|
|
135
|
+
/* not json */
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface PaymentOptions {
|
|
142
|
+
wallet: Wallet;
|
|
143
|
+
requirement: PaymentRequirements;
|
|
144
|
+
nowSeconds?: number;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export async function makePaymentPayload({ wallet, requirement, nowSeconds }: PaymentOptions): Promise<PaymentPayload> {
|
|
148
|
+
const { NETWORKS: _net } = { NETWORKS };
|
|
149
|
+
const chainId = chainIdForNetwork(requirement.network);
|
|
150
|
+
const now = nowSeconds ?? Math.floor(Date.now() / 1000);
|
|
151
|
+
const value = BigInt(requirement.maxAmountRequired);
|
|
152
|
+
|
|
153
|
+
const domain = {
|
|
154
|
+
name: 'USD Coin',
|
|
155
|
+
version: '2',
|
|
156
|
+
chainId,
|
|
157
|
+
verifyingContract: requirement.asset,
|
|
158
|
+
};
|
|
159
|
+
const types = {
|
|
160
|
+
TransferWithAuthorization: [
|
|
161
|
+
{ name: 'from', type: 'address' },
|
|
162
|
+
{ name: 'to', type: 'address' },
|
|
163
|
+
{ name: 'value', type: 'uint256' },
|
|
164
|
+
{ name: 'validAfter', type: 'uint256' },
|
|
165
|
+
{ name: 'validBefore', type: 'uint256' },
|
|
166
|
+
{ name: 'nonce', type: 'bytes32' },
|
|
167
|
+
],
|
|
168
|
+
};
|
|
169
|
+
const authorization: Authorization = {
|
|
170
|
+
from: wallet.address,
|
|
171
|
+
to: requirement.payTo,
|
|
172
|
+
value: value.toString(),
|
|
173
|
+
validAfter: (now - 60).toString(),
|
|
174
|
+
validBefore: (now + requirement.maxTimeoutSeconds).toString(),
|
|
175
|
+
nonce: randomBytesHex(32),
|
|
176
|
+
};
|
|
177
|
+
const signature = await wallet.signTypedData(
|
|
178
|
+
domain,
|
|
179
|
+
types,
|
|
180
|
+
{ from: authorization.from, to: authorization.to, value: authorization.value, validAfter: authorization.validAfter, validBefore: authorization.validBefore, nonce: authorization.nonce }
|
|
181
|
+
);
|
|
182
|
+
return {
|
|
183
|
+
x402Version: 1,
|
|
184
|
+
scheme: 'exact',
|
|
185
|
+
network: requirement.network,
|
|
186
|
+
payload: { signature, authorization },
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function chainIdForNetwork(network: string): number {
|
|
191
|
+
for (const n of Object.values(NETWORKS)) {
|
|
192
|
+
if (n.networkId === network) return n.chainId;
|
|
193
|
+
}
|
|
194
|
+
const m = /-(\d+)$/.exec(network);
|
|
195
|
+
if (m) return Number(m[1]);
|
|
196
|
+
throw new Error(`Unknown network ${network}`);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function xPaymentHeader(payload: PaymentPayload): string {
|
|
200
|
+
return encodeJson(payload);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ---------- server: verification + settlement ----------
|
|
204
|
+
|
|
205
|
+
const TRANSFER_WITH_AUTH_ABI = [
|
|
206
|
+
'function transferWithAuthorization(address from, address to, uint256 value, uint256 validAfter, uint256 validBefore, bytes32 nonce)',
|
|
207
|
+
'function balanceOf(address) view returns (uint256)',
|
|
208
|
+
] as const;
|
|
209
|
+
|
|
210
|
+
export function verifyPaymentPayload(
|
|
211
|
+
payload: unknown,
|
|
212
|
+
requirement: PaymentRequirements
|
|
213
|
+
): { isValid: boolean; payer?: string; invalidReason?: string } {
|
|
214
|
+
if (!payload || typeof payload !== 'object') {
|
|
215
|
+
return { isValid: false, invalidReason: ERROR.invalidPayload };
|
|
216
|
+
}
|
|
217
|
+
const p = payload as PaymentPayload;
|
|
218
|
+
if (p.x402Version !== 1) return { isValid: false, invalidReason: ERROR.invalidVersion };
|
|
219
|
+
if (p.scheme !== 'exact') return { isValid: false, invalidReason: ERROR.invalidScheme };
|
|
220
|
+
if (p.network !== requirement.network) return { isValid: false, invalidReason: ERROR.invalidNetwork };
|
|
221
|
+
const a = p.payload?.authorization;
|
|
222
|
+
const sig = p.payload?.signature;
|
|
223
|
+
if (!a || !sig) return { isValid: false, invalidReason: ERROR.invalidPayload };
|
|
224
|
+
|
|
225
|
+
const now = Math.floor(Date.now() / 1000);
|
|
226
|
+
const validAfter = Number(a.validAfter);
|
|
227
|
+
const validBefore = Number(a.validBefore);
|
|
228
|
+
if (now < validAfter) return { isValid: false, invalidReason: ERROR.invalidValidAfter };
|
|
229
|
+
if (now > validBefore) return { isValid: false, invalidReason: ERROR.invalidValidBefore };
|
|
230
|
+
if (BigInt(a.value) < BigInt(requirement.maxAmountRequired)) {
|
|
231
|
+
return { isValid: false, invalidReason: ERROR.invalidValue };
|
|
232
|
+
}
|
|
233
|
+
if (a.to.toLowerCase() !== requirement.payTo.toLowerCase()) {
|
|
234
|
+
return { isValid: false, invalidReason: ERROR.recipientMismatch };
|
|
235
|
+
}
|
|
236
|
+
if (!/^0x[a-fA-F0-9]{40}$/.test(a.from) || !/^0x[a-fA-F0-9]{64}$/.test(a.nonce)) {
|
|
237
|
+
return { isValid: false, invalidReason: ERROR.invalidPayload };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const chainId = chainIdForNetwork(requirement.network);
|
|
241
|
+
const domain = { name: 'USD Coin', version: '2', chainId, verifyingContract: requirement.asset };
|
|
242
|
+
const types = {
|
|
243
|
+
TransferWithAuthorization: [
|
|
244
|
+
{ name: 'from', type: 'address' },
|
|
245
|
+
{ name: 'to', type: 'address' },
|
|
246
|
+
{ name: 'value', type: 'uint256' },
|
|
247
|
+
{ name: 'validAfter', type: 'uint256' },
|
|
248
|
+
{ name: 'validBefore', type: 'uint256' },
|
|
249
|
+
{ name: 'nonce', type: 'bytes32' },
|
|
250
|
+
],
|
|
251
|
+
};
|
|
252
|
+
const message = { from: a.from, to: a.to, value: a.value, validAfter: a.validAfter, validBefore: a.validBefore, nonce: a.nonce };
|
|
253
|
+
try {
|
|
254
|
+
const signer = verifyTypedData(domain, types, message, sig);
|
|
255
|
+
if (signer.toLowerCase() !== a.from.toLowerCase()) {
|
|
256
|
+
return { isValid: false, invalidReason: ERROR.invalidSignature };
|
|
257
|
+
}
|
|
258
|
+
} catch {
|
|
259
|
+
return { isValid: false, invalidReason: ERROR.invalidSignature };
|
|
260
|
+
}
|
|
261
|
+
return { isValid: true, payer: a.from };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export async function settlePayment(
|
|
265
|
+
payload: PaymentPayload,
|
|
266
|
+
requirement: PaymentRequirements,
|
|
267
|
+
opts: { signer?: Wallet; rpcUrl?: string; confirmations?: number }
|
|
268
|
+
): Promise<SettlementResponse> {
|
|
269
|
+
const a = payload.payload.authorization;
|
|
270
|
+
const network = requirement.network;
|
|
271
|
+
const rpc = opts.rpcUrl ?? process.env.X402_RPC_URL;
|
|
272
|
+
const provider = new JsonRpcProvider(rpc);
|
|
273
|
+
const signer =
|
|
274
|
+
opts.signer ??
|
|
275
|
+
(process.env.X402_GAS_PRIVATE_KEY
|
|
276
|
+
? new Wallet(process.env.X402_GAS_PRIVATE_KEY, provider)
|
|
277
|
+
: undefined);
|
|
278
|
+
if (!signer) {
|
|
279
|
+
return { success: false, errorReason: 'server has no gas signer configured', transaction: '', network, payer: a.from };
|
|
280
|
+
}
|
|
281
|
+
const contract = new Contract(requirement.asset, TRANSFER_WITH_AUTH_ABI, signer);
|
|
282
|
+
const erc20 = contract as unknown as {
|
|
283
|
+
balanceOf: (address: string) => Promise<bigint>;
|
|
284
|
+
transferWithAuthorization: (
|
|
285
|
+
from: string, to: string, value: string, validAfter: string, validBefore: string, nonce: string
|
|
286
|
+
) => Promise<{ hash: string; wait: (confirmations?: number) => Promise<unknown> }>;
|
|
287
|
+
};
|
|
288
|
+
try {
|
|
289
|
+
const balance = await erc20.balanceOf(a.from);
|
|
290
|
+
if (balance < BigInt(a.value)) {
|
|
291
|
+
return { success: false, errorReason: ERROR.insufficientFunds, transaction: '', network, payer: a.from };
|
|
292
|
+
}
|
|
293
|
+
const tx = await erc20.transferWithAuthorization(a.from, a.to, a.value, a.validAfter, a.validBefore, a.nonce);
|
|
294
|
+
await tx.wait(opts.confirmations ?? 1);
|
|
295
|
+
return { success: true, transaction: tx.hash, network, payer: a.from };
|
|
296
|
+
} catch (e) {
|
|
297
|
+
return {
|
|
298
|
+
success: false,
|
|
299
|
+
errorReason: ERROR.invalidTxState,
|
|
300
|
+
transaction: '',
|
|
301
|
+
network,
|
|
302
|
+
payer: a.from,
|
|
303
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
304
|
+
...((e as any)?.shortMessage ? { errorReason: (e as { shortMessage: string }).shortMessage } : {}),
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
}
|