@bankofai/x402-gateway 1.0.0-beta.0
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/LICENSE +21 -0
- package/README.md +107 -0
- package/dist/cli.js +33 -0
- package/dist/config.js +136 -0
- package/dist/server.js +249 -0
- package/dist/tokens.js +38 -0
- package/dist/x402.js +25 -0
- package/examples/provider.yml +53 -0
- package/package.json +34 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 BANK OF AI
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# x402 Gateway
|
|
2
|
+
|
|
3
|
+
TypeScript reverse proxy for paid HTTP APIs. This version uses the npm
|
|
4
|
+
TypeScript x402 SDK packages only:
|
|
5
|
+
|
|
6
|
+
- `@bankofai/x402-core@1.0.0`
|
|
7
|
+
- `@bankofai/x402-evm@1.0.0`
|
|
8
|
+
- `@bankofai/x402-tron@1.0.0`
|
|
9
|
+
|
|
10
|
+
Payment requirements are emitted as `scheme=exact`; supported stablecoins add
|
|
11
|
+
`extra.assetTransferMethod=permit2`.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install
|
|
17
|
+
npm run build
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Run
|
|
21
|
+
|
|
22
|
+
Start from a single provider file:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
node dist/cli.js --provider examples/provider.yml --host 127.0.0.1 --port 4020
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Start from a providers directory:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
node dist/cli.js --providers providers --host 0.0.0.0 --port 4020
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Health check:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
curl http://127.0.0.1:4020/__402/health
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Paid provider path:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
curl -i http://127.0.0.1:4020/providers/tron-nile-usdt/v1/ping
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
If the endpoint has metering, the gateway returns `402 Payment Required` with a
|
|
47
|
+
`PAYMENT-REQUIRED` header. After the client retries with `PAYMENT-SIGNATURE`,
|
|
48
|
+
the gateway verifies and settles with the facilitator, then forwards to the
|
|
49
|
+
configured upstream.
|
|
50
|
+
|
|
51
|
+
## Provider Config
|
|
52
|
+
|
|
53
|
+
Provider files stay in YAML:
|
|
54
|
+
|
|
55
|
+
```yaml
|
|
56
|
+
name: tron-nile-usdt
|
|
57
|
+
forward_url: ${X402_PROVIDER_FORWARD_URL}
|
|
58
|
+
|
|
59
|
+
routing:
|
|
60
|
+
auth:
|
|
61
|
+
method: header
|
|
62
|
+
key: Authorization
|
|
63
|
+
prefix: "Bearer "
|
|
64
|
+
value_from_env: X402_PROVIDER_API_TOKEN
|
|
65
|
+
|
|
66
|
+
operator:
|
|
67
|
+
network: tron-nile
|
|
68
|
+
currencies:
|
|
69
|
+
usd: ["USDT"]
|
|
70
|
+
recipient: ${X402_PROVIDER_RECIPIENT_TRON}
|
|
71
|
+
scheme: exact
|
|
72
|
+
protocol: exact
|
|
73
|
+
asset_transfer_method: permit2
|
|
74
|
+
facilitator_url: ${X402_FACILITATOR_URL}
|
|
75
|
+
facilitator_api_key_env: X402_FACILITATOR_API_KEY
|
|
76
|
+
valid_for_seconds: 300
|
|
77
|
+
|
|
78
|
+
endpoints:
|
|
79
|
+
- method: GET
|
|
80
|
+
path: /v1/ping
|
|
81
|
+
metering:
|
|
82
|
+
dimensions:
|
|
83
|
+
- tiers:
|
|
84
|
+
- price_usd: 0.002
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
`@bankofai/x402-*` 1.0.0 uses `scheme: exact` with
|
|
88
|
+
`extra.assetTransferMethod: permit2` in the payment requirement. Older provider
|
|
89
|
+
configs that say `exact_permit` are normalized at load time, but new provider
|
|
90
|
+
configs should use `protocol: exact` and `asset_transfer_method: permit2`.
|
|
91
|
+
|
|
92
|
+
Network aliases accepted:
|
|
93
|
+
|
|
94
|
+
- `tron-mainnet` -> `tron:mainnet`
|
|
95
|
+
- `tron-nile` -> `tron:nile`
|
|
96
|
+
- `bsc-mainnet` -> `eip155:56`
|
|
97
|
+
- `bsc-testnet` -> `eip155:97`
|
|
98
|
+
|
|
99
|
+
## Environment
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
X402_FACILITATOR_URL=https://facilitator.bankofai.io
|
|
103
|
+
X402_FACILITATOR_API_KEY=<optional-facilitator-api-key>
|
|
104
|
+
X402_PROVIDER_FORWARD_URL=<upstream-base-url>
|
|
105
|
+
X402_PROVIDER_RECIPIENT_TRON=<recipient-T-address>
|
|
106
|
+
X402_PROVIDER_API_TOKEN=<upstream-token>
|
|
107
|
+
```
|
package/dist/cli.js
ADDED
|
@@ -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
|
+
});
|
package/dist/config.js
ADDED
|
@@ -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
|
+
}
|
package/dist/server.js
ADDED
|
@@ -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
|
+
}
|
package/dist/tokens.js
ADDED
|
@@ -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
|
+
}
|
package/dist/x402.js
ADDED
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
name: tron-nile-usdt
|
|
2
|
+
title: "TRON Nile USDT Provider"
|
|
3
|
+
description: "TRON Nile provider protected by x402 exact Permit2 USDT payment"
|
|
4
|
+
category: data
|
|
5
|
+
version: v1
|
|
6
|
+
|
|
7
|
+
forward_url: ${X402_PROVIDER_FORWARD_URL}
|
|
8
|
+
openapi_url: ${X402_PROVIDER_OPENAPI_URL}
|
|
9
|
+
|
|
10
|
+
display:
|
|
11
|
+
service_url: ${X402_GATEWAY_PUBLIC_BASE_URL}/providers/tron-nile-usdt
|
|
12
|
+
tags: ["tron-nile", "usdt", "x402"]
|
|
13
|
+
|
|
14
|
+
discovery:
|
|
15
|
+
use_case: "Use for validating TRON Nile USDT x402 payments."
|
|
16
|
+
spend_aware_usage:
|
|
17
|
+
- "Use small Nile USDT amounts while validating facilitator and gateway wiring."
|
|
18
|
+
when_to_use:
|
|
19
|
+
- "Use when testing x402 exact Permit2 payments on TRON Nile."
|
|
20
|
+
request_examples:
|
|
21
|
+
- "GET /providers/tron-nile-usdt/v1/ping"
|
|
22
|
+
|
|
23
|
+
routing:
|
|
24
|
+
type: proxy
|
|
25
|
+
auth:
|
|
26
|
+
method: header
|
|
27
|
+
key: Authorization
|
|
28
|
+
prefix: "Bearer "
|
|
29
|
+
value_from_env: X402_PROVIDER_API_TOKEN
|
|
30
|
+
|
|
31
|
+
operator:
|
|
32
|
+
network: tron-nile
|
|
33
|
+
currencies:
|
|
34
|
+
usd: ["USDT"]
|
|
35
|
+
recipient: ${X402_PROVIDER_RECIPIENT_TRON}
|
|
36
|
+
scheme: exact
|
|
37
|
+
protocol: exact
|
|
38
|
+
asset_transfer_method: permit2
|
|
39
|
+
facilitator_url: https://tn-facilitator.bankofai.io
|
|
40
|
+
facilitator_api_key_env: X402_FACILITATOR_API_KEY
|
|
41
|
+
valid_for_seconds: 300
|
|
42
|
+
|
|
43
|
+
endpoints:
|
|
44
|
+
- method: GET
|
|
45
|
+
path: /v1/ping
|
|
46
|
+
description: "TRON Nile USDT paid ping endpoint"
|
|
47
|
+
metering:
|
|
48
|
+
dimensions:
|
|
49
|
+
- direction: usage
|
|
50
|
+
unit: requests
|
|
51
|
+
scale: 1
|
|
52
|
+
tiers:
|
|
53
|
+
- price_usd: 0.0001
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@bankofai/x402-gateway",
|
|
3
|
+
"version": "1.0.0-beta.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"files": [
|
|
7
|
+
"dist",
|
|
8
|
+
"examples",
|
|
9
|
+
"README.md",
|
|
10
|
+
"LICENSE"
|
|
11
|
+
],
|
|
12
|
+
"bin": {
|
|
13
|
+
"x402-gateway": "./dist/cli.js"
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "tsc -p tsconfig.json",
|
|
17
|
+
"start": "node dist/cli.js",
|
|
18
|
+
"dev": "tsx src/cli.ts"
|
|
19
|
+
},
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=20"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@bankofai/x402-core": "1.0.0",
|
|
25
|
+
"@bankofai/x402-evm": "1.0.0",
|
|
26
|
+
"@bankofai/x402-tron": "1.0.0",
|
|
27
|
+
"yaml": "^2.8.2"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@types/node": "^24.10.1",
|
|
31
|
+
"tsx": "^4.20.6",
|
|
32
|
+
"typescript": "^5.9.3"
|
|
33
|
+
}
|
|
34
|
+
}
|