Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ LOG_LEVEL=info
TRON_RPC_URL=
# Public base URL used in x402 resource metadata. Set this behind gateways that reject localhost/private URLs.
PUBLIC_RESOURCE_BASE_URL=
# x402 settlement endpoint.
X402_FACILITATOR_URL=https://facilitator-v2.bankofai.io
# x402 production settlement endpoint.
X402_FACILITATOR_URL=https://facilitator.bankofai.io
# Facilitator auth for higher rate limits. Sent as X-API-KEY.
X402_FACILITATOR_API_KEY=
# Backward-compatible alias. Use X402_FACILITATOR_API_KEY for new deployments.
Expand Down
5 changes: 4 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ RUN npm run build

FROM node:22-slim

ENV NODE_ENV=production
ENV NODE_ENV=production \
BANKOFAI_ENV=prod \
X402_FACILITATOR_URL=https://facilitator.bankofai.io \
TRON_RPC_URL=

WORKDIR /app

Expand Down
2 changes: 1 addition & 1 deletion config/networks.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"mainnet": {
"name": "TRON Mainnet",
"paymentNetwork": "tron:mainnet",
"paymentNetwork": "tron:0x2b6653dc",
"rpcUrl": "https://api.trongrid.io",
"explorer": "https://tronscan.org",
"chainId": "728126428",
Expand Down
55 changes: 17 additions & 38 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 6 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@
"node": ">=22.0.0"
},
"dependencies": {
"@bankofai/x402-core": "1.0.0",
"@bankofai/x402-evm": "1.0.0",
"@bankofai/x402-tron": "1.0.0",
"@bankofai/x402-core": "1.0.1",
"@bankofai/x402-evm": "1.0.1",
"@bankofai/x402-tron": "1.0.1",
"@modelcontextprotocol/sdk": "1.29.0",
"dotenv": "^16.4.7",
"express": "^5.1.0",
Expand All @@ -31,5 +31,8 @@
"tsx": "^4.21.0",
"typescript": "^5.7.3",
"viem": "^2.54.1"
},
"overrides": {
"ws": "8.21.0"
}
}
15 changes: 13 additions & 2 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,14 +50,25 @@ function envNumber(name: string, fallback: number): number {
return parsed;
}

function defaultFacilitatorUrl(bankofaiEnv: string): string {
return bankofaiEnv.toLowerCase().trim() === "dev"
? "https://tn-facilitator.bankofai.io"
: "https://facilitator.bankofai.io";
}

const bankofaiEnv = envString("BANKOFAI_ENV", "prod");
const x402FacilitatorUrl = bankofaiEnv.toLowerCase().trim() === "dev"
? defaultFacilitatorUrl(bankofaiEnv)
: envString("X402_FACILITATOR_URL", defaultFacilitatorUrl(bankofaiEnv));

export const settings = {
bankofaiEnv: envString("BANKOFAI_ENV", "prod"),
bankofaiEnv,
tronRpcUrl: envString("TRON_RPC_URL", ""),
publicResourceBaseUrl: envString("PUBLIC_RESOURCE_BASE_URL", ""),
host: envString("HOST", "0.0.0.0"),
port: envNumber("PORT", 8000),
logLevel: envString("LOG_LEVEL", "info"),
x402FacilitatorUrl: envString("X402_FACILITATOR_URL", "https://facilitator-v2.bankofai.io"),
x402FacilitatorUrl,
facilitatorApiKey: envString("X402_FACILITATOR_API_KEY", envString("FACILITATOR_API_KEY", "")),
facilitatorTimeoutSeconds: envNumber("FACILITATOR_TIMEOUT_SECONDS", 10),
facilitatorVerifyRetries: envNumber("FACILITATOR_VERIFY_RETRIES", 1),
Expand Down
72 changes: 55 additions & 17 deletions src/payments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import crypto from "node:crypto";
import type { Network, PaymentPayload, PaymentRequired, PaymentRequirements, SettleResponse } from "@bankofai/x402-core/types";
import { HTTPFacilitatorClient } from "@bankofai/x402-core/http";
import { decodePaymentSignatureHeader } from "@bankofai/x402-core/http";
import { getToken } from "@bankofai/x402-tron";
import { getToken, TRON_MAINNET } from "@bankofai/x402-tron";
import { getDefaultAsset } from "@bankofai/x402-evm";
import { NetworkConfig, networkConfig, networkConfigs, settings } from "./config.js";
import { PaymentFlowError } from "./errors.js";
Expand All @@ -11,7 +11,8 @@ import { logger } from "./logger.js";

const ALLOWED_TRC20_TOKENS = new Set(["USDT", "USDD"]);
const BSC_ALLOWED_TOKENS = new Set(["USDT"]);
const PAYMENT_SCHEME = "exact";
const EXACT_SCHEME = "exact";
const GASFREE_SCHEME = "exact_gasfree";
const MIN_RECHARGE_AMOUNT = "1";
const MAX_RECHARGE_AMOUNT = "20000";

Expand Down Expand Up @@ -168,12 +169,34 @@ function decimalToSmallestUnit(amount: string, decimals: number): bigint {
return BigInt(`${parsed.intPart}${paddedFrac}` || "0");
}

function paymentExtra(cfg: NetworkConfig, tokenSymbol: string): Record<string, unknown> {
function sdkTronNetwork(paymentNetwork: string): Network {
if (paymentNetwork === "tron:mainnet") {
return TRON_MAINNET as Network;
}
return paymentNetwork as Network;
}

function facilitatorNetworkCandidates(paymentNetwork: string): Network[] {
const candidates = [paymentNetwork as Network];
const sdkNetwork = sdkTronNetwork(paymentNetwork);
if (!candidates.includes(sdkNetwork)) {
candidates.push(sdkNetwork);
}
return candidates;
}

function paymentExtra(cfg: NetworkConfig, tokenSymbol: string, scheme: string): Record<string, unknown> {
if (cfg.paymentNetwork.startsWith("tron:")) {
const token = getToken(cfg.paymentNetwork as Network, tokenSymbol);
const token = getToken(sdkTronNetwork(cfg.paymentNetwork), tokenSymbol);
if (!token) {
return {};
}
if (scheme === GASFREE_SCHEME) {
return {
name: token.name,
...(token.version !== undefined ? { version: token.version } : {})
};
}
const includeTip712Domain = !token.assetTransferMethod || Boolean(token.supportsEip2612);
return {
...(includeTip712Domain && token.version !== undefined ? { name: token.name, version: token.version } : {}),
Expand Down Expand Up @@ -264,15 +287,20 @@ export async function buildRechargeChallenge(amount: string, token: string, reso
continue;
}

accepts.push({
scheme: PAYMENT_SCHEME,
network: cfg.paymentNetwork as Network,
amount: amountSmallest.toString(),
asset: tokenCfg.address,
payTo: cfg.bankofaiDepositAddress,
maxTimeoutSeconds: 3600,
extra: paymentExtra(cfg, tokenSymbol)
});
const schemes = cfg.paymentNetwork.startsWith("tron:")
? [EXACT_SCHEME, GASFREE_SCHEME]
: [EXACT_SCHEME];
for (const scheme of schemes) {
accepts.push({
scheme,
network: cfg.paymentNetwork as Network,
amount: amountSmallest.toString(),
asset: tokenCfg.address,
payTo: cfg.bankofaiDepositAddress,
maxTimeoutSeconds: 3600,
extra: paymentExtra(cfg, tokenSymbol, scheme)
});
}
}

if (accepts.length === 0) {
Expand All @@ -289,8 +317,10 @@ export async function buildRechargeChallenge(amount: string, token: string, reso
}

const supportedKinds = supported.kinds ?? [];
const filteredAccepts = accepts.map((accept) => {
const supportedKind = supportedKinds.find((kind) => kind.scheme === accept.scheme && kind.network === accept.network);
const filteredAccepts = accepts.flatMap((accept) => {
const supportedKind = supportedKinds.find((kind) =>
kind.scheme === accept.scheme && kind.network !== undefined && facilitatorNetworkCandidates(String(accept.network)).includes(kind.network as Network)
);
if (!supportedKind) {
logger.warn("advertising route not present in facilitator supported response", {
tokenSymbol,
Expand All @@ -301,6 +331,7 @@ export async function buildRechargeChallenge(amount: string, token: string, reso
}
return {
...accept,
network: (supportedKind.network ?? accept.network) as Network,
extra: {
...(supportedKind.extra ?? {}),
...(accept.extra ?? {})
Expand Down Expand Up @@ -357,6 +388,10 @@ function selectedRequirementFromPayload(payload: PaymentPayload, challenge: Paym

function paymentWalletAddress(payload: PaymentPayload): string {
const payment = payload.payload as Record<string, unknown>;
const gasfree = payment.gasfree as Record<string, unknown> | undefined;
if (typeof gasfree?.user === "string") {
return gasfree.user;
}
const permit = payment.paymentPermit as Record<string, unknown> | undefined;
if (typeof permit?.buyer === "string") {
return permit.buyer;
Expand Down Expand Up @@ -454,7 +489,10 @@ async function settleDecodedPayload(

export function findNetworkConfigByPaymentNetwork(paymentNetwork: string): NetworkConfig {
for (const cfg of supportedPaymentNetworkConfigs()) {
if (String(cfg.paymentNetwork) === String(paymentNetwork)) {
if (
String(cfg.paymentNetwork) === String(paymentNetwork) ||
String(sdkTronNetwork(cfg.paymentNetwork)) === String(paymentNetwork)
) {
return cfg;
}
}
Expand All @@ -470,7 +508,7 @@ export function txExplorerUrl(txHash: string, paymentNetwork: string): string {
}

export function bankofaiChainId(paymentNetwork: string): string {
if (paymentNetwork === "tron:mainnet") {
if (paymentNetwork === TRON_MAINNET || paymentNetwork === "tron:mainnet") {
return "eip155:728126428";
}
if (paymentNetwork === "eip155:56") {
Expand Down
3 changes: 3 additions & 0 deletions test/payments.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import assert from "node:assert/strict";
import test from "node:test";
import { networkConfigs } from "../src/config.js";
import { bankofaiChainId, normalizeToken, supportedTokens } from "../src/payments.js";

test("supported tokens are restricted to recharge payment scope", () => {
Expand All @@ -10,6 +11,8 @@ test("supported tokens are restricted to recharge payment scope", () => {
});

test("BANK OF AI chain ids are mainnet only", () => {
assert.equal(networkConfigs.mainnet.paymentNetwork, "tron:0x2b6653dc");
assert.equal(bankofaiChainId("tron:0x2b6653dc"), "eip155:728126428");
assert.equal(bankofaiChainId("tron:mainnet"), "eip155:728126428");
assert.equal(bankofaiChainId("eip155:56"), "eip155:56");
assert.throws(() => bankofaiChainId("tron:nile"), /Unsupported chain mapping/);
Expand Down
Loading