@bankofai/x402-cli 1.0.0-beta.3 → 1.0.0-beta.5

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
@@ -5,11 +5,13 @@ import fs from "node:fs";
5
5
  import os from "node:os";
6
6
  import path from "node:path";
7
7
  import { spawn } from "node:child_process";
8
+ import { createRequire } from "node:module";
8
9
  import { fileURLToPath } from "node:url";
9
10
  import YAML from "yaml";
10
- import { createPaymentPayload, decodeRequired, decodeResponse, encodeRequired, encodeResponse, encodeSignature, headers } from "./x402.js";
11
- import { findTokenByAddress, getToken, normalizeNetwork, toSmallestUnit } from "./tokens.js";
12
- const BOOLEAN_FLAGS = new Set(["daemon", "dry-run", "help", "human", "include-blocked", "json", "version"]);
11
+ import { createPaymentPayload, decodeRequired, decodeResponse, decodeSignature, encodeRequired, encodeResponse, encodeSignature, headers } from "./x402.js";
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"]);
14
+ const require = createRequire(import.meta.url);
13
15
  function parseArgs(argv) {
14
16
  const [command = "help", ...rest] = argv;
15
17
  const positional = [];
@@ -53,7 +55,7 @@ function parseArgs(argv) {
53
55
  else if (BOOLEAN_FLAGS.has(key)) {
54
56
  options[key] = true;
55
57
  }
56
- else if (!next || next.startsWith("-")) {
58
+ else if (!next || next.startsWith("--")) {
57
59
  options[key] = true;
58
60
  }
59
61
  else {
@@ -342,7 +344,11 @@ function readYaml(file) {
342
344
  return YAML.parse(fs.readFileSync(file, "utf8"));
343
345
  }
344
346
  function expandEnv(value) {
345
- return value.replace(/\$\{([^}]+)\}/g, (_, name) => process.env[name] ?? "");
347
+ return value.replace(/\$\{([^}]+)\}/g, (_, name) => {
348
+ if (!(name in process.env))
349
+ throw new Error(`environment variable \${${name}} is not set`);
350
+ return process.env[name] ?? "";
351
+ });
346
352
  }
347
353
  function expandDeep(value) {
348
354
  if (typeof value === "string")
@@ -384,6 +390,14 @@ function validateProvider(provider, file = "provider.yml") {
384
390
  if (typeof value !== "string" || !value.trim())
385
391
  throw new Error(`${file}: ${name} is required`);
386
392
  }
393
+ try {
394
+ const url = new URL(provider.forward_url);
395
+ if (!["http:", "https:"].includes(url.protocol))
396
+ throw new Error("unsupported protocol");
397
+ }
398
+ catch {
399
+ throw new Error(`${file}: forward_url must be a valid http(s) URL`);
400
+ }
387
401
  if (!Array.isArray(provider.endpoints) || !provider.endpoints.length) {
388
402
  throw new Error(`${file}: endpoints must contain at least one endpoint`);
389
403
  }
@@ -392,6 +406,12 @@ function validateProvider(provider, file = "provider.yml") {
392
406
  if (typeof endpoint.method !== "string" || typeof endpoint.path !== "string") {
393
407
  throw new Error(`${file}: each endpoint needs method and path`);
394
408
  }
409
+ if (!endpoint.method.trim() || !endpoint.path.trim() || !endpoint.path.startsWith("/")) {
410
+ throw new Error(`${file}: endpoint method/path must be non-empty and path must start with /`);
411
+ }
412
+ const price = providerPrice(endpoint);
413
+ if (!Number.isFinite(price) || price < 0)
414
+ throw new Error(`${file}: endpoint price_usd must be a finite number >= 0`);
395
415
  const key = `${endpoint.method.toUpperCase()} ${endpoint.path}`;
396
416
  if (seen.has(key))
397
417
  throw new Error(`${file}: duplicate endpoint ${key}`);
@@ -566,10 +586,41 @@ function cachedCatalogPath() {
566
586
  function providerFilename(fqn) {
567
587
  return `${fqn.replace(/\//g, "__")}.json`;
568
588
  }
589
+ function sanitizeProviderName(name) {
590
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._/-]{0,127}$/.test(name) || name.includes("..")) {
591
+ throw new Error("provider name must be a safe FQN using letters, numbers, dots, underscores, dashes, or slashes");
592
+ }
593
+ return name;
594
+ }
595
+ function safeOutputPath(baseDir, ...parts) {
596
+ const root = path.resolve(baseDir);
597
+ const target = path.resolve(root, ...parts);
598
+ if (target !== root && !target.startsWith(`${root}${path.sep}`)) {
599
+ throw new Error(`refusing to write outside output directory: ${target}`);
600
+ }
601
+ return target;
602
+ }
603
+ function ensureWritable(file, options) {
604
+ if (fs.existsSync(file) && !hasFlag(options, "force")) {
605
+ throw new Error(`${file} already exists; pass --force to overwrite`);
606
+ }
607
+ }
569
608
  function defaultCatalogSource() {
609
+ const envSource = process.env.X402_CATALOG || process.env.X402_GATEWAY_CATALOG;
610
+ if (envSource)
611
+ return envSource;
570
612
  return fs.existsSync(cachedCatalogPath())
571
613
  ? cachedCatalogPath()
572
- : "https://tm-x402-catalog.bankofai.io/api/catalog.json";
614
+ : "https://x402-catalog.bankofai.io/api/catalog.json";
615
+ }
616
+ function positiveIntegerOption(options, key, fallback) {
617
+ const value = opt(options, key, String(fallback));
618
+ if (!/^\d+$/.test(value))
619
+ throw new Error(`--${key} must be a positive integer`);
620
+ const parsed = Number(value);
621
+ if (!Number.isSafeInteger(parsed) || parsed <= 0)
622
+ throw new Error(`--${key} must be a positive integer`);
623
+ return parsed;
573
624
  }
574
625
  function remoteBaseFromCatalogPayload(payload) {
575
626
  const base = payload.base_url ?? payload.baseUrl;
@@ -597,7 +648,7 @@ function catalogDetailSource(source, section, name) {
597
648
  : base.pathname.endsWith("/")
598
649
  ? base.pathname
599
650
  : `${base.pathname}/`;
600
- base.pathname = `${pathname}${section}/${name}.json`;
651
+ base.pathname = `${pathname}${section}/${providerFilename(name)}`;
601
652
  base.search = "";
602
653
  base.hash = "";
603
654
  return base.toString();
@@ -614,8 +665,6 @@ async function readCatalogProvider(source, name) {
614
665
  const summary = providers.find((item) => item.name === name || item.fqn === name);
615
666
  if (!summary)
616
667
  throw new Error(`provider not found: ${name}`);
617
- if (Array.isArray(summary.endpoints) && summary.endpoints.length)
618
- return summary;
619
668
  const fqn = summary.fqn ?? summary.name ?? name;
620
669
  try {
621
670
  return await readJson(catalogDetailSource(source, "providers", fqn));
@@ -696,7 +745,7 @@ function submissionCatalog(detail) {
696
745
  description,
697
746
  useCase,
698
747
  i18n: detail.i18n ?? { "zh-CN": zhCopy(title, subtitle, description, useCase) },
699
- logo: detail.logo ?? "https://tm-x402-catalog.bankofai.io/assets/providers/default.png",
748
+ logo: detail.logo ?? "https://x402-catalog.bankofai.io/assets/providers/default.png",
700
749
  category: detail.category ?? "other",
701
750
  chains: detail.chains ?? [],
702
751
  isFirstParty: Boolean(detail.is_first_party ?? detail.isFirstParty),
@@ -762,12 +811,18 @@ async function catalogExportGateway(gatewayUrl, options) {
762
811
  const providerFqn = opt(options, "provider");
763
812
  if (!providerFqn)
764
813
  throw new Error("--provider is required");
814
+ sanitizeProviderName(providerFqn);
765
815
  const base = gatewayUrl.replace(/\/+$/, "");
766
- const detail = await readJson(`${base}/__402/catalog/providers/${providerFqn}.json`);
767
- const target = opt(options, "output-dir", path.join("providers", providerFqn));
816
+ const detail = await readJson(`${base}/__402/catalog/providers/${providerFilename(providerFqn)}`);
817
+ const outputRoot = opt(options, "output-dir", "providers");
818
+ const target = opt(options, "output-dir")
819
+ ? path.resolve(outputRoot)
820
+ : safeOutputPath("providers", providerFilename(providerFqn).replace(/\.json$/, ""));
768
821
  fs.mkdirSync(target, { recursive: true });
769
822
  const catalogPath = path.join(target, "catalog.json");
770
823
  const payMdPath = path.join(target, "pay.md");
824
+ ensureWritable(catalogPath, options);
825
+ ensureWritable(payMdPath, options);
771
826
  writeJson(catalogPath, submissionCatalog(detail));
772
827
  fs.writeFileSync(payMdPath, payMarkdownFromDetail(detail));
773
828
  emit({ command: "catalog export-gateway", mode: outputMode(options), result: { provider: providerFqn, catalog: catalogPath, payMd: payMdPath } });
@@ -788,7 +843,11 @@ function buildRequirement(options) {
788
843
  const decimals = decimalsOption !== undefined ? Number(decimalsOption) : registryToken.decimals;
789
844
  if (!Number.isInteger(decimals) || decimals < 0)
790
845
  throw new Error("--decimals must be a non-negative integer");
791
- const amount = opt(options, "rawAmount") ?? toSmallestUnit(opt(options, "amount", "0.0001"), decimals);
846
+ const rawAmount = opt(options, "rawAmount") ?? opt(options, "raw-amount");
847
+ const humanAmount = opt(options, "amount");
848
+ 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);
792
851
  const assetAddress = explicitAsset ?? registryToken.address;
793
852
  const assetTransferMethod = registryToken?.assetTransferMethod ?? "permit2";
794
853
  return {
@@ -827,6 +886,9 @@ function stripFlag(argv, flag) {
827
886
  return argv.filter(item => item !== flag);
828
887
  }
829
888
  function serveDaemon(argv, options) {
889
+ const requirement = buildRequirement(options);
890
+ if (!requirement.payTo)
891
+ throw new Error("--pay-to is required");
830
892
  const daemonArgs = stripFlag(stripFlag(argv, "--daemon"), "-d");
831
893
  const script = fileURLToPath(import.meta.url);
832
894
  const child = spawn(process.execPath, [script, ...daemonArgs], {
@@ -841,7 +903,7 @@ function serveDaemon(argv, options) {
841
903
  emit({
842
904
  command: "server",
843
905
  mode: outputMode(options),
844
- network: normalizeNetwork(opt(options, "network", "tron:nile")),
906
+ network: requirement.network,
845
907
  scheme: "exact",
846
908
  result: {
847
909
  pid: child.pid,
@@ -854,7 +916,7 @@ function serveDaemon(argv, options) {
854
916
  function validateAmountLimits(selected, options) {
855
917
  const maxRaw = opt(options, "max-rawAmount") ?? opt(options, "max-raw-amount");
856
918
  const maxAmount = opt(options, "max-amount");
857
- if (maxRaw && BigInt(selected.amount) > BigInt(maxRaw)) {
919
+ if (maxRaw && BigInt(selected.amount) > BigInt(assertRawAmount(maxRaw, "--max-rawAmount"))) {
858
920
  throw new Error(`payment raw amount ${selected.amount} exceeds --max-rawAmount ${maxRaw}`);
859
921
  }
860
922
  if (maxAmount) {
@@ -887,17 +949,18 @@ async function serve(options) {
887
949
  };
888
950
  const server = http.createServer(async (request, response) => {
889
951
  try {
890
- if (request.url?.startsWith("/health")) {
952
+ const pathname = new URL(request.url ?? "/", `http://${host}:${port}`).pathname;
953
+ if (pathname === "/health") {
891
954
  response.writeHead(200, { "content-type": "application/json" });
892
955
  response.end(JSON.stringify({ ok: true }));
893
956
  return;
894
957
  }
895
- if (request.url?.startsWith("/.well-known/x402")) {
958
+ if (pathname === "/.well-known/x402") {
896
959
  response.writeHead(200, { "content-type": "application/json" });
897
- response.end(JSON.stringify({ network: requirement.network, scheme: "exact", asset: requirement.asset, amount: requirement.amount, payTo: requirement.payTo, pay_url: resourceUrl }));
960
+ response.end(JSON.stringify({ network: requirement.network, scheme: "exact", asset: requirement.asset, rawAmount: requirement.amount, amount: requirement.amount, payTo: requirement.payTo, pay_url: resourceUrl }));
898
961
  return;
899
962
  }
900
- if (!request.url?.startsWith("/pay")) {
963
+ if (pathname !== "/pay") {
901
964
  response.writeHead(404).end("not found");
902
965
  return;
903
966
  }
@@ -910,22 +973,33 @@ async function serve(options) {
910
973
  response.end(JSON.stringify(challenge));
911
974
  return;
912
975
  }
913
- const payload = Buffer.from(signature, "base64").toString("utf8").startsWith("{")
914
- ? JSON.parse(Buffer.from(signature, "base64").toString("utf8"))
915
- : signature;
976
+ let payload;
977
+ try {
978
+ payload = decodeSignature(signature);
979
+ }
980
+ catch {
981
+ response.writeHead(400, { "content-type": "application/json" });
982
+ response.end(JSON.stringify({ error: "invalid payment signature" }));
983
+ return;
984
+ }
916
985
  const verify = await facilitatorPost(facilitatorUrl, "/verify", {
917
986
  paymentPayload: payload,
918
987
  paymentRequirements: requirement,
919
988
  });
920
- if (verify?.valid === false || verify?.isValid === false) {
989
+ if (!(verify?.valid === true || verify?.isValid === true)) {
921
990
  response.writeHead(400, { "content-type": "application/json" });
922
- response.end(JSON.stringify({ error: "payment verification failed", verify }));
991
+ response.end(JSON.stringify({ error: "payment verification failed" }));
923
992
  return;
924
993
  }
925
994
  const settle = await facilitatorPost(facilitatorUrl, "/settle", {
926
995
  paymentPayload: payload,
927
996
  paymentRequirements: requirement,
928
997
  });
998
+ if (!(settle?.success === true || settle?.settled === true || typeof settle?.transaction === "string" || typeof settle?.txHash === "string")) {
999
+ response.writeHead(502, { "content-type": "application/json" });
1000
+ response.end(JSON.stringify({ error: "settlement failed" }));
1001
+ return;
1002
+ }
929
1003
  response.writeHead(200, {
930
1004
  "content-type": "application/json",
931
1005
  [headers.response]: encodeResponse(settle),
@@ -937,13 +1011,18 @@ async function serve(options) {
937
1011
  response.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
938
1012
  }
939
1013
  });
940
- server.listen(port, host, () => {
941
- emit({
942
- command: "server",
943
- network: requirement.network,
944
- scheme: "exact",
945
- mode: outputMode(options),
946
- result: { pay_url: resourceUrl, token: opt(options, "token", "USDT"), rawAmount: requirement.amount, pay_to: requirement.payTo },
1014
+ await new Promise((resolve, reject) => {
1015
+ server.once("error", reject);
1016
+ server.listen(port, host, () => {
1017
+ server.off("error", reject);
1018
+ emit({
1019
+ command: "server",
1020
+ network: requirement.network,
1021
+ scheme: "exact",
1022
+ mode: outputMode(options),
1023
+ result: { pay_url: resourceUrl, token: opt(options, "token", "USDT"), rawAmount: requirement.amount, pay_to: requirement.payTo },
1024
+ });
1025
+ resolve();
947
1026
  });
948
1027
  });
949
1028
  }
@@ -1023,7 +1102,7 @@ async function pay(url, options) {
1023
1102
  const paid = await fetch(url, {
1024
1103
  method,
1025
1104
  headers: retryHeaders,
1026
- body: opt(options, "body"),
1105
+ body: ["GET", "HEAD"].includes(method.toUpperCase()) ? undefined : opt(options, "body"),
1027
1106
  });
1028
1107
  const body = await responsePayload(paid);
1029
1108
  const paymentResponse = paid.headers.get(headers.response);
@@ -1067,10 +1146,20 @@ function executableInPath(name) {
1067
1146
  }
1068
1147
  return undefined;
1069
1148
  }
1149
+ function resolveGatewayPackageRuntime() {
1150
+ try {
1151
+ return require.resolve("@bankofai/x402-gateway/dist/cli.js");
1152
+ }
1153
+ catch {
1154
+ return undefined;
1155
+ }
1156
+ }
1070
1157
  function gatewayCommand(options) {
1071
1158
  const explicit = opt(options, "gateway-bin");
1159
+ const gatewayPackageRuntime = resolveGatewayPackageRuntime();
1072
1160
  const candidates = [
1073
1161
  explicit ? { file: explicit, source: "--gateway-bin" } : undefined,
1162
+ gatewayPackageRuntime ? { file: gatewayPackageRuntime, source: "@bankofai/x402-gateway dependency" } : undefined,
1074
1163
  { file: path.resolve(path.dirname(fileURLToPath(import.meta.url)), "gateway", "cli.js"), source: "bundled gateway runtime" },
1075
1164
  executableInPath("x402-gateway") ? { file: executableInPath("x402-gateway"), source: "PATH x402-gateway" } : undefined,
1076
1165
  { file: path.resolve(process.cwd(), "../x402-gateway/dist/cli.js"), source: "sibling ../x402-gateway" },
@@ -1087,7 +1176,7 @@ function gatewayCommand(options) {
1087
1176
  // Try the next candidate.
1088
1177
  }
1089
1178
  }
1090
- throw new Error("x402-gateway runtime not found. Install/publish a x402-gateway npm binary, run from a checkout with ../x402-gateway/dist/cli.js, or pass --gateway-bin <path>.");
1179
+ throw new Error("x402-gateway runtime not found. Install @bankofai/x402-gateway, run from a checkout with ../x402-gateway/dist/cli.js, or pass --gateway-bin <path>.");
1091
1180
  }
1092
1181
  async function gatewayStart(args, options) {
1093
1182
  const gateway = gatewayCommand(options);
@@ -1269,7 +1358,7 @@ async function searchCatalog(source, query, options) {
1269
1358
  }
1270
1359
  return hits
1271
1360
  .sort((a, b) => b.score - a.score || a.fqn.localeCompare(b.fqn))
1272
- .slice(0, Number(opt(options, "limit", "10")));
1361
+ .slice(0, positiveIntegerOption(options, "limit", 10));
1273
1362
  }
1274
1363
  function searchHitToJson(hit) {
1275
1364
  return {
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env node
2
+ import { loadProviders } from "./config.js";
3
+ import { createGatewayServer } from "./server.js";
4
+ function parseArgs(argv) {
5
+ const options = {};
6
+ for (let i = 0; i < argv.length; i += 1) {
7
+ const item = argv[i];
8
+ if (!item.startsWith("--"))
9
+ continue;
10
+ const key = item.slice(2);
11
+ const next = argv[i + 1];
12
+ if (!next || next.startsWith("--"))
13
+ options[key] = true;
14
+ else {
15
+ options[key] = next;
16
+ i += 1;
17
+ }
18
+ }
19
+ return options;
20
+ }
21
+ function opt(options, key, fallback) {
22
+ const value = options[key];
23
+ return typeof value === "string" ? value : fallback;
24
+ }
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");
33
+ });
@@ -0,0 +1,136 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import YAML from "yaml";
4
+ import { getToken, normalizeNetwork, toSmallestUnit } from "./tokens.js";
5
+ function expandEnv(value) {
6
+ return value.replace(/\$\{([^}]+)\}/g, (_, name) => process.env[name] ?? "");
7
+ }
8
+ function expandDeep(value) {
9
+ if (typeof value === "string")
10
+ return expandEnv(value);
11
+ if (Array.isArray(value))
12
+ return value.map(expandDeep);
13
+ if (value && typeof value === "object") {
14
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, expandDeep(item)]));
15
+ }
16
+ return value;
17
+ }
18
+ function assertString(value, name) {
19
+ if (typeof value !== "string" || !value.trim())
20
+ throw new Error(`${name} is required`);
21
+ }
22
+ function validateProvider(config, file) {
23
+ assertString(config.name, `${file}: name`);
24
+ assertString(config.forward_url, `${file}: forward_url`);
25
+ assertString(config.operator?.network, `${file}: operator.network`);
26
+ assertString(config.operator?.recipient, `${file}: operator.recipient`);
27
+ if (!config.endpoints?.length)
28
+ throw new Error(`${file}: endpoints must contain at least one endpoint`);
29
+ const seen = new Set();
30
+ for (const [index, endpoint] of config.endpoints.entries()) {
31
+ assertString(endpoint.method, `${file}: endpoints[${index}].method`);
32
+ assertString(endpoint.path, `${file}: endpoints[${index}].path`);
33
+ if (!endpoint.path.startsWith("/"))
34
+ throw new Error(`${file}: endpoint path must start with /`);
35
+ const method = endpoint.method.toUpperCase();
36
+ if (!["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"].includes(method)) {
37
+ throw new Error(`${file}: unsupported endpoint method ${endpoint.method}`);
38
+ }
39
+ const key = `${method} ${endpoint.path}`;
40
+ if (seen.has(key))
41
+ throw new Error(`${file}: duplicate endpoint ${key}`);
42
+ 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
+ }
48
+ }
49
+ }
50
+ export function loadProvider(file) {
51
+ const config = expandDeep(YAML.parse(fs.readFileSync(file, "utf8")));
52
+ validateProvider(config, file);
53
+ config.operator.network = normalizeNetwork(config.operator.network);
54
+ normalizePaymentProtocol(config, file);
55
+ return {
56
+ config,
57
+ facilitatorUrl: config.operator.facilitator_url ||
58
+ process.env.X402_FACILITATOR_URL ||
59
+ process.env.FACILITATOR_URL ||
60
+ "https://facilitator.bankofai.io",
61
+ facilitatorApiKey: config.operator.facilitator_api_key ||
62
+ (config.operator.facilitator_api_key_env
63
+ ? process.env[config.operator.facilitator_api_key_env]
64
+ : undefined) ||
65
+ process.env.X402_FACILITATOR_API_KEY ||
66
+ process.env.FACILITATOR_API_KEY,
67
+ };
68
+ }
69
+ function normalizePaymentProtocol(config, file) {
70
+ const raw = String(config.operator.protocol || config.operator.scheme || "exact").toLowerCase();
71
+ const normalized = raw.replace(/[-:\s]/g, "_");
72
+ if (!["exact", "exact_permit", "permit2", "exact_permit2"].includes(normalized)) {
73
+ throw new Error(`${file}: unsupported x402 protocol ${raw}; use exact + permit2`);
74
+ }
75
+ config.operator.scheme = "exact";
76
+ config.operator.protocol = "exact";
77
+ config.operator.asset_transfer_method = "permit2";
78
+ config.operator.assetTransferMethod = "permit2";
79
+ }
80
+ export function loadProviders(providerPath) {
81
+ const stat = fs.statSync(providerPath);
82
+ const files = stat.isDirectory()
83
+ ? fs.readdirSync(providerPath, { recursive: true })
84
+ .map(item => path.join(providerPath, String(item)))
85
+ .filter(item => item.endsWith("provider.yml") || item.endsWith("provider.yaml"))
86
+ : [providerPath];
87
+ const entries = files.map(file => {
88
+ const entry = loadProvider(file);
89
+ return [entry.config.name, entry];
90
+ });
91
+ const names = new Set();
92
+ for (const [name] of entries) {
93
+ if (names.has(name))
94
+ throw new Error(`duplicate provider name: ${name}`);
95
+ names.add(name);
96
+ }
97
+ return new Map(entries);
98
+ }
99
+ export function endpointFor(provider, method, routePath) {
100
+ return provider.endpoints?.find(endpoint => endpoint.method.toUpperCase() === method.toUpperCase() && pathMatches(endpoint.path, routePath));
101
+ }
102
+ function pathMatches(template, routePath) {
103
+ const templateParts = template.split("/").filter(Boolean);
104
+ const routeParts = routePath.split("/").filter(Boolean);
105
+ if (templateParts.length !== routeParts.length)
106
+ return false;
107
+ return templateParts.every((part, index) => {
108
+ if (part.startsWith("{") && part.endsWith("}"))
109
+ return routeParts[index].length > 0;
110
+ return part === routeParts[index];
111
+ });
112
+ }
113
+ export function priceUsd(endpoint, params = {}) {
114
+ 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;
116
+ }
117
+ export function paymentRequirements(provider, price) {
118
+ if (price <= 0)
119
+ return [];
120
+ const network = normalizeNetwork(provider.operator.network);
121
+ const symbols = provider.operator.currencies?.usd ?? ["USDT"];
122
+ const payTo = provider.recipients?.[provider.operator.recipient]?.account ?? provider.operator.recipient;
123
+ return symbols.map(symbol => {
124
+ const token = getToken(network, symbol);
125
+ const transferMethod = provider.operator.assetTransferMethod || provider.operator.asset_transfer_method || token.assetTransferMethod;
126
+ return {
127
+ scheme: "exact",
128
+ network,
129
+ amount: toSmallestUnit(price, token.decimals),
130
+ asset: token.address,
131
+ payTo,
132
+ maxTimeoutSeconds: provider.operator.valid_for_seconds ?? 300,
133
+ extra: transferMethod === "permit2" ? { assetTransferMethod: "permit2" } : {},
134
+ };
135
+ });
136
+ }
@@ -0,0 +1,249 @@
1
+ import http from "node:http";
2
+ import { URL } from "node:url";
3
+ import { endpointFor, paymentRequirements, priceUsd } from "./config.js";
4
+ import { decodeSignature, encodeRequired, encodeResponse, headers, matchRequirement } from "./x402.js";
5
+ const metrics = {
6
+ requests: 0,
7
+ paidRequests: 0,
8
+ verifyFailures: 0,
9
+ settleFailures: 0,
10
+ upstreamFailures: 0,
11
+ };
12
+ async function readBody(request) {
13
+ const chunks = [];
14
+ for await (const chunk of request)
15
+ chunks.push(Buffer.from(chunk));
16
+ return Buffer.concat(chunks);
17
+ }
18
+ function json(response, status, body, extraHeaders = {}) {
19
+ response.writeHead(status, { "content-type": "application/json", ...extraHeaders });
20
+ response.end(JSON.stringify(body));
21
+ }
22
+ async function facilitatorPost(entry, path, body) {
23
+ const headers = { "content-type": "application/json" };
24
+ if (entry.facilitatorApiKey) {
25
+ headers.authorization = `Bearer ${entry.facilitatorApiKey}`;
26
+ }
27
+ const response = await fetch(new URL(path, entry.facilitatorUrl), {
28
+ method: "POST",
29
+ headers,
30
+ body: JSON.stringify(body),
31
+ });
32
+ const text = await response.text();
33
+ const data = text ? JSON.parse(text) : {};
34
+ if (!response.ok)
35
+ throw new Error(`facilitator ${path} failed: ${response.status} ${text}`);
36
+ return data;
37
+ }
38
+ async function attachFeeQuotes(entry, requirements, context) {
39
+ try {
40
+ const quotes = await facilitatorPost(entry, "/fee_quote", {
41
+ paymentRequirements: requirements,
42
+ context,
43
+ });
44
+ const list = Array.isArray(quotes) ? quotes : quotes.quotes ?? quotes.fees ?? [];
45
+ return requirements.map(requirement => {
46
+ const quote = list.find((item) => item.scheme === requirement.scheme &&
47
+ item.network === requirement.network &&
48
+ String(item.asset).toLowerCase() === requirement.asset.toLowerCase());
49
+ return quote?.fee ? { ...requirement, extra: { ...requirement.extra, fee: quote.fee } } : requirement;
50
+ });
51
+ }
52
+ catch {
53
+ return requirements;
54
+ }
55
+ }
56
+ function isAdminAllowed(request) {
57
+ const token = process.env.X402_GATEWAY_ADMIN_TOKEN;
58
+ if (!token)
59
+ return true;
60
+ const auth = request.headers.authorization ?? "";
61
+ return auth === `Bearer ${token}`;
62
+ }
63
+ function requestParams(url, body, request) {
64
+ const params = {};
65
+ url.searchParams.forEach((value, key) => { params[key] = value; });
66
+ const contentType = String(request.headers["content-type"] ?? "").split(";", 1)[0].toLowerCase();
67
+ try {
68
+ if (body.length && contentType === "application/json") {
69
+ const parsed = JSON.parse(body.toString("utf8"));
70
+ if (parsed && typeof parsed === "object") {
71
+ for (const [key, value] of Object.entries(parsed)) {
72
+ if (["string", "number", "boolean"].includes(typeof value))
73
+ params[key] = String(value);
74
+ }
75
+ }
76
+ }
77
+ else if (body.length && contentType === "application/x-www-form-urlencoded") {
78
+ new URLSearchParams(body.toString("utf8")).forEach((value, key) => { params[key] = value; });
79
+ }
80
+ }
81
+ catch {
82
+ // Metering variants are advisory; malformed bodies just do not add params.
83
+ }
84
+ return params;
85
+ }
86
+ function upstreamHeaders(request, entry) {
87
+ const headersOut = new Headers();
88
+ for (const [key, value] of Object.entries(request.headers)) {
89
+ if (!value)
90
+ continue;
91
+ const lower = key.toLowerCase();
92
+ if (["host", "connection", "content-length", "authorization", "payment-signature"].includes(lower))
93
+ continue;
94
+ headersOut.set(key, Array.isArray(value) ? value.join(",") : value);
95
+ }
96
+ const auth = entry.config.routing?.auth;
97
+ if (auth) {
98
+ const value = auth.value ?? (auth.value_from_env ? process.env[auth.value_from_env] : undefined);
99
+ if (value && ["header", "access_token", "oauth2", undefined].includes(auth.method)) {
100
+ headersOut.set(auth.key ?? "Authorization", `${auth.prefix ?? ""}${value}`);
101
+ }
102
+ }
103
+ return headersOut;
104
+ }
105
+ function upstreamUrl(entry, request, routePath) {
106
+ const sourceUrl = new URL(request.url ?? "/", "http://local");
107
+ const upstream = new URL(routePath + (sourceUrl.search || ""), entry.config.forward_url);
108
+ const auth = entry.config.routing?.auth;
109
+ const value = auth?.value ?? (auth?.value_from_env ? process.env[auth.value_from_env] : undefined);
110
+ if (auth?.method === "query_param" && value) {
111
+ upstream.searchParams.set(auth.param ?? auth.key ?? "api_key", value);
112
+ }
113
+ return upstream;
114
+ }
115
+ async function forward(entry, request, response, routePath, body, paymentResponse) {
116
+ 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
+ });
122
+ const responseHeaders = {};
123
+ upstreamResponse.headers.forEach((value, key) => {
124
+ if (!["connection", "transfer-encoding", "content-encoding", "content-length"].includes(key.toLowerCase())) {
125
+ responseHeaders[key] = value;
126
+ }
127
+ });
128
+ if (paymentResponse)
129
+ responseHeaders[headers.response] = encodeResponse(paymentResponse);
130
+ response.writeHead(upstreamResponse.status, responseHeaders);
131
+ response.end(Buffer.from(await upstreamResponse.arrayBuffer()));
132
+ }
133
+ export function createGatewayServer(providers) {
134
+ return http.createServer(async (request, response) => {
135
+ try {
136
+ metrics.requests += 1;
137
+ const url = new URL(request.url ?? "/", "http://local");
138
+ if (url.pathname === "/__402/health") {
139
+ json(response, 200, { ok: true, providers: providers.size });
140
+ return;
141
+ }
142
+ if (url.pathname === "/__402/ready") {
143
+ json(response, 200, { ok: true, providers: providers.size });
144
+ return;
145
+ }
146
+ if (url.pathname === "/__402/providers") {
147
+ if (!isAdminAllowed(request))
148
+ return json(response, 401, { error: "unauthorized" });
149
+ json(response, 200, { providers: [...providers.values()].map(entry => ({
150
+ name: entry.config.name,
151
+ title: entry.config.title,
152
+ network: entry.config.operator.network,
153
+ facilitatorUrl: entry.facilitatorUrl,
154
+ endpoints: entry.config.endpoints?.length ?? 0,
155
+ })) });
156
+ return;
157
+ }
158
+ if (url.pathname === "/__402/endpoints") {
159
+ if (!isAdminAllowed(request))
160
+ return json(response, 401, { error: "unauthorized" });
161
+ json(response, 200, { endpoints: [...providers.values()].flatMap(entry => (entry.config.endpoints ?? []).map(endpoint => ({
162
+ provider: entry.config.name,
163
+ method: endpoint.method,
164
+ path: endpoint.path,
165
+ priceUsd: priceUsd(endpoint),
166
+ network: entry.config.operator.network,
167
+ }))) });
168
+ return;
169
+ }
170
+ if (url.pathname === "/metrics") {
171
+ response.writeHead(200, { "content-type": "text/plain; version=0.0.4" });
172
+ response.end([
173
+ `x402_gateway_requests_total ${metrics.requests}`,
174
+ `x402_gateway_paid_requests_total ${metrics.paidRequests}`,
175
+ `x402_gateway_verify_failures_total ${metrics.verifyFailures}`,
176
+ `x402_gateway_settle_failures_total ${metrics.settleFailures}`,
177
+ `x402_gateway_upstream_failures_total ${metrics.upstreamFailures}`,
178
+ "",
179
+ ].join("\n"));
180
+ return;
181
+ }
182
+ const match = url.pathname.match(/^\/providers\/([^/]+)(\/.*)$/);
183
+ if (!match) {
184
+ json(response, 404, { error: "not found" });
185
+ return;
186
+ }
187
+ const entry = providers.get(match[1]);
188
+ if (!entry) {
189
+ json(response, 404, { error: "provider not found" });
190
+ return;
191
+ }
192
+ const routePath = match[2];
193
+ const endpoint = endpointFor(entry.config, request.method ?? "GET", routePath);
194
+ if (!endpoint) {
195
+ json(response, 404, { error: "endpoint not found" });
196
+ return;
197
+ }
198
+ const body = await readBody(request);
199
+ const requirements = paymentRequirements(entry.config, priceUsd(endpoint, requestParams(url, body, request)));
200
+ if (!requirements.length) {
201
+ await forward(entry, request, response, routePath, body);
202
+ return;
203
+ }
204
+ const paymentHeader = request.headers[headers.signature.toLowerCase()];
205
+ if (!paymentHeader || Array.isArray(paymentHeader)) {
206
+ const accepts = await attachFeeQuotes(entry, requirements);
207
+ const challenge = {
208
+ x402Version: 2,
209
+ error: "Payment required",
210
+ resource: { url: url.pathname },
211
+ accepts,
212
+ };
213
+ json(response, 402, challenge, { [headers.required]: encodeRequired(challenge) });
214
+ return;
215
+ }
216
+ const payload = decodeSignature(paymentHeader);
217
+ const requirement = requirements.find(item => matchRequirement(payload, item));
218
+ if (!requirement) {
219
+ json(response, 400, { error: "payment does not match any requirement" });
220
+ return;
221
+ }
222
+ const verify = await facilitatorPost(entry, "/verify", {
223
+ paymentPayload: payload,
224
+ paymentRequirements: requirement,
225
+ });
226
+ if (verify?.valid === false || verify?.isValid === false) {
227
+ metrics.verifyFailures += 1;
228
+ json(response, 400, { error: "payment verification failed", verify });
229
+ return;
230
+ }
231
+ let settle;
232
+ try {
233
+ settle = await facilitatorPost(entry, "/settle", {
234
+ paymentPayload: payload,
235
+ paymentRequirements: requirement,
236
+ });
237
+ }
238
+ catch (error) {
239
+ metrics.settleFailures += 1;
240
+ throw error;
241
+ }
242
+ metrics.paidRequests += 1;
243
+ await forward(entry, request, response, routePath, body, settle);
244
+ }
245
+ catch (error) {
246
+ json(response, 500, { error: error instanceof Error ? error.message : String(error) });
247
+ }
248
+ });
249
+ }
@@ -0,0 +1,38 @@
1
+ export const TOKENS = {
2
+ "tron:mainnet": {
3
+ USDT: { address: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", decimals: 6, name: "Tether USD", symbol: "USDT", assetTransferMethod: "permit2" },
4
+ USDD: { address: "TXDk8mbtRbXeYuMNS83CfKPaYYT8XWv9Hz", decimals: 18, name: "Decentralized USD", symbol: "USDD", assetTransferMethod: "permit2" },
5
+ },
6
+ "tron:nile": {
7
+ USDT: { address: "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", decimals: 6, name: "Tether USD", symbol: "USDT", assetTransferMethod: "permit2" },
8
+ USDD: { address: "TGjgvdTWWrybVLaVeFqSyVqJQWjxqRYbaK", decimals: 18, name: "Decentralized USD", symbol: "USDD", assetTransferMethod: "permit2" },
9
+ },
10
+ "eip155:56": {
11
+ USDT: { address: "0x55d398326f99059fF775485246999027B3197955", decimals: 18, name: "Tether USD", symbol: "USDT", assetTransferMethod: "permit2" },
12
+ },
13
+ "eip155:97": {
14
+ USDT: { address: "0x337610d27c682E347C9cD60BD4b3b107C9d34dDd", decimals: 18, name: "Tether USD", symbol: "USDT", assetTransferMethod: "permit2" },
15
+ USDC: { address: "0x64544969ed7EBf5f083679233325356EbE738930", decimals: 18, name: "USD Coin", symbol: "USDC", assetTransferMethod: "permit2" },
16
+ },
17
+ };
18
+ export function normalizeNetwork(network) {
19
+ return ({
20
+ "tron-mainnet": "tron:mainnet",
21
+ "tron-shasta": "tron:shasta",
22
+ "tron-nile": "tron:nile",
23
+ "bsc-mainnet": "eip155:56",
24
+ "bsc-testnet": "eip155:97",
25
+ }[network] ?? network);
26
+ }
27
+ export function getToken(network, symbol) {
28
+ const normalized = normalizeNetwork(network);
29
+ const token = TOKENS[normalized]?.[symbol.toUpperCase()];
30
+ if (!token)
31
+ throw new Error(`unknown token ${symbol} on ${network}`);
32
+ return token;
33
+ }
34
+ export function toSmallestUnit(amount, decimals) {
35
+ const [whole, fraction = ""] = String(amount).split(".");
36
+ const padded = (fraction + "0".repeat(decimals)).slice(0, decimals);
37
+ return (BigInt(whole || "0") * 10n ** BigInt(decimals) + BigInt(padded || "0")).toString();
38
+ }
@@ -0,0 +1,25 @@
1
+ import { decodePaymentSignatureHeader, encodePaymentRequiredHeader, encodePaymentResponseHeader, } from "@bankofai/x402-core/http";
2
+ export const headers = {
3
+ required: "PAYMENT-REQUIRED",
4
+ signature: "PAYMENT-SIGNATURE",
5
+ response: "PAYMENT-RESPONSE",
6
+ };
7
+ export function encodeRequired(value) {
8
+ return encodePaymentRequiredHeader(value);
9
+ }
10
+ export function encodeResponse(value) {
11
+ return encodePaymentResponseHeader(value);
12
+ }
13
+ export function decodeSignature(value) {
14
+ return decodePaymentSignatureHeader(value);
15
+ }
16
+ export function matchRequirement(payload, requirement) {
17
+ const accepted = payload?.accepted ?? payload?.payment?.accepted ?? payload;
18
+ if (!accepted || typeof accepted !== "object")
19
+ return false;
20
+ return (accepted.scheme === requirement.scheme &&
21
+ accepted.network === requirement.network &&
22
+ String(accepted.amount) === requirement.amount &&
23
+ String(accepted.asset).toLowerCase() === requirement.asset.toLowerCase() &&
24
+ String(accepted.payTo) === requirement.payTo);
25
+ }
package/dist/tokens.js CHANGED
@@ -56,9 +56,9 @@ export const TOKENS = {
56
56
  },
57
57
  "eip155:97": {
58
58
  USDT: {
59
- address: "0x64544969ed7EBf5f083679233325356EbE738930",
59
+ address: "0x337610d27c682E347C9cD60BD4b3b107C9d34dDd",
60
60
  decimals: 18,
61
- name: "USD Coin",
61
+ name: "Tether USD",
62
62
  symbol: "USDT",
63
63
  version: "1",
64
64
  assetTransferMethod: "permit2",
@@ -92,8 +92,19 @@ export function findTokenByAddress(network, address) {
92
92
  const lower = address.toLowerCase();
93
93
  return Object.values(TOKENS[normalizeNetwork(network)] ?? {}).find(token => token.address.toLowerCase() === lower);
94
94
  }
95
+ export function assertRawAmount(value, name = "raw amount") {
96
+ if (!/^\d+$/.test(value))
97
+ throw new Error(`${name} must be a non-negative integer string`);
98
+ return value.replace(/^0+(?=\d)/, "");
99
+ }
95
100
  export function toSmallestUnit(amount, decimals) {
101
+ if (!Number.isInteger(decimals) || decimals < 0)
102
+ throw new Error("token decimals must be a non-negative integer");
103
+ if (!/^\d+(\.\d+)?$/.test(amount))
104
+ throw new Error("amount must be a non-negative decimal string");
96
105
  const [whole, fraction = ""] = amount.split(".");
106
+ if (fraction.length > decimals)
107
+ throw new Error(`amount has more than ${decimals} decimal places`);
97
108
  const padded = (fraction + "0".repeat(decimals)).slice(0, decimals);
98
- return (BigInt(whole || "0") * 10n ** BigInt(decimals) + BigInt(padded || "0")).toString();
109
+ return assertRawAmount((BigInt(whole || "0") * 10n ** BigInt(decimals) + BigInt(padded || "0")).toString());
99
110
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bankofai/x402-cli",
3
- "version": "1.0.0-beta.3",
3
+ "version": "1.0.0-beta.5",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist"
@@ -10,6 +10,8 @@
10
10
  },
11
11
  "scripts": {
12
12
  "build": "tsc -p tsconfig.json",
13
+ "bundle:gateway": "node scripts/bundle-gateway.mjs",
14
+ "prepack": "npm run build && npm run bundle:gateway",
13
15
  "test": "npm run build && node --test tests/*.test.mjs",
14
16
  "start": "node dist/cli.js",
15
17
  "dev": "tsx src/cli.ts"
@@ -20,9 +22,10 @@
20
22
  "dependencies": {
21
23
  "@bankofai/x402-core": "1.0.0",
22
24
  "@bankofai/x402-evm": "1.0.0",
25
+ "@bankofai/x402-gateway": "^1.0.0-beta.1",
23
26
  "@bankofai/x402-tron": "1.0.0",
24
- "tronweb": "^6.0.4",
25
- "viem": "^2.40.3",
27
+ "tronweb": "6.0.2",
28
+ "viem": "^2.55.0",
26
29
  "yaml": "^2.8.2"
27
30
  },
28
31
  "devDependencies": {