@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 +123 -34
- package/dist/gateway/cli.js +33 -0
- package/dist/gateway/config.js +136 -0
- package/dist/gateway/server.js +249 -0
- package/dist/gateway/tokens.js +38 -0
- package/dist/gateway/x402.js +25 -0
- package/dist/tokens.js +14 -3
- package/package.json +6 -3
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) =>
|
|
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://
|
|
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}
|
|
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://
|
|
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}
|
|
767
|
-
const
|
|
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
|
|
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:
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
914
|
-
|
|
915
|
-
|
|
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 ===
|
|
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"
|
|
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
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
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/
|
|
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,
|
|
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: "
|
|
59
|
+
address: "0x337610d27c682E347C9cD60BD4b3b107C9d34dDd",
|
|
60
60
|
decimals: 18,
|
|
61
|
-
name: "USD
|
|
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
|
+
"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": "
|
|
25
|
-
"viem": "^2.
|
|
27
|
+
"tronweb": "6.0.2",
|
|
28
|
+
"viem": "^2.55.0",
|
|
26
29
|
"yaml": "^2.8.2"
|
|
27
30
|
},
|
|
28
31
|
"devDependencies": {
|