@bankofai/x402-cli 1.0.0-beta.5 → 1.0.0-beta.7
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/dist/cli.js +157 -47
- package/dist/gateway/cli.js +200 -15
- package/dist/gateway/config.js +69 -8
- package/dist/gateway/server.js +132 -23
- package/dist/gateway/tokens.js +31 -2
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -10,8 +10,19 @@ import { fileURLToPath } from "node:url";
|
|
|
10
10
|
import YAML from "yaml";
|
|
11
11
|
import { createPaymentPayload, decodeRequired, decodeResponse, decodeSignature, encodeRequired, encodeResponse, encodeSignature, headers } from "./x402.js";
|
|
12
12
|
import { assertRawAmount, findTokenByAddress, getToken, normalizeNetwork, toSmallestUnit } from "./tokens.js";
|
|
13
|
-
const BOOLEAN_FLAGS = new Set(["daemon", "dry-run", "force", "help", "human", "include-blocked", "json", "version"]);
|
|
13
|
+
const BOOLEAN_FLAGS = new Set(["daemon", "dry-run", "force", "help", "human", "include-blocked", "json", "raw", "version"]);
|
|
14
14
|
const require = createRequire(import.meta.url);
|
|
15
|
+
class CliError extends Error {
|
|
16
|
+
code;
|
|
17
|
+
hint;
|
|
18
|
+
exitCode;
|
|
19
|
+
constructor(code, message, hint, exitCode = 1) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.code = code;
|
|
22
|
+
this.hint = hint;
|
|
23
|
+
this.exitCode = exitCode;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
15
26
|
function parseArgs(argv) {
|
|
16
27
|
const [command = "help", ...rest] = argv;
|
|
17
28
|
const positional = [];
|
|
@@ -88,8 +99,17 @@ function hasFlag(options, key) {
|
|
|
88
99
|
return options[key] === true;
|
|
89
100
|
}
|
|
90
101
|
function outputMode(options) {
|
|
102
|
+
if (hasFlag(options, "json") && hasFlag(options, "human")) {
|
|
103
|
+
throw new CliError("INVALID_ARGUMENT", "--json and --human are mutually exclusive", "Pass either --json or --human, not both.", 2);
|
|
104
|
+
}
|
|
91
105
|
return hasFlag(options, "json") ? "json" : "human";
|
|
92
106
|
}
|
|
107
|
+
function requireArgument(value, name, usage) {
|
|
108
|
+
if (value === undefined || value === "") {
|
|
109
|
+
throw new CliError("MISSING_ARGUMENT", `${name} is required`, `Usage: ${usage}`, 2);
|
|
110
|
+
}
|
|
111
|
+
return value;
|
|
112
|
+
}
|
|
93
113
|
function optAll(options, key) {
|
|
94
114
|
const value = options[key];
|
|
95
115
|
if (Array.isArray(value))
|
|
@@ -144,6 +164,13 @@ function emit(args) {
|
|
|
144
164
|
}
|
|
145
165
|
function classify(error) {
|
|
146
166
|
const message = error instanceof Error ? error.message : String(error);
|
|
167
|
+
if (error instanceof CliError) {
|
|
168
|
+
return {
|
|
169
|
+
code: error.code,
|
|
170
|
+
message,
|
|
171
|
+
hint: error.hint,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
147
174
|
const lower = message.toLowerCase();
|
|
148
175
|
if (lower.includes("missing private key") || lower.includes("could not find a wallet")) {
|
|
149
176
|
return {
|
|
@@ -243,6 +270,13 @@ function classify(error) {
|
|
|
243
270
|
hint: "Check the URL, local server, proxy, and network connectivity.",
|
|
244
271
|
};
|
|
245
272
|
}
|
|
273
|
+
if (lower.includes(" is required") || lower.includes("must be") || lower.includes("invalid --") || lower.includes("mutually exclusive")) {
|
|
274
|
+
return {
|
|
275
|
+
code: lower.includes("required") ? "MISSING_ARGUMENT" : "INVALID_ARGUMENT",
|
|
276
|
+
message,
|
|
277
|
+
hint: "Run the command with --help to see valid usage and options.",
|
|
278
|
+
};
|
|
279
|
+
}
|
|
246
280
|
return {
|
|
247
281
|
code: "IO_ERROR",
|
|
248
282
|
message,
|
|
@@ -275,24 +309,28 @@ Global options:
|
|
|
275
309
|
Options:
|
|
276
310
|
--method <method> HTTP method (default: GET)
|
|
277
311
|
--header "Name: Value" Request header, repeatable
|
|
278
|
-
--body <body> Request body for non-GET methods
|
|
312
|
+
--body <body> Request body for non-GET/HEAD methods
|
|
279
313
|
--network <caip2> Require a specific network
|
|
280
314
|
--token <symbol> Require a specific token
|
|
281
315
|
--scheme <scheme> Require a specific x402 scheme
|
|
282
316
|
--max-amount <amount> Maximum human-readable payment amount
|
|
283
|
-
--max-
|
|
317
|
+
--max-raw-amount <amount> Maximum smallest-unit payment amount
|
|
284
318
|
--dry-run Read requirements but do not sign or pay
|
|
285
|
-
--private-key <hex> Explicit payer private key
|
|
319
|
+
--private-key <hex> Explicit payer private key (or PRIVATE_KEY/TRON_PRIVATE_KEY/EVM_PRIVATE_KEY)
|
|
286
320
|
--rpc-url <url> Explicit network RPC URL
|
|
287
321
|
--json Print JSON envelope
|
|
322
|
+
|
|
323
|
+
Examples:
|
|
324
|
+
x402-cli pay https://api.example.com/paid --dry-run --json
|
|
325
|
+
x402-cli pay https://api.example.com/paid --max-amount 0.01
|
|
288
326
|
`,
|
|
289
327
|
serve: `Usage:
|
|
290
328
|
x402-cli serve --pay-to <address> [options]
|
|
291
329
|
|
|
292
330
|
Options:
|
|
293
331
|
--pay-to <address> Recipient wallet address
|
|
294
|
-
--amount <amount> Human-readable token amount
|
|
295
|
-
--
|
|
332
|
+
--amount <amount> Human-readable token amount (default: 0.0001)
|
|
333
|
+
--raw-amount <amount> Smallest-unit amount
|
|
296
334
|
--network <caip2> Payment network (default: tron:nile)
|
|
297
335
|
--token <symbol> Token symbol (default: USDT)
|
|
298
336
|
--asset <address> Explicit token address
|
|
@@ -303,6 +341,10 @@ Options:
|
|
|
303
341
|
--facilitator-url <url> Facilitator base URL
|
|
304
342
|
--daemon Run in background and print the child pid
|
|
305
343
|
--json Print JSON envelope
|
|
344
|
+
|
|
345
|
+
Examples:
|
|
346
|
+
x402-cli serve --pay-to T... --network tron:nile --token USDT
|
|
347
|
+
x402-cli serve --pay-to 0x... --network eip155:97 --token USDT --amount 0.0001
|
|
306
348
|
`,
|
|
307
349
|
roundtrip: `Usage:
|
|
308
350
|
x402-cli roundtrip --pay-to <address> [serve/pay options]
|
|
@@ -316,6 +358,15 @@ Commands:
|
|
|
316
358
|
check <providers> Validate provider.yml files
|
|
317
359
|
scaffold <name> Write a starter provider.yml
|
|
318
360
|
catalog <command> Build/check/search gateway catalog assets
|
|
361
|
+
`,
|
|
362
|
+
"gateway-catalog": `Usage:
|
|
363
|
+
x402-cli gateway catalog <build|check|pay-assets|search> [options]
|
|
364
|
+
|
|
365
|
+
Commands:
|
|
366
|
+
build <providers> Build a local catalog from provider.yml files
|
|
367
|
+
check <providers> Validate local provider.yml files
|
|
368
|
+
pay-assets <providers> List payable endpoint assets
|
|
369
|
+
search <query> Search a catalog artifact
|
|
319
370
|
`,
|
|
320
371
|
catalog: `Usage:
|
|
321
372
|
x402-cli catalog <update|search|show|endpoints|pay-json|export-gateway|build> [options]
|
|
@@ -335,7 +386,47 @@ Options:
|
|
|
335
386
|
--output-dir <dir> Output directory for generated files
|
|
336
387
|
-n, --limit <count> Search result limit
|
|
337
388
|
--include-blocked Include blocked providers in search
|
|
338
|
-
--json Print JSON envelope
|
|
389
|
+
--json Print JSON envelope
|
|
390
|
+
`,
|
|
391
|
+
"catalog-search": `Usage:
|
|
392
|
+
x402-cli catalog search <query> [--catalog <source>] [options]
|
|
393
|
+
|
|
394
|
+
Options:
|
|
395
|
+
--catalog <source> catalog.json path or URL
|
|
396
|
+
-n, --limit <count> Search result limit
|
|
397
|
+
--include-blocked Include blocked providers in search
|
|
398
|
+
--json Print JSON envelope
|
|
399
|
+
`,
|
|
400
|
+
"catalog-show": `Usage:
|
|
401
|
+
x402-cli catalog show <provider> [--catalog <source>] [options]
|
|
402
|
+
|
|
403
|
+
Options:
|
|
404
|
+
--catalog <source> catalog.json path or URL
|
|
405
|
+
--json Print JSON envelope
|
|
406
|
+
`,
|
|
407
|
+
"catalog-pay-json": `Usage:
|
|
408
|
+
x402-cli catalog pay-json <provider> [--catalog <source>] [options]
|
|
409
|
+
|
|
410
|
+
Options:
|
|
411
|
+
--catalog <source> catalog.json path or URL
|
|
412
|
+
--raw Print raw pay payload instead of JSON envelope
|
|
413
|
+
--json Print JSON envelope
|
|
414
|
+
`,
|
|
415
|
+
"catalog-endpoints": `Usage:
|
|
416
|
+
x402-cli catalog endpoints <provider> [--catalog <source>] [options]
|
|
417
|
+
|
|
418
|
+
Options:
|
|
419
|
+
--catalog <source> catalog.json path or URL
|
|
420
|
+
--json Print JSON envelope
|
|
421
|
+
`,
|
|
422
|
+
"catalog-export-gateway": `Usage:
|
|
423
|
+
x402-cli catalog export-gateway <gateway-url> --provider <fqn> [options]
|
|
424
|
+
|
|
425
|
+
Options:
|
|
426
|
+
--provider <fqn> Provider FQN to export
|
|
427
|
+
--output-dir <dir> Output directory for generated files
|
|
428
|
+
--force Overwrite existing files
|
|
429
|
+
--json Print JSON envelope
|
|
339
430
|
`,
|
|
340
431
|
};
|
|
341
432
|
return sections[topic] ?? sections.root;
|
|
@@ -616,10 +707,10 @@ function defaultCatalogSource() {
|
|
|
616
707
|
function positiveIntegerOption(options, key, fallback) {
|
|
617
708
|
const value = opt(options, key, String(fallback));
|
|
618
709
|
if (!/^\d+$/.test(value))
|
|
619
|
-
throw new
|
|
710
|
+
throw new CliError("INVALID_ARGUMENT", `--${key} must be a positive integer`, `Pass --${key} with a value greater than zero.`, 2);
|
|
620
711
|
const parsed = Number(value);
|
|
621
712
|
if (!Number.isSafeInteger(parsed) || parsed <= 0)
|
|
622
|
-
throw new
|
|
713
|
+
throw new CliError("INVALID_ARGUMENT", `--${key} must be a positive integer`, `Pass --${key} with a value greater than zero.`, 2);
|
|
623
714
|
return parsed;
|
|
624
715
|
}
|
|
625
716
|
function remoteBaseFromCatalogPayload(payload) {
|
|
@@ -808,9 +899,8 @@ function payMarkdownFromDetail(detail) {
|
|
|
808
899
|
return lines.join("\n");
|
|
809
900
|
}
|
|
810
901
|
async function catalogExportGateway(gatewayUrl, options) {
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
throw new Error("--provider is required");
|
|
902
|
+
requireArgument(gatewayUrl, "gateway-url", "x402-cli catalog export-gateway <gateway-url> --provider <fqn> [options]");
|
|
903
|
+
const providerFqn = requireArgument(opt(options, "provider"), "--provider", "x402-cli catalog export-gateway <gateway-url> --provider <fqn> [options]");
|
|
814
904
|
sanitizeProviderName(providerFqn);
|
|
815
905
|
const base = gatewayUrl.replace(/\/+$/, "");
|
|
816
906
|
const detail = await readJson(`${base}/__402/catalog/providers/${providerFilename(providerFqn)}`);
|
|
@@ -846,8 +936,8 @@ function buildRequirement(options) {
|
|
|
846
936
|
const rawAmount = opt(options, "rawAmount") ?? opt(options, "raw-amount");
|
|
847
937
|
const humanAmount = opt(options, "amount");
|
|
848
938
|
if (rawAmount && humanAmount)
|
|
849
|
-
throw new
|
|
850
|
-
const amount = rawAmount ? assertRawAmount(rawAmount, "--
|
|
939
|
+
throw new CliError("INVALID_ARGUMENT", "--amount and --raw-amount are mutually exclusive", "Pass either --amount or --raw-amount, not both.", 2);
|
|
940
|
+
const amount = rawAmount ? assertRawAmount(rawAmount, "--raw-amount") : toSmallestUnit(humanAmount ?? "0.0001", decimals);
|
|
851
941
|
const assetAddress = explicitAsset ?? registryToken.address;
|
|
852
942
|
const assetTransferMethod = registryToken?.assetTransferMethod ?? "permit2";
|
|
853
943
|
return {
|
|
@@ -916,14 +1006,14 @@ function serveDaemon(argv, options) {
|
|
|
916
1006
|
function validateAmountLimits(selected, options) {
|
|
917
1007
|
const maxRaw = opt(options, "max-rawAmount") ?? opt(options, "max-raw-amount");
|
|
918
1008
|
const maxAmount = opt(options, "max-amount");
|
|
919
|
-
if (maxRaw && BigInt(selected.amount) > BigInt(assertRawAmount(maxRaw, "--max-
|
|
920
|
-
throw new Error(`payment raw amount ${selected.amount} exceeds --max-
|
|
1009
|
+
if (maxRaw && BigInt(selected.amount) > BigInt(assertRawAmount(maxRaw, "--max-raw-amount"))) {
|
|
1010
|
+
throw new Error(`payment raw amount ${selected.amount} exceeds --max-raw-amount ${maxRaw}`);
|
|
921
1011
|
}
|
|
922
1012
|
if (maxAmount) {
|
|
923
1013
|
const token = findTokenByAddress(selected.network, selected.asset);
|
|
924
1014
|
const decimalsOption = opt(options, "decimals");
|
|
925
1015
|
if (!token && decimalsOption === undefined) {
|
|
926
|
-
throw new Error("cannot evaluate --max-amount for an unknown asset; pass --max-
|
|
1016
|
+
throw new Error("cannot evaluate --max-amount for an unknown asset; pass --max-raw-amount or --decimals");
|
|
927
1017
|
}
|
|
928
1018
|
const decimals = decimalsOption !== undefined ? Number(decimalsOption) : token.decimals;
|
|
929
1019
|
if (!Number.isInteger(decimals) || decimals < 0)
|
|
@@ -1047,8 +1137,7 @@ function selectRequirement(accepts, options) {
|
|
|
1047
1137
|
return selected;
|
|
1048
1138
|
}
|
|
1049
1139
|
async function pay(url, options) {
|
|
1050
|
-
|
|
1051
|
-
throw new Error("URL is required");
|
|
1140
|
+
requireArgument(url, "URL", "x402-cli pay <url> [options]");
|
|
1052
1141
|
const method = opt(options, "method", "GET");
|
|
1053
1142
|
const baseHeaders = requestHeaders(options);
|
|
1054
1143
|
const probe = await fetch(url, {
|
|
@@ -1259,6 +1348,9 @@ function catalogBuild(target, options) {
|
|
|
1259
1348
|
result: { output, count: providers.length },
|
|
1260
1349
|
});
|
|
1261
1350
|
}
|
|
1351
|
+
else if (outputMode(options) === "json") {
|
|
1352
|
+
emit({ command: "catalog build", mode: "json", result: catalog });
|
|
1353
|
+
}
|
|
1262
1354
|
else {
|
|
1263
1355
|
printJson(catalog);
|
|
1264
1356
|
}
|
|
@@ -1274,16 +1366,11 @@ function catalogPayAssets(target, options) {
|
|
|
1274
1366
|
scheme: "exact",
|
|
1275
1367
|
assetTransferMethod: providerAssetTransferMethod(provider),
|
|
1276
1368
|
})));
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
command: "gateway catalog pay-assets",
|
|
1283
|
-
mode: "human",
|
|
1284
|
-
result: { count: rows.length, assets: rows },
|
|
1285
|
-
});
|
|
1286
|
-
}
|
|
1369
|
+
emit({
|
|
1370
|
+
command: "gateway catalog pay-assets",
|
|
1371
|
+
mode: outputMode(options),
|
|
1372
|
+
result: { count: rows.length, assets: rows },
|
|
1373
|
+
});
|
|
1287
1374
|
}
|
|
1288
1375
|
async function readProviderDetailForSearch(source, fqn) {
|
|
1289
1376
|
try {
|
|
@@ -1382,10 +1469,11 @@ function searchHitToJson(hit) {
|
|
|
1382
1469
|
};
|
|
1383
1470
|
}
|
|
1384
1471
|
async function catalogSearch(source, query, options) {
|
|
1472
|
+
positiveIntegerOption(options, "limit", 10);
|
|
1385
1473
|
const hits = await searchCatalog(source, query, options);
|
|
1386
1474
|
const results = hits.map(searchHitToJson);
|
|
1387
1475
|
if (outputMode(options) === "json") {
|
|
1388
|
-
|
|
1476
|
+
emit({ command: "catalog search", mode: "json", result: { query, catalog: source, count: hits.length, results } });
|
|
1389
1477
|
return;
|
|
1390
1478
|
}
|
|
1391
1479
|
if (!hits.length) {
|
|
@@ -1415,9 +1503,10 @@ async function catalogSearch(source, query, options) {
|
|
|
1415
1503
|
}
|
|
1416
1504
|
}
|
|
1417
1505
|
async function catalogShow(source, name, options) {
|
|
1506
|
+
requireArgument(name, "provider", "x402-cli catalog show <provider> [--catalog <source>]");
|
|
1418
1507
|
const provider = await readCatalogProvider(source, name);
|
|
1419
1508
|
if (outputMode(options) === "json") {
|
|
1420
|
-
|
|
1509
|
+
emit({ command: "catalog show", mode: "json", result: provider });
|
|
1421
1510
|
return;
|
|
1422
1511
|
}
|
|
1423
1512
|
process.stdout.write(`${provider.fqn ?? provider.name} - ${provider.title ?? provider.main_title ?? provider.name}\n`);
|
|
@@ -1429,10 +1518,11 @@ async function catalogShow(source, name, options) {
|
|
|
1429
1518
|
process.stdout.write(`chains: ${provider.chains.join(", ")}\n`);
|
|
1430
1519
|
}
|
|
1431
1520
|
async function catalogEndpoints(source, name, options) {
|
|
1521
|
+
requireArgument(name, "provider", "x402-cli catalog endpoints <provider> [--catalog <source>]");
|
|
1432
1522
|
const provider = await readCatalogProvider(source, name);
|
|
1433
1523
|
const endpoints = provider.endpoints ?? [];
|
|
1434
1524
|
if (outputMode(options) === "json") {
|
|
1435
|
-
|
|
1525
|
+
emit({ command: "catalog endpoints", mode: "json", result: { provider: provider.fqn ?? provider.name, endpoints } });
|
|
1436
1526
|
return;
|
|
1437
1527
|
}
|
|
1438
1528
|
for (const endpoint of endpoints) {
|
|
@@ -1441,28 +1531,33 @@ async function catalogEndpoints(source, name, options) {
|
|
|
1441
1531
|
process.stdout.write(` ${String(endpoint.description).split("\n")[0]}\n`);
|
|
1442
1532
|
}
|
|
1443
1533
|
}
|
|
1444
|
-
async function catalogPayJson(source, name) {
|
|
1534
|
+
async function catalogPayJson(source, name, options) {
|
|
1535
|
+
requireArgument(name, "provider", "x402-cli catalog pay-json <provider> [--catalog <source>]");
|
|
1445
1536
|
const provider = await readCatalogPayProvider(source, name);
|
|
1446
1537
|
const endpoint = (provider.endpoints ?? []).find((item) => item.paid || item.x402_routes?.length || item.x402Routes?.length) ?? provider.endpoints?.[0];
|
|
1447
1538
|
if (!endpoint)
|
|
1448
1539
|
throw new Error(`provider has no endpoints: ${name}`);
|
|
1449
|
-
|
|
1540
|
+
const result = {
|
|
1450
1541
|
provider: provider.fqn ?? provider.name,
|
|
1451
1542
|
url: endpoint.url ?? endpoint.path,
|
|
1452
1543
|
method: endpoint.method,
|
|
1453
1544
|
paid: endpoint.paid,
|
|
1454
1545
|
x402_routes: endpoint.x402_routes ?? endpoint.x402Routes ?? [],
|
|
1455
1546
|
endpoint,
|
|
1456
|
-
}
|
|
1547
|
+
};
|
|
1548
|
+
if (hasFlag(options, "raw"))
|
|
1549
|
+
printJson(result);
|
|
1550
|
+
else
|
|
1551
|
+
emit({ command: "catalog pay-json", mode: outputMode(options), result });
|
|
1457
1552
|
}
|
|
1458
1553
|
async function handleGateway(args) {
|
|
1459
1554
|
const { command, positional, options } = parseArgs(args);
|
|
1460
1555
|
if (hasFlag(options, "help") || command === "help") {
|
|
1461
|
-
process.stdout.write(helpText("gateway"));
|
|
1556
|
+
process.stdout.write(helpText(command === "catalog" || positional[0] === "catalog" ? "gateway-catalog" : "gateway"));
|
|
1462
1557
|
return;
|
|
1463
1558
|
}
|
|
1464
1559
|
if (command === "search")
|
|
1465
|
-
await catalogSearch(opt(options, "catalog", defaultCatalogSource()), positional.join(" "), options);
|
|
1560
|
+
await catalogSearch(opt(options, "catalog", defaultCatalogSource()), requireArgument(positional.join(" "), "query", "x402-cli gateway search <query> [options]"), options);
|
|
1466
1561
|
else if (command === "start")
|
|
1467
1562
|
await gatewayStart(["--providers", opt(options, "providers", opt(options, "providers-dir", positional[0] ?? "providers")), "--host", opt(options, "host", "127.0.0.1"), "--port", opt(options, "port", "4020")], options);
|
|
1468
1563
|
else if (command === "check")
|
|
@@ -1472,7 +1567,7 @@ async function handleGateway(args) {
|
|
|
1472
1567
|
else if (command === "catalog")
|
|
1473
1568
|
await handleGatewayCatalog(positional, options);
|
|
1474
1569
|
else
|
|
1475
|
-
throw new
|
|
1570
|
+
throw new CliError("UNKNOWN_COMMAND", `Unknown gateway command: ${command}`, "Run x402-cli gateway --help to list commands.", 2);
|
|
1476
1571
|
}
|
|
1477
1572
|
async function handleGatewayCatalog(positional, options) {
|
|
1478
1573
|
const sub = positional[0] ?? "build";
|
|
@@ -1484,37 +1579,46 @@ async function handleGatewayCatalog(positional, options) {
|
|
|
1484
1579
|
else if (sub === "pay-assets")
|
|
1485
1580
|
catalogPayAssets(target, options);
|
|
1486
1581
|
else if (sub === "search")
|
|
1487
|
-
await catalogSearch(opt(options, "catalog", defaultCatalogSource()), positional.slice(2).join(" ") || opt(options, "query", ""), options);
|
|
1582
|
+
await catalogSearch(opt(options, "catalog", defaultCatalogSource()), requireArgument(positional.slice(2).join(" ") || opt(options, "query"), "query", "x402-cli gateway catalog search <query> [options]"), options);
|
|
1488
1583
|
else
|
|
1489
|
-
throw new
|
|
1584
|
+
throw new CliError("UNKNOWN_COMMAND", `Unknown gateway catalog command: ${sub}`, "Run x402-cli gateway catalog --help to list commands.", 2);
|
|
1490
1585
|
}
|
|
1491
1586
|
async function handleCatalog(args) {
|
|
1492
1587
|
const { command, positional, options } = parseArgs(args);
|
|
1493
1588
|
if (hasFlag(options, "help") || command === "help") {
|
|
1494
|
-
|
|
1589
|
+
const topic = command === "help" ? positional[0] : command;
|
|
1590
|
+
process.stdout.write(helpText(topic ? `catalog-${topic}` : "catalog"));
|
|
1495
1591
|
return;
|
|
1496
1592
|
}
|
|
1497
1593
|
const source = opt(options, "catalog", defaultCatalogSource());
|
|
1498
1594
|
if (command === "update")
|
|
1499
1595
|
await catalogUpdate(source, options);
|
|
1500
1596
|
else if (command === "search")
|
|
1501
|
-
await catalogSearch(source, positional.join(" "), options);
|
|
1597
|
+
await catalogSearch(source, requireArgument(positional.join(" "), "query", "x402-cli catalog search <query> [options]"), options);
|
|
1502
1598
|
else if (command === "show")
|
|
1503
1599
|
await catalogShow(source, positional[0], options);
|
|
1504
1600
|
else if (command === "endpoints")
|
|
1505
1601
|
await catalogEndpoints(source, positional[0], options);
|
|
1506
1602
|
else if (command === "pay-json")
|
|
1507
|
-
await catalogPayJson(source, positional[0]);
|
|
1603
|
+
await catalogPayJson(source, positional[0], options);
|
|
1508
1604
|
else if (command === "export-gateway")
|
|
1509
1605
|
await catalogExportGateway(positional[0], options);
|
|
1510
1606
|
else if (command === "build")
|
|
1511
1607
|
catalogBuild(positional[0] ?? "providers", options);
|
|
1512
1608
|
else
|
|
1513
|
-
throw new
|
|
1609
|
+
throw new CliError("UNKNOWN_COMMAND", `Unknown catalog command: ${command}`, "Run x402-cli catalog --help to list commands.", 2);
|
|
1514
1610
|
}
|
|
1515
1611
|
async function main() {
|
|
1516
1612
|
const argv = process.argv.slice(2);
|
|
1517
1613
|
const { command, positional, options } = parseArgs(argv);
|
|
1614
|
+
if (hasFlag(options, "help") && command === "gateway") {
|
|
1615
|
+
await handleGateway(argv.slice(1));
|
|
1616
|
+
return;
|
|
1617
|
+
}
|
|
1618
|
+
if (hasFlag(options, "help") && command === "catalog") {
|
|
1619
|
+
await handleCatalog(argv.slice(1));
|
|
1620
|
+
return;
|
|
1621
|
+
}
|
|
1518
1622
|
if (command === "--help" || command === "-h" || command === "help" || hasFlag(options, "help")) {
|
|
1519
1623
|
const topic = command === "help" ? positional[0] : command.startsWith("-") ? undefined : command;
|
|
1520
1624
|
process.stdout.write(helpText(topic));
|
|
@@ -1539,14 +1643,20 @@ async function main() {
|
|
|
1539
1643
|
else if (command === "catalog")
|
|
1540
1644
|
await handleCatalog(argv.slice(1));
|
|
1541
1645
|
else {
|
|
1542
|
-
|
|
1646
|
+
throw new CliError("UNKNOWN_COMMAND", `Unknown command: ${command}`, "Run x402-cli --help to list commands.", 2);
|
|
1543
1647
|
}
|
|
1544
1648
|
}
|
|
1649
|
+
function errorCommandName(argv) {
|
|
1650
|
+
const [first, second] = argv;
|
|
1651
|
+
if ((first === "catalog" || first === "gateway") && second && !second.startsWith("-"))
|
|
1652
|
+
return `${first} ${second}`;
|
|
1653
|
+
return first ?? "x402-cli";
|
|
1654
|
+
}
|
|
1545
1655
|
main().catch(error => {
|
|
1546
1656
|
emit({
|
|
1547
|
-
command: process.argv
|
|
1657
|
+
command: errorCommandName(process.argv.slice(2)),
|
|
1548
1658
|
mode: process.argv.includes("--json") ? "json" : "human",
|
|
1549
1659
|
error: classify(error),
|
|
1550
1660
|
});
|
|
1551
|
-
process.exit(1);
|
|
1661
|
+
process.exit(error instanceof CliError ? error.exitCode : 1);
|
|
1552
1662
|
});
|
package/dist/gateway/cli.js
CHANGED
|
@@ -1,33 +1,218 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs";
|
|
2
3
|
import { loadProviders } from "./config.js";
|
|
3
4
|
import { createGatewayServer } from "./server.js";
|
|
5
|
+
class CliError extends Error {
|
|
6
|
+
exitCode;
|
|
7
|
+
constructor(message, exitCode = 2) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.exitCode = exitCode;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
const VALUE_OPTIONS = new Set(["provider", "providers", "host", "port"]);
|
|
13
|
+
const BOOLEAN_OPTIONS = new Set(["debug", "help", "json", "quiet", "version"]);
|
|
14
|
+
const ALL_OPTIONS = new Set([...VALUE_OPTIONS, ...BOOLEAN_OPTIONS]);
|
|
4
15
|
function parseArgs(argv) {
|
|
16
|
+
const explicitCommand = Boolean(argv[0] && !argv[0].startsWith("-"));
|
|
17
|
+
const command = explicitCommand ? argv[0] : "start";
|
|
18
|
+
const rest = explicitCommand ? argv.slice(1) : argv;
|
|
5
19
|
const options = {};
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
20
|
+
if (command !== "start" && command !== "check") {
|
|
21
|
+
throw new CliError(`Unknown command: ${command}`);
|
|
22
|
+
}
|
|
23
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
24
|
+
const item = rest[i];
|
|
25
|
+
if (item === "-h") {
|
|
26
|
+
options.help = true;
|
|
9
27
|
continue;
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
28
|
+
}
|
|
29
|
+
if (item === "-v" || item === "-V") {
|
|
30
|
+
options.version = true;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (!item.startsWith("--"))
|
|
34
|
+
throw new CliError(`Unexpected argument: ${item}`);
|
|
35
|
+
const eq = item.indexOf("=");
|
|
36
|
+
const key = eq > 2 ? item.slice(2, eq) : item.slice(2);
|
|
37
|
+
if (!ALL_OPTIONS.has(key))
|
|
38
|
+
throw new CliError(`Unknown option: --${key}`);
|
|
39
|
+
if (BOOLEAN_OPTIONS.has(key)) {
|
|
40
|
+
if (eq > 2)
|
|
41
|
+
throw new CliError(`Option --${key} does not take a value`);
|
|
13
42
|
options[key] = true;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
const inline = eq > 2 ? item.slice(eq + 1) : undefined;
|
|
46
|
+
const next = rest[i + 1];
|
|
47
|
+
if (inline !== undefined) {
|
|
48
|
+
if (!inline)
|
|
49
|
+
throw new CliError(`Option --${key} requires a value`);
|
|
50
|
+
options[key] = inline;
|
|
51
|
+
}
|
|
14
52
|
else {
|
|
53
|
+
if (!next || next.startsWith("--"))
|
|
54
|
+
throw new CliError(`Option --${key} requires a value`);
|
|
15
55
|
options[key] = next;
|
|
16
56
|
i += 1;
|
|
17
57
|
}
|
|
18
58
|
}
|
|
19
|
-
return options;
|
|
59
|
+
return { command, options };
|
|
20
60
|
}
|
|
21
61
|
function opt(options, key, fallback) {
|
|
22
62
|
const value = options[key];
|
|
23
63
|
return typeof value === "string" ? value : fallback;
|
|
24
64
|
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
65
|
+
function flag(options, key) {
|
|
66
|
+
return options[key] === true;
|
|
67
|
+
}
|
|
68
|
+
function version() {
|
|
69
|
+
try {
|
|
70
|
+
const url = new URL("../package.json", import.meta.url);
|
|
71
|
+
return String(JSON.parse(fs.readFileSync(url, "utf8")).version ?? "0.0.0");
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return "0.0.0";
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function help() {
|
|
78
|
+
return `x402-gateway ${version()}
|
|
79
|
+
|
|
80
|
+
Usage:
|
|
81
|
+
x402-gateway start --providers <dir> [options]
|
|
82
|
+
x402-gateway --providers <dir> [options]
|
|
83
|
+
x402-gateway check --providers <dir>
|
|
84
|
+
x402-gateway check --provider <file>
|
|
85
|
+
|
|
86
|
+
Commands:
|
|
87
|
+
start Start the x402 gateway server (default)
|
|
88
|
+
check Validate provider YAML and print a summary
|
|
89
|
+
|
|
90
|
+
Options:
|
|
91
|
+
--provider <file> Load one provider YAML file
|
|
92
|
+
--providers <dir> Load provider.yml/provider.yaml files from a directory
|
|
93
|
+
--host <host> Bind host (default: 127.0.0.1)
|
|
94
|
+
--port <port> Bind port (default: 8080)
|
|
95
|
+
--json Print machine-readable JSON
|
|
96
|
+
--quiet Suppress startup/shutdown messages
|
|
97
|
+
--debug Print stack traces for startup errors
|
|
98
|
+
-h, --help Show help
|
|
99
|
+
-v, -V, --version Show version
|
|
100
|
+
|
|
101
|
+
Examples:
|
|
102
|
+
x402-gateway --provider examples/provider.yml --host 127.0.0.1 --port 4020
|
|
103
|
+
x402-gateway start --providers providers --port 4020
|
|
104
|
+
x402-gateway check --providers providers --json
|
|
105
|
+
`;
|
|
106
|
+
}
|
|
107
|
+
function providerPath(options) {
|
|
108
|
+
const source = opt(options, "providers", opt(options, "provider", process.env.X402_GATEWAY_PROVIDERS_DIR));
|
|
109
|
+
if (!source)
|
|
110
|
+
throw new CliError("No provider source configured. Use --provider <file> or --providers <dir>.");
|
|
111
|
+
return source;
|
|
112
|
+
}
|
|
113
|
+
function parsePort(value) {
|
|
114
|
+
const raw = value ?? process.env.PORT ?? "8080";
|
|
115
|
+
if (!/^\d+$/.test(raw))
|
|
116
|
+
throw new CliError(`Invalid --port ${raw}; expected an integer between 1 and 65535`);
|
|
117
|
+
const port = Number(raw);
|
|
118
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
119
|
+
throw new CliError(`Invalid --port ${raw}; expected an integer between 1 and 65535`);
|
|
120
|
+
}
|
|
121
|
+
return port;
|
|
122
|
+
}
|
|
123
|
+
function publicHost(host) {
|
|
124
|
+
return host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host;
|
|
125
|
+
}
|
|
126
|
+
function printStartup(options, host, port, providers) {
|
|
127
|
+
if (flag(options, "quiet"))
|
|
128
|
+
return;
|
|
129
|
+
if (flag(options, "json")) {
|
|
130
|
+
process.stdout.write(JSON.stringify({ ok: true, host, port, providers }, null, 2) + "\n");
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const base = `http://${publicHost(host)}:${port}`;
|
|
134
|
+
process.stdout.write(`x402-gateway listening on ${base}\n`);
|
|
135
|
+
process.stdout.write(`providers: ${providers.length} loaded\n`);
|
|
136
|
+
process.stdout.write(`health: ${base}/__402/health\n`);
|
|
137
|
+
process.stdout.write(`ready: ${base}/__402/ready\n`);
|
|
138
|
+
}
|
|
139
|
+
function check(options) {
|
|
140
|
+
const source = providerPath(options);
|
|
141
|
+
const providers = loadProviders(source);
|
|
142
|
+
const summary = [...providers.values()].map(entry => ({
|
|
143
|
+
name: entry.config.name,
|
|
144
|
+
network: entry.config.operator.network,
|
|
145
|
+
endpoints: entry.config.endpoints?.length ?? 0,
|
|
146
|
+
}));
|
|
147
|
+
if (flag(options, "json")) {
|
|
148
|
+
process.stdout.write(JSON.stringify({ ok: true, source, count: summary.length, providers: summary }, null, 2) + "\n");
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
process.stdout.write(`ok: ${summary.length} provider${summary.length === 1 ? "" : "s"} loaded from ${source}\n`);
|
|
152
|
+
for (const item of summary) {
|
|
153
|
+
process.stdout.write(` ${item.name} (${item.network}) endpoints=${item.endpoints}\n`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
async function start(options) {
|
|
157
|
+
const source = providerPath(options);
|
|
158
|
+
const host = opt(options, "host", process.env.X402_GATEWAY_HOST || "127.0.0.1");
|
|
159
|
+
const port = parsePort(opt(options, "port"));
|
|
160
|
+
const providers = loadProviders(source);
|
|
161
|
+
const server = createGatewayServer(providers);
|
|
162
|
+
await new Promise((resolve, reject) => {
|
|
163
|
+
server.once("error", reject);
|
|
164
|
+
server.listen(port, host, () => {
|
|
165
|
+
server.off("error", reject);
|
|
166
|
+
printStartup(options, host, port, [...providers.keys()]);
|
|
167
|
+
resolve();
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
const shutdown = (signal) => {
|
|
171
|
+
if (!flag(options, "quiet") && !flag(options, "json"))
|
|
172
|
+
process.stderr.write(`received ${signal}, shutting down...\n`);
|
|
173
|
+
server.close(() => {
|
|
174
|
+
if (!flag(options, "quiet") && !flag(options, "json"))
|
|
175
|
+
process.stderr.write("server closed\n");
|
|
176
|
+
process.exit(0);
|
|
177
|
+
});
|
|
178
|
+
};
|
|
179
|
+
process.once("SIGINT", shutdown);
|
|
180
|
+
process.once("SIGTERM", shutdown);
|
|
181
|
+
}
|
|
182
|
+
async function main() {
|
|
183
|
+
const parsed = parseArgs(process.argv.slice(2));
|
|
184
|
+
if (flag(parsed.options, "help")) {
|
|
185
|
+
process.stdout.write(help());
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (flag(parsed.options, "version")) {
|
|
189
|
+
process.stdout.write(`${version()}\n`);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (parsed.command === "check")
|
|
193
|
+
check(parsed.options);
|
|
194
|
+
else
|
|
195
|
+
await start(parsed.options);
|
|
196
|
+
}
|
|
197
|
+
main().catch(error => {
|
|
198
|
+
const parsed = (() => {
|
|
199
|
+
try {
|
|
200
|
+
return parseArgs(process.argv.slice(2));
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
return { options: {} };
|
|
204
|
+
}
|
|
205
|
+
})();
|
|
206
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
207
|
+
if (flag(parsed.options, "json")) {
|
|
208
|
+
process.stdout.write(JSON.stringify({ ok: false, error: { message } }, null, 2) + "\n");
|
|
209
|
+
}
|
|
210
|
+
else {
|
|
211
|
+
process.stderr.write(`x402-gateway: ${message}\n`);
|
|
212
|
+
process.stderr.write("Run `x402-gateway --help` for usage.\n");
|
|
213
|
+
if (flag(parsed.options, "debug") && error instanceof Error && error.stack) {
|
|
214
|
+
process.stderr.write(`${error.stack}\n`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
process.exit(error instanceof CliError ? error.exitCode : 1);
|
|
33
218
|
});
|
package/dist/gateway/config.js
CHANGED
|
@@ -3,7 +3,11 @@ import path from "node:path";
|
|
|
3
3
|
import YAML from "yaml";
|
|
4
4
|
import { getToken, normalizeNetwork, toSmallestUnit } from "./tokens.js";
|
|
5
5
|
function expandEnv(value) {
|
|
6
|
-
return value.replace(/\$\{([^}]+)\}/g, (_, name) =>
|
|
6
|
+
return value.replace(/\$\{([^}]+)\}/g, (_, name) => {
|
|
7
|
+
if (!(name in process.env))
|
|
8
|
+
throw new Error(`environment variable \${${name}} is not set`);
|
|
9
|
+
return process.env[name] ?? "";
|
|
10
|
+
});
|
|
7
11
|
}
|
|
8
12
|
function expandDeep(value) {
|
|
9
13
|
if (typeof value === "string")
|
|
@@ -19,11 +23,63 @@ function assertString(value, name) {
|
|
|
19
23
|
if (typeof value !== "string" || !value.trim())
|
|
20
24
|
throw new Error(`${name} is required`);
|
|
21
25
|
}
|
|
26
|
+
function assertHttpUrl(value, name) {
|
|
27
|
+
if (!value)
|
|
28
|
+
return;
|
|
29
|
+
try {
|
|
30
|
+
const url = new URL(value);
|
|
31
|
+
if (!["http:", "https:"].includes(url.protocol))
|
|
32
|
+
throw new Error("unsupported protocol");
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
throw new Error(`${name} must be a valid http(s) URL`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function validateTiers(tiers, file, path) {
|
|
39
|
+
if (!Array.isArray(tiers) || !tiers.length)
|
|
40
|
+
throw new Error(`${file}: ${path}.tiers must contain at least one tier`);
|
|
41
|
+
for (const [tierIndex, tier] of tiers.entries()) {
|
|
42
|
+
const price = tier?.price_usd;
|
|
43
|
+
if (typeof price !== "number" || !Number.isFinite(price) || price < 0) {
|
|
44
|
+
throw new Error(`${file}: ${path}.tiers[${tierIndex}].price_usd must be a finite number >= 0`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function validateDimensions(dimensions, file, path) {
|
|
49
|
+
if (!Array.isArray(dimensions) || !dimensions.length) {
|
|
50
|
+
throw new Error(`${file}: ${path}.dimensions must contain at least one dimension`);
|
|
51
|
+
}
|
|
52
|
+
if (dimensions.length !== 1)
|
|
53
|
+
throw new Error(`${file}: ${path}.dimensions currently supports exactly one dimension`);
|
|
54
|
+
for (const [dimensionIndex, dimension] of dimensions.entries()) {
|
|
55
|
+
validateTiers(dimension?.tiers, file, `${path}.dimensions[${dimensionIndex}]`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function validateMetering(endpoint, file, path) {
|
|
59
|
+
if (!endpoint.metering)
|
|
60
|
+
return;
|
|
61
|
+
validateDimensions(endpoint.metering.dimensions, file, `${path}.metering`);
|
|
62
|
+
for (const [variantIndex, variant] of (endpoint.metering.variants ?? []).entries()) {
|
|
63
|
+
assertString(variant.param, `${file}: ${path}.metering.variants[${variantIndex}].param`);
|
|
64
|
+
assertString(variant.value, `${file}: ${path}.metering.variants[${variantIndex}].value`);
|
|
65
|
+
validateDimensions(variant.dimensions, file, `${path}.metering.variants[${variantIndex}]`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
22
68
|
function validateProvider(config, file) {
|
|
23
69
|
assertString(config.name, `${file}: name`);
|
|
24
70
|
assertString(config.forward_url, `${file}: forward_url`);
|
|
25
71
|
assertString(config.operator?.network, `${file}: operator.network`);
|
|
26
72
|
assertString(config.operator?.recipient, `${file}: operator.recipient`);
|
|
73
|
+
assertHttpUrl(config.forward_url, `${file}: forward_url`);
|
|
74
|
+
assertHttpUrl(config.operator?.facilitator_url, `${file}: operator.facilitator_url`);
|
|
75
|
+
if (config.operator?.valid_for_seconds !== undefined &&
|
|
76
|
+
(!Number.isFinite(config.operator.valid_for_seconds) || config.operator.valid_for_seconds <= 0)) {
|
|
77
|
+
throw new Error(`${file}: operator.valid_for_seconds must be > 0`);
|
|
78
|
+
}
|
|
79
|
+
const authMethod = config.routing?.auth?.method;
|
|
80
|
+
if (authMethod && !["header", "query_param", "access_token", "oauth2"].includes(authMethod)) {
|
|
81
|
+
throw new Error(`${file}: unsupported routing.auth.method ${authMethod}`);
|
|
82
|
+
}
|
|
27
83
|
if (!config.endpoints?.length)
|
|
28
84
|
throw new Error(`${file}: endpoints must contain at least one endpoint`);
|
|
29
85
|
const seen = new Set();
|
|
@@ -40,11 +96,7 @@ function validateProvider(config, file) {
|
|
|
40
96
|
if (seen.has(key))
|
|
41
97
|
throw new Error(`${file}: duplicate endpoint ${key}`);
|
|
42
98
|
seen.add(key);
|
|
43
|
-
|
|
44
|
-
if (typeof tier.price_usd !== "number" || tier.price_usd < 0) {
|
|
45
|
-
throw new Error(`${file}: endpoints[${index}].metering tier ${tierIndex} price_usd must be >= 0`);
|
|
46
|
-
}
|
|
47
|
-
}
|
|
99
|
+
validateMetering(endpoint, file, `endpoints[${index}]`);
|
|
48
100
|
}
|
|
49
101
|
}
|
|
50
102
|
export function loadProvider(file) {
|
|
@@ -111,8 +163,14 @@ function pathMatches(template, routePath) {
|
|
|
111
163
|
});
|
|
112
164
|
}
|
|
113
165
|
export function priceUsd(endpoint, params = {}) {
|
|
166
|
+
if (!endpoint?.metering)
|
|
167
|
+
return 0;
|
|
114
168
|
const variant = endpoint?.metering?.variants?.find(item => params[item.param] === item.value);
|
|
115
|
-
|
|
169
|
+
const price = (variant?.dimensions ?? endpoint.metering.dimensions)?.[0]?.tiers?.[0]?.price_usd;
|
|
170
|
+
if (typeof price !== "number" || !Number.isFinite(price) || price < 0) {
|
|
171
|
+
throw new Error(`invalid metering price for ${endpoint.method} ${endpoint.path}`);
|
|
172
|
+
}
|
|
173
|
+
return price;
|
|
116
174
|
}
|
|
117
175
|
export function paymentRequirements(provider, price) {
|
|
118
176
|
if (price <= 0)
|
|
@@ -123,10 +181,13 @@ export function paymentRequirements(provider, price) {
|
|
|
123
181
|
return symbols.map(symbol => {
|
|
124
182
|
const token = getToken(network, symbol);
|
|
125
183
|
const transferMethod = provider.operator.assetTransferMethod || provider.operator.asset_transfer_method || token.assetTransferMethod;
|
|
184
|
+
const amount = toSmallestUnit(price, token.decimals);
|
|
185
|
+
if (amount === "0")
|
|
186
|
+
throw new Error(`positive price produced zero amount for ${symbol} on ${network}`);
|
|
126
187
|
return {
|
|
127
188
|
scheme: "exact",
|
|
128
189
|
network,
|
|
129
|
-
amount
|
|
190
|
+
amount,
|
|
130
191
|
asset: token.address,
|
|
131
192
|
payTo,
|
|
132
193
|
maxTimeoutSeconds: provider.operator.valid_for_seconds ?? 300,
|
package/dist/gateway/server.js
CHANGED
|
@@ -2,37 +2,106 @@ import http from "node:http";
|
|
|
2
2
|
import { URL } from "node:url";
|
|
3
3
|
import { endpointFor, paymentRequirements, priceUsd } from "./config.js";
|
|
4
4
|
import { decodeSignature, encodeRequired, encodeResponse, headers, matchRequirement } from "./x402.js";
|
|
5
|
+
class HttpError extends Error {
|
|
6
|
+
status;
|
|
7
|
+
publicMessage;
|
|
8
|
+
constructor(status, publicMessage, message = publicMessage) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.status = status;
|
|
11
|
+
this.publicMessage = publicMessage;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
class RequestTooLargeError extends HttpError {
|
|
15
|
+
constructor() {
|
|
16
|
+
super(413, "request body too large");
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
const MAX_BODY_BYTES = Number(process.env.X402_GATEWAY_MAX_BODY_BYTES ?? 1_000_000);
|
|
20
|
+
const FACILITATOR_TIMEOUT_MS = Number(process.env.X402_GATEWAY_FACILITATOR_TIMEOUT_MS ?? 10_000);
|
|
21
|
+
const UPSTREAM_TIMEOUT_MS = Number(process.env.X402_GATEWAY_UPSTREAM_TIMEOUT_MS ?? 30_000);
|
|
22
|
+
const STRIP_REQUEST_HEADERS = new Set([
|
|
23
|
+
"host",
|
|
24
|
+
"connection",
|
|
25
|
+
"transfer-encoding",
|
|
26
|
+
"content-length",
|
|
27
|
+
"authorization",
|
|
28
|
+
"proxy-authorization",
|
|
29
|
+
"cookie",
|
|
30
|
+
"x-api-key",
|
|
31
|
+
"api-key",
|
|
32
|
+
"apikey",
|
|
33
|
+
"x-auth-token",
|
|
34
|
+
"x-access-token",
|
|
35
|
+
"x-payment",
|
|
36
|
+
"payment-signature",
|
|
37
|
+
"payment-required",
|
|
38
|
+
"x-payment-required",
|
|
39
|
+
"accept-encoding",
|
|
40
|
+
]);
|
|
41
|
+
const STRIP_RESPONSE_HEADERS = new Set([
|
|
42
|
+
"connection",
|
|
43
|
+
"transfer-encoding",
|
|
44
|
+
"content-encoding",
|
|
45
|
+
"content-length",
|
|
46
|
+
"authorization",
|
|
47
|
+
"proxy-authorization",
|
|
48
|
+
"set-cookie",
|
|
49
|
+
]);
|
|
5
50
|
const metrics = {
|
|
6
51
|
requests: 0,
|
|
7
52
|
paidRequests: 0,
|
|
8
53
|
verifyFailures: 0,
|
|
9
54
|
settleFailures: 0,
|
|
55
|
+
feeQuoteFailures: 0,
|
|
10
56
|
upstreamFailures: 0,
|
|
11
57
|
};
|
|
12
58
|
async function readBody(request) {
|
|
13
59
|
const chunks = [];
|
|
14
|
-
|
|
15
|
-
|
|
60
|
+
let total = 0;
|
|
61
|
+
for await (const chunk of request) {
|
|
62
|
+
const buffer = Buffer.from(chunk);
|
|
63
|
+
total += buffer.length;
|
|
64
|
+
if (total > MAX_BODY_BYTES)
|
|
65
|
+
throw new RequestTooLargeError();
|
|
66
|
+
chunks.push(buffer);
|
|
67
|
+
}
|
|
16
68
|
return Buffer.concat(chunks);
|
|
17
69
|
}
|
|
18
70
|
function json(response, status, body, extraHeaders = {}) {
|
|
19
71
|
response.writeHead(status, { "content-type": "application/json", ...extraHeaders });
|
|
20
72
|
response.end(JSON.stringify(body));
|
|
21
73
|
}
|
|
74
|
+
async function fetchWithTimeout(url, init, timeoutMs, label = "upstream") {
|
|
75
|
+
const controller = new AbortController();
|
|
76
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
77
|
+
try {
|
|
78
|
+
return await fetch(url, { ...init, signal: controller.signal });
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
if (error?.name === "AbortError")
|
|
82
|
+
throw new HttpError(504, `${label} request timed out`);
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
clearTimeout(timer);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
22
89
|
async function facilitatorPost(entry, path, body) {
|
|
23
90
|
const headers = { "content-type": "application/json" };
|
|
24
91
|
if (entry.facilitatorApiKey) {
|
|
25
92
|
headers.authorization = `Bearer ${entry.facilitatorApiKey}`;
|
|
26
93
|
}
|
|
27
|
-
const response = await
|
|
28
|
-
method: "POST",
|
|
29
|
-
headers,
|
|
30
|
-
body: JSON.stringify(body),
|
|
31
|
-
});
|
|
94
|
+
const response = await fetchWithTimeout(new URL(path, entry.facilitatorUrl), { method: "POST", headers, body: JSON.stringify(body) }, FACILITATOR_TIMEOUT_MS, "facilitator");
|
|
32
95
|
const text = await response.text();
|
|
33
|
-
|
|
96
|
+
let data = {};
|
|
97
|
+
try {
|
|
98
|
+
data = text ? JSON.parse(text) : {};
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
throw new HttpError(502, "facilitator returned invalid response");
|
|
102
|
+
}
|
|
34
103
|
if (!response.ok)
|
|
35
|
-
throw new
|
|
104
|
+
throw new HttpError(502, "facilitator request failed", `facilitator ${path} failed: ${response.status} ${text}`);
|
|
36
105
|
return data;
|
|
37
106
|
}
|
|
38
107
|
async function attachFeeQuotes(entry, requirements, context) {
|
|
@@ -49,14 +118,25 @@ async function attachFeeQuotes(entry, requirements, context) {
|
|
|
49
118
|
return quote?.fee ? { ...requirement, extra: { ...requirement.extra, fee: quote.fee } } : requirement;
|
|
50
119
|
});
|
|
51
120
|
}
|
|
52
|
-
catch {
|
|
121
|
+
catch (error) {
|
|
122
|
+
metrics.feeQuoteFailures += 1;
|
|
123
|
+
console.warn("fee quote failed", error);
|
|
53
124
|
return requirements;
|
|
54
125
|
}
|
|
55
126
|
}
|
|
127
|
+
function isVerifySuccess(verify) {
|
|
128
|
+
return verify?.valid === true || verify?.isValid === true;
|
|
129
|
+
}
|
|
130
|
+
function isSettleSuccess(settle) {
|
|
131
|
+
return (settle?.success === true ||
|
|
132
|
+
settle?.settled === true ||
|
|
133
|
+
(typeof settle?.transaction === "string" && settle.transaction.length > 0) ||
|
|
134
|
+
(typeof settle?.txHash === "string" && settle.txHash.length > 0));
|
|
135
|
+
}
|
|
56
136
|
function isAdminAllowed(request) {
|
|
57
137
|
const token = process.env.X402_GATEWAY_ADMIN_TOKEN;
|
|
58
138
|
if (!token)
|
|
59
|
-
return true;
|
|
139
|
+
return process.env.X402_GATEWAY_ADMIN_ALLOW_PUBLIC === "true";
|
|
60
140
|
const auth = request.headers.authorization ?? "";
|
|
61
141
|
return auth === `Bearer ${token}`;
|
|
62
142
|
}
|
|
@@ -89,7 +169,7 @@ function upstreamHeaders(request, entry) {
|
|
|
89
169
|
if (!value)
|
|
90
170
|
continue;
|
|
91
171
|
const lower = key.toLowerCase();
|
|
92
|
-
if (
|
|
172
|
+
if (STRIP_REQUEST_HEADERS.has(lower))
|
|
93
173
|
continue;
|
|
94
174
|
headersOut.set(key, Array.isArray(value) ? value.join(",") : value);
|
|
95
175
|
}
|
|
@@ -114,14 +194,23 @@ function upstreamUrl(entry, request, routePath) {
|
|
|
114
194
|
}
|
|
115
195
|
async function forward(entry, request, response, routePath, body, paymentResponse) {
|
|
116
196
|
const upstream = upstreamUrl(entry, request, routePath);
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
197
|
+
let upstreamResponse;
|
|
198
|
+
try {
|
|
199
|
+
upstreamResponse = await fetchWithTimeout(upstream, {
|
|
200
|
+
method: request.method,
|
|
201
|
+
headers: upstreamHeaders(request, entry),
|
|
202
|
+
body: ["GET", "HEAD"].includes(request.method ?? "GET") ? undefined : new Uint8Array(body),
|
|
203
|
+
}, UPSTREAM_TIMEOUT_MS);
|
|
204
|
+
}
|
|
205
|
+
catch (error) {
|
|
206
|
+
metrics.upstreamFailures += 1;
|
|
207
|
+
if (error instanceof HttpError)
|
|
208
|
+
throw error;
|
|
209
|
+
throw new HttpError(502, "upstream request failed");
|
|
210
|
+
}
|
|
122
211
|
const responseHeaders = {};
|
|
123
212
|
upstreamResponse.headers.forEach((value, key) => {
|
|
124
|
-
if (!
|
|
213
|
+
if (!STRIP_RESPONSE_HEADERS.has(key.toLowerCase())) {
|
|
125
214
|
responseHeaders[key] = value;
|
|
126
215
|
}
|
|
127
216
|
});
|
|
@@ -140,7 +229,7 @@ export function createGatewayServer(providers) {
|
|
|
140
229
|
return;
|
|
141
230
|
}
|
|
142
231
|
if (url.pathname === "/__402/ready") {
|
|
143
|
-
json(response, 200, { ok:
|
|
232
|
+
json(response, providers.size ? 200 : 503, { ok: providers.size > 0, providers: providers.size });
|
|
144
233
|
return;
|
|
145
234
|
}
|
|
146
235
|
if (url.pathname === "/__402/providers") {
|
|
@@ -168,12 +257,15 @@ export function createGatewayServer(providers) {
|
|
|
168
257
|
return;
|
|
169
258
|
}
|
|
170
259
|
if (url.pathname === "/metrics") {
|
|
260
|
+
if (!isAdminAllowed(request))
|
|
261
|
+
return json(response, 401, { error: "unauthorized" });
|
|
171
262
|
response.writeHead(200, { "content-type": "text/plain; version=0.0.4" });
|
|
172
263
|
response.end([
|
|
173
264
|
`x402_gateway_requests_total ${metrics.requests}`,
|
|
174
265
|
`x402_gateway_paid_requests_total ${metrics.paidRequests}`,
|
|
175
266
|
`x402_gateway_verify_failures_total ${metrics.verifyFailures}`,
|
|
176
267
|
`x402_gateway_settle_failures_total ${metrics.settleFailures}`,
|
|
268
|
+
`x402_gateway_fee_quote_failures_total ${metrics.feeQuoteFailures}`,
|
|
177
269
|
`x402_gateway_upstream_failures_total ${metrics.upstreamFailures}`,
|
|
178
270
|
"",
|
|
179
271
|
].join("\n"));
|
|
@@ -213,7 +305,14 @@ export function createGatewayServer(providers) {
|
|
|
213
305
|
json(response, 402, challenge, { [headers.required]: encodeRequired(challenge) });
|
|
214
306
|
return;
|
|
215
307
|
}
|
|
216
|
-
|
|
308
|
+
let payload;
|
|
309
|
+
try {
|
|
310
|
+
payload = decodeSignature(paymentHeader);
|
|
311
|
+
}
|
|
312
|
+
catch {
|
|
313
|
+
json(response, 400, { error: "invalid payment signature" });
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
217
316
|
const requirement = requirements.find(item => matchRequirement(payload, item));
|
|
218
317
|
if (!requirement) {
|
|
219
318
|
json(response, 400, { error: "payment does not match any requirement" });
|
|
@@ -223,9 +322,9 @@ export function createGatewayServer(providers) {
|
|
|
223
322
|
paymentPayload: payload,
|
|
224
323
|
paymentRequirements: requirement,
|
|
225
324
|
});
|
|
226
|
-
if (verify
|
|
325
|
+
if (!isVerifySuccess(verify)) {
|
|
227
326
|
metrics.verifyFailures += 1;
|
|
228
|
-
json(response, 400, { error: "payment verification failed"
|
|
327
|
+
json(response, 400, { error: "payment verification failed" });
|
|
229
328
|
return;
|
|
230
329
|
}
|
|
231
330
|
let settle;
|
|
@@ -239,11 +338,21 @@ export function createGatewayServer(providers) {
|
|
|
239
338
|
metrics.settleFailures += 1;
|
|
240
339
|
throw error;
|
|
241
340
|
}
|
|
341
|
+
if (!isSettleSuccess(settle)) {
|
|
342
|
+
metrics.settleFailures += 1;
|
|
343
|
+
json(response, 502, { error: "settlement failed" });
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
242
346
|
metrics.paidRequests += 1;
|
|
243
347
|
await forward(entry, request, response, routePath, body, settle);
|
|
244
348
|
}
|
|
245
349
|
catch (error) {
|
|
246
|
-
|
|
350
|
+
if (error instanceof HttpError) {
|
|
351
|
+
json(response, error.status, { error: error.publicMessage });
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
console.error(error);
|
|
355
|
+
json(response, 500, { error: "internal server error" });
|
|
247
356
|
}
|
|
248
357
|
});
|
|
249
358
|
}
|
package/dist/gateway/tokens.js
CHANGED
|
@@ -31,8 +31,37 @@ export function getToken(network, symbol) {
|
|
|
31
31
|
throw new Error(`unknown token ${symbol} on ${network}`);
|
|
32
32
|
return token;
|
|
33
33
|
}
|
|
34
|
+
function numberToDecimalString(amount) {
|
|
35
|
+
const value = amount.toString();
|
|
36
|
+
const match = value.match(/^(\d+(?:\.\d+)?)[eE]([+-]?\d+)$/);
|
|
37
|
+
if (!match)
|
|
38
|
+
return value;
|
|
39
|
+
const [, coefficient, exponentText] = match;
|
|
40
|
+
const exponent = Number(exponentText);
|
|
41
|
+
const [whole, fraction = ""] = coefficient.split(".");
|
|
42
|
+
const digits = whole + fraction;
|
|
43
|
+
const decimalIndex = whole.length + exponent;
|
|
44
|
+
if (decimalIndex <= 0)
|
|
45
|
+
return `0.${"0".repeat(Math.abs(decimalIndex))}${digits}`.replace(/0+$/, "");
|
|
46
|
+
if (decimalIndex >= digits.length)
|
|
47
|
+
return `${digits}${"0".repeat(decimalIndex - digits.length)}`;
|
|
48
|
+
return `${digits.slice(0, decimalIndex)}.${digits.slice(decimalIndex)}`.replace(/0+$/, "").replace(/\.$/, "");
|
|
49
|
+
}
|
|
34
50
|
export function toSmallestUnit(amount, decimals) {
|
|
35
|
-
|
|
51
|
+
if (!Number.isInteger(decimals) || decimals < 0)
|
|
52
|
+
throw new Error(`invalid token decimals ${decimals}`);
|
|
53
|
+
const numeric = typeof amount === "number" ? amount : Number(amount);
|
|
54
|
+
if (!Number.isFinite(numeric) || numeric < 0)
|
|
55
|
+
throw new Error(`invalid amount ${amount}`);
|
|
56
|
+
const value = typeof amount === "number" ? numberToDecimalString(amount) : String(amount);
|
|
57
|
+
if (!/^\d+(\.\d+)?$/.test(value))
|
|
58
|
+
throw new Error(`invalid decimal amount ${amount}`);
|
|
59
|
+
const [whole, fraction = ""] = value.split(".");
|
|
36
60
|
const padded = (fraction + "0".repeat(decimals)).slice(0, decimals);
|
|
37
|
-
|
|
61
|
+
let units = BigInt(whole || "0") * 10n ** BigInt(decimals) + BigInt(padded || "0");
|
|
62
|
+
if (fraction.length > decimals && /[1-9]/.test(fraction.slice(decimals)))
|
|
63
|
+
units += 1n;
|
|
64
|
+
if (numeric > 0 && units === 0n)
|
|
65
|
+
units = 1n;
|
|
66
|
+
return units.toString();
|
|
38
67
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bankofai/x402-cli",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.7",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"files": [
|
|
6
6
|
"dist"
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"@bankofai/x402-evm": "1.0.0",
|
|
25
25
|
"@bankofai/x402-gateway": "^1.0.0-beta.1",
|
|
26
26
|
"@bankofai/x402-tron": "1.0.0",
|
|
27
|
-
"tronweb": "6.0
|
|
27
|
+
"tronweb": "6.4.0",
|
|
28
28
|
"viem": "^2.55.0",
|
|
29
29
|
"yaml": "^2.8.2"
|
|
30
30
|
},
|