@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 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-rawAmount <amount> Maximum smallest-unit payment amount
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
- --rawAmount <amount> Smallest-unit amount
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 where supported
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 Error(`--${key} must be a positive integer`);
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 Error(`--${key} must be a positive integer`);
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
- const providerFqn = opt(options, "provider");
812
- if (!providerFqn)
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 Error("--amount and --rawAmount are mutually exclusive");
850
- const amount = rawAmount ? assertRawAmount(rawAmount, "--rawAmount") : toSmallestUnit(humanAmount ?? "0.0001", decimals);
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-rawAmount"))) {
920
- throw new Error(`payment raw amount ${selected.amount} exceeds --max-rawAmount ${maxRaw}`);
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-rawAmount or --decimals");
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
- if (!url)
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
- if (outputMode(options) === "json") {
1278
- printJson({ assets: rows, count: rows.length });
1279
- }
1280
- else {
1281
- emit({
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
- printJson({ ok: true, command: "catalog search", query, catalog: source, count: hits.length, results });
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
- printJson({ ok: true, command: "catalog show", result: provider });
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
- printJson({ ok: true, command: "catalog endpoints", provider: provider.fqn ?? provider.name, endpoints });
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
- printJson({
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 Error("Usage: x402-cli gateway <search|start|check|scaffold|catalog>");
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 Error("Usage: x402-cli gateway catalog <build|check|pay-assets|search>");
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
- process.stdout.write(helpText("catalog"));
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 Error("Usage: x402-cli catalog <update|search|show|endpoints|pay-json|export-gateway|build>");
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
- process.stdout.write(helpText());
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[2] ?? "x402-cli",
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
  });
@@ -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
- for (let i = 0; i < argv.length; i += 1) {
7
- const item = argv[i];
8
- if (!item.startsWith("--"))
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
- const key = item.slice(2);
11
- const next = argv[i + 1];
12
- if (!next || next.startsWith("--"))
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
- const options = parseArgs(process.argv.slice(2));
26
- const providersPath = opt(options, "providers", opt(options, "provider", process.env.X402_GATEWAY_PROVIDERS_DIR || "/app/providers"));
27
- const host = opt(options, "host", process.env.X402_GATEWAY_HOST || "0.0.0.0");
28
- const port = Number(opt(options, "port", process.env.PORT || "8080"));
29
- const providers = loadProviders(providersPath);
30
- const server = createGatewayServer(providers);
31
- server.listen(port, host, () => {
32
- process.stdout.write(JSON.stringify({ ok: true, host, port, providers: [...providers.keys()] }, null, 2) + "\n");
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
  });
@@ -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) => process.env[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
- for (const [tierIndex, tier] of (endpoint.metering?.dimensions?.[0]?.tiers ?? []).entries()) {
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
- return (variant?.dimensions ?? endpoint?.metering?.dimensions)?.[0]?.tiers?.[0]?.price_usd ?? 0;
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: toSmallestUnit(price, token.decimals),
190
+ amount,
130
191
  asset: token.address,
131
192
  payTo,
132
193
  maxTimeoutSeconds: provider.operator.valid_for_seconds ?? 300,
@@ -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
- for await (const chunk of request)
15
- chunks.push(Buffer.from(chunk));
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 fetch(new URL(path, entry.facilitatorUrl), {
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
- const data = text ? JSON.parse(text) : {};
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 Error(`facilitator ${path} failed: ${response.status} ${text}`);
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 (["host", "connection", "content-length", "authorization", "payment-signature"].includes(lower))
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
- const upstreamResponse = await fetch(upstream, {
118
- method: request.method,
119
- headers: upstreamHeaders(request, entry),
120
- body: ["GET", "HEAD"].includes(request.method ?? "GET") ? undefined : new Uint8Array(body),
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 (!["connection", "transfer-encoding", "content-encoding", "content-length"].includes(key.toLowerCase())) {
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: true, providers: providers.size });
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
- const payload = decodeSignature(paymentHeader);
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?.valid === false || verify?.isValid === false) {
325
+ if (!isVerifySuccess(verify)) {
227
326
  metrics.verifyFailures += 1;
228
- json(response, 400, { error: "payment verification failed", verify });
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
- json(response, 500, { error: error instanceof Error ? error.message : String(error) });
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
  }
@@ -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
- const [whole, fraction = ""] = String(amount).split(".");
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
- return (BigInt(whole || "0") * 10n ** BigInt(decimals) + BigInt(padded || "0")).toString();
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.5",
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.2",
27
+ "tronweb": "6.4.0",
28
28
  "viem": "^2.55.0",
29
29
  "yaml": "^2.8.2"
30
30
  },