feat(x402): Added x402 protection for each endpoint

This commit is contained in:
Chris Troutner
2025-11-09 21:42:00 -08:00
parent b41fa29c36
commit eb57a7c773
7 changed files with 1159 additions and 85 deletions
+22
View File
@@ -6,3 +6,25 @@ This is a REST API for communicating with Bitcoin Cash infrastructure. It replac
[MIT](./LICENSE.md)
## x402-bch Payments
All REST endpoints exposed under the `/v6` prefix are protected by the [`x402-bch-express`](https://www.npmjs.com/package/x402-bch-express) middleware. Each API call requires a BCH payment authorization for **2000 satoshis**. The middleware advertises payment requirements via HTTP 402 responses and validates incoming `X-PAYMENT` headers with a configured Facilitator.
### Configuration
Environment variables control the payment flow:
- `X402_ENABLED` — set to `false` (case-insensitive) to disable the middleware. Defaults to enabled.
- `SERVER_BCH_ADDRESS` — BCH cash address that receives funding transactions. Defaults to `bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d`.
- `FACILITATOR_URL` — Root URL of the facilitator service (e.g., `http://localhost:4345/facilitator`).
- `X402_PRICE_SAT` — Optional; override the satoshi price per call (defaults to `2000`).
When `X402_ENABLED=false`, the server continues to operate without payment headers for local development or trusted deployments.
### Manual Verification
1. Start or point to an `x402-bch` facilitator service (the example facilitator listens at `http://localhost:4345/facilitator`).
2. Run the API server with the default configuration: `npm start`.
3. Call a protected endpoint without an `X-PAYMENT` header, e.g. `curl -i http://localhost:5942/v6/full-node/control/getNetworkInfo`. The server will respond with HTTP `402` and include payment requirements.
4. Restart the server with `X402_ENABLED=false npm start` to confirm that the same request now bypasses the middleware (useful for local development without payments).
+22
View File
@@ -7,6 +7,7 @@
import express from 'express'
import cors from 'cors'
import dotenv from 'dotenv'
import { paymentMiddleware as x402PaymentMiddleware } from 'x402-bch-express'
import { fileURLToPath } from 'url'
import { dirname, join } from 'path'
@@ -14,6 +15,7 @@ import { dirname, join } from 'path'
import config from '../src/config/index.js'
import Controllers from '../src/controllers/index.js'
import wlogger from '../src/adapters/wlogger.js'
import { buildX402Routes, getX402Settings } from '../src/config/x402.js'
// Load environment variables
dotenv.config()
@@ -57,6 +59,8 @@ class Server {
// Create an Express instance.
const app = express()
const x402Settings = getX402Settings()
// MIDDLEWARE START
app.use(express.json())
app.use(express.urlencoded({ extended: true }))
@@ -68,6 +72,24 @@ class Server {
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With']
}))
if (x402Settings.enabled) {
const routes = buildX402Routes(this.config.apiPrefix)
const facilitatorOptions = x402Settings.facilitatorUrl
? { url: x402Settings.facilitatorUrl }
: undefined
wlogger.info(`x402 middleware enabled; enforcing ${x402Settings.priceSat} satoshis per request`)
app.use(
x402PaymentMiddleware(
x402Settings.serverAddress,
routes,
facilitatorOptions
)
)
} else {
wlogger.info('x402 middleware disabled via configuration')
}
// Endpoint logging middleware
app.use((req, res, next) => {
console.log(`Endpoint called: ${req.method} ${req.path}`)
+1043 -83
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -20,7 +20,8 @@
"dotenv": "16.3.1",
"express": "5.1.0",
"winston": "3.11.0",
"winston-daily-rotate-file": "4.7.1"
"winston-daily-rotate-file": "4.7.1",
"x402-bch-express": "1.1.1"
},
"devDependencies": {
"apidoc": "1.2.0",
+24
View File
@@ -16,6 +16,25 @@ const pkgInfo = JSON.parse(readFileSync(`${__dirname.toString()}/../../../packag
const version = pkgInfo.version
const normalizeBoolean = (value, defaultValue) => {
if (value === undefined || value === null || value === '') return defaultValue
const normalized = String(value).trim().toLowerCase()
if (['false', '0', 'no', 'off'].includes(normalized)) return false
if (['true', '1', 'yes', 'on'].includes(normalized)) return true
return defaultValue
}
const parsedPriceSat = Number(process.env.X402_PRICE_SAT)
const priceSat = Number.isFinite(parsedPriceSat) && parsedPriceSat > 0 ? parsedPriceSat : 2000
const x402Defaults = {
enabled: normalizeBoolean(process.env.X402_ENABLED, true),
facilitatorUrl: process.env.FACILITATOR_URL || 'http://localhost:4345/facilitator',
serverAddress: process.env.SERVER_BCH_ADDRESS || 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d',
priceSat
}
export default {
// Server port
port: process.env.PORT || 5942,
@@ -23,6 +42,9 @@ export default {
// Environment
env: process.env.NODE_ENV || 'development',
// API prefix for REST controllers
apiPrefix: process.env.API_PREFIX || '/v6',
// Logging level
logLevel: process.env.LOG_LEVEL || 'info',
@@ -59,6 +81,8 @@ export default {
rpcRequestIdPrefix: process.env.RPC_REQUEST_ID_PREFIX || 'psf-bch-api'
},
x402: x402Defaults,
// Version
version
}
+43
View File
@@ -0,0 +1,43 @@
import config from './index.js'
const DEFAULT_DESCRIPTION = 'Access to protected psf-bch-api resources'
const DEFAULT_TIMEOUT_SECONDS = 60
const NETWORK = 'bch'
/**
* Builds a route configuration map for x402-bch middleware.
*
* @param {string} apiPrefix Express API prefix (e.g., "/v6")
* @returns {Object} Routes configuration compatible with x402-bch-express
*/
export function buildX402Routes (apiPrefix = '/v6') {
const normalizedPrefix = apiPrefix.endsWith('/')
? apiPrefix.slice(0, -1)
: apiPrefix
const prefixWithSlash = normalizedPrefix.startsWith('/')
? normalizedPrefix
: `/${normalizedPrefix}`
const routeKey = `${prefixWithSlash}/*`
return {
network: NETWORK,
[routeKey]: {
price: config.x402.priceSat,
network: NETWORK,
config: {
description: `${DEFAULT_DESCRIPTION} (2000 satoshis)`,
maxTimeoutSeconds: DEFAULT_TIMEOUT_SECONDS
}
}
}
}
export function getX402Settings () {
return {
enabled: Boolean(config.x402?.enabled),
facilitatorUrl: config.x402?.facilitatorUrl,
serverAddress: config.x402?.serverAddress,
priceSat: config.x402?.priceSat
}
}
+3 -1
View File
@@ -18,6 +18,7 @@ class Controllers {
this.useCases = new UseCases({ adapters: this.adapters })
this.config = config
this.timerController = new TimerController({ adapters: this.adapters, useCases: this.useCases })
this.apiPrefix = this.config.apiPrefix || '/v6'
// Bind 'this' object to all subfunctions
this.initAdapters = this.initAdapters.bind(this)
@@ -45,7 +46,8 @@ class Controllers {
attachRESTControllers (app) {
const restControllers = new RESTControllers({
adapters: this.adapters,
useCases: this.useCases
useCases: this.useCases,
apiPrefix: this.apiPrefix
})
// Attach the REST API Controllers to the Express app.