Merge pull request #1 from Permissionless-Software-Foundation/dh-x402-base

feat(x402): Added x402 Base blockchain support
This commit is contained in:
Chris Troutner
2026-03-22 09:56:37 -07:00
committed by GitHub
9 changed files with 6859 additions and 219 deletions
+16 -13
View File
@@ -1,35 +1,38 @@
# START INFRASTRUCTURE SETUP # START INFRASTRUCTURE SETUP
# Full Node Connection # Full Node Connection
RPC_BASEURL=http://172.17.0.1:8332 export RPC_BASEURL=http://172.17.0.1:8332
RPC_USERNAME=bitcoin export RPC_USERNAME=bitcoin
RPC_PASSWORD=password export RPC_PASSWORD=password
# Fulcrum Indexer # Fulcrum Indexer
FULCRUM_API=http://172.17.0.1:3001/v1 export FULCRUM_API=http://172.17.0.1:3001/v1
# SLP Indexer # SLP Indexer
SLP_INDEXER_API=http://localhost:5010 export SLP_INDEXER_API=http://localhost:5010
# REST API URL for wallet operations # REST API URL for wallet operations
LOCAL_RESTURL=http://localhost:5942/v6 export LOCAL_RESTURL=http://localhost:5942/v6
# END INFRASTRUCTURE SETUP # END INFRASTRUCTURE SETUP
# START ACCESS CONTROL # START ACCESS CONTROL
PORT=5942 export PORT=5942
# x402 payments required to access this API? # x402 payments required to access this API?
X402_ENABLED=true export X402_ENABLED=true
SERVER_BCH_ADDRESS=bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d export SERVER_BASE_ADDRESS=0xd32585CE60815654C50CAf350e18de8096061e63
FACILITATOR_URL=http://localhost:4345/facilitator export X402_PRICE_USDC=0.1
X402_PRICE_SAT=200 export x402_NETWORK=base
export x402_FACILITATOR_URL=http://localhost:4022
# Basic Authentication required to access this API? # Basic Authentication required to access this API?
USE_BASIC_AUTH=true export USE_BASIC_AUTH=true
BASIC_AUTH_TOKEN=some-random-token export BASIC_AUTH_TOKEN=some-random-token
# END ACCESS CONTROL # END ACCESS CONTROL
npm start
+4
View File
@@ -0,0 +1,4 @@
export EVM_PRIVATE_KEY=
export NETWORK=base
node facilitator-server.js
+2
View File
@@ -3,3 +3,5 @@ node_modules/
logs/ logs/
docs/ docs/
coverage/ coverage/
start.sh
start-facilitator.sh
+22 -10
View File
@@ -7,7 +7,8 @@
import express from 'express' import express from 'express'
import cors from 'cors' import cors from 'cors'
import dotenv from 'dotenv' import dotenv from 'dotenv'
import { paymentMiddleware as x402PaymentMiddleware } from 'x402-bch-express' import { paymentMiddleware as x402PaymentMiddleware } from 'x402-express'
import { fileURLToPath } from 'url' import { fileURLToPath } from 'url'
import { dirname, join } from 'path' import { dirname, join } from 'path'
@@ -15,7 +16,7 @@ import { dirname, join } from 'path'
import config from '../src/config/index.js' import config from '../src/config/index.js'
import Controllers from '../src/controllers/index.js' import Controllers from '../src/controllers/index.js'
import wlogger from '../src/adapters/wlogger.js' import wlogger from '../src/adapters/wlogger.js'
import { buildX402Routes, getX402Settings, getBasicAuthSettings } from '../src/config/x402.js' import { buildX402Routes, getX402Settings, getBasicAuthSettings, createAuthHeader } from '../src/config/x402.js'
import { basicAuthMiddleware } from '../src/middleware/basic-auth.js' import { basicAuthMiddleware } from '../src/middleware/basic-auth.js'
// Load environment variables // Load environment variables
@@ -105,14 +106,20 @@ class Server {
if (x402Settings.enabled && basicAuthSettings.enabled) { if (x402Settings.enabled && basicAuthSettings.enabled) {
// X402_ENABLED=true AND USE_BASIC_AUTH=true: Apply x402 conditionally // X402_ENABLED=true AND USE_BASIC_AUTH=true: Apply x402 conditionally
const routes = buildX402Routes(this.config.apiPrefix) const routes = buildX402Routes(this.config.apiPrefix)
const facilitatorOptions = x402Settings.facilitatorUrl let facilitatorOptions = x402Settings.facilitatorUrl
? { url: x402Settings.facilitatorUrl }
: undefined
wlogger.info(`x402 middleware enabled with basic auth bypass; enforcing ${x402Settings.priceSat} satoshis per request (unless basic auth provided)`) if (facilitatorOptions) {
facilitatorOptions = {
url: x402Settings.facilitatorUrl,
createAuthHeaders: createAuthHeader
}
}
wlogger.info(`x402 middleware enabled with basic auth bypass; enforcing ${x402Settings.priceUSDC} satoshis per request (unless basic auth provided)`)
// Create conditional x402 middleware that bypasses if basic auth is valid // Create conditional x402 middleware that bypasses if basic auth is valid
const conditionalX402Middleware = (req, res, next) => { const conditionalX402Middleware = (req, res, next) => {
// req.headers['accept'] = 'application/json';
// If basic auth is valid, bypass x402 // If basic auth is valid, bypass x402
if (req.locals?.basicAuthValid === true) { if (req.locals?.basicAuthValid === true) {
return next() return next()
@@ -130,11 +137,16 @@ class Server {
} else if (x402Settings.enabled && !basicAuthSettings.enabled) { } else if (x402Settings.enabled && !basicAuthSettings.enabled) {
// X402_ENABLED=true AND USE_BASIC_AUTH=false: Apply x402 unconditionally (no basic auth bypass) // X402_ENABLED=true AND USE_BASIC_AUTH=false: Apply x402 unconditionally (no basic auth bypass)
const routes = buildX402Routes(this.config.apiPrefix) const routes = buildX402Routes(this.config.apiPrefix)
const facilitatorOptions = x402Settings.facilitatorUrl let facilitatorOptions = x402Settings.facilitatorUrl
? { url: x402Settings.facilitatorUrl }
: undefined
wlogger.info(`x402 middleware enabled (basic auth disabled); enforcing ${x402Settings.priceSat} satoshis per request`) if (facilitatorOptions) {
facilitatorOptions = {
url: x402Settings.facilitatorUrl,
createAuthHeaders: createAuthHeader
}
}
wlogger.info(`x402 middleware enabled (basic auth disabled); enforcing ${x402Settings.priceUSDC} satoshis per request`)
// Apply x402 middleware unconditionally - no basic auth bypass // Apply x402 middleware unconditionally - no basic auth bypass
app.use(x402PaymentMiddleware( app.use(x402PaymentMiddleware(
+61
View File
@@ -0,0 +1,61 @@
import axios from 'axios'
import { withPaymentInterceptor } from 'x402-axios'
import { privateKeyToAccount } from 'viem/accounts'
import { base } from 'viem/chains'
// import { baseSepolia } from 'viem/chains' // Testnet
import { createWalletClient, http } from 'viem'
const baseURL = 'http://localhost:5942'
const endpointPath = '/v6/full-node/blockchain/getBlockchainInfo'
const pKey = process.env.PRIVATE_KEY || ''
// PRIVATE_KEY=0x... node examples/08-x402-axios-client.js
if (!pKey) throw new Error('PRIVATE_KEY env required!')
console.log('pKey', pKey)
// Create a wallet client
const account = privateKeyToAccount(pKey)
const client = createWalletClient({
account,
transport: http(),
chain: base
})
const api = withPaymentInterceptor(
axios.create({
baseURL,
headers: {
'Content-Type': 'application/json'
}
}),
client
)
const request = async () => {
console.log('\n\nStep 1: Making first call, expecting a 402 error returned.')
try {
const response = await axios.get(baseURL + endpointPath)
console.log(response.data)
console.log('Step 1 failed. Expected a 402 error.')
} catch (err) {
console.log(`Status code: ${err?.response?.status}`)
console.log(`Error data: ${JSON.stringify(err.response.data, null, 2)}`)
console.log('\n\n')
}
console.log('\n\nStep 2: Making second call with a payment.')
try {
// Call the same endpoint path with a payment.
const paidRes = await api.get(endpointPath)
console.log('Data returned after payment: ', paidRes.data)
} catch (err) {
console.log('Step 2 failed. Expected a 200 success status code.')
console.log(`Status code: ${err?.response?.status}`)
console.log(`Error data: ${JSON.stringify(err.response.data, null, 2)}`)
process.exit(1)
}
}
request()
+6685 -182
View File
File diff suppressed because it is too large Load Diff
+7 -2
View File
@@ -9,13 +9,16 @@
"lint": "standard --env mocha --fix", "lint": "standard --env mocha --fix",
"test": "npm run lint && TEST=unit c8 mocha 'test/unit/**/*.js' --exit", "test": "npm run lint && TEST=unit c8 mocha 'test/unit/**/*.js' --exit",
"test:integration": "mocha --timeout 25000 'test/integration/**/*.js' --exit", "test:integration": "mocha --timeout 25000 'test/integration/**/*.js' --exit",
"coverage": "c8 --reporter=html mocha 'test/unit/**/*.js' --exit" "coverage": "export NODE_ENV=test && c8 --reporter=html mocha 'test/unit/**/*.js' --exit"
}, },
"author": "Chris Troutner <chris.troutner@gmail.com>", "author": "Chris Troutner <chris.troutner@gmail.com>",
"license": "MIT", "license": "MIT",
"description": "REST API proxy to Bitcoin Cash infrastructure", "description": "REST API proxy to Bitcoin Cash infrastructure",
"dependencies": { "dependencies": {
"@coinbase/cdp-sdk": "1.45.0",
"@psf/bch-js": "7.1.11", "@psf/bch-js": "7.1.11",
"@x402/core": "2.6.0",
"@x402/evm": "2.6.0",
"axios": "1.7.7", "axios": "1.7.7",
"cors": "2.8.5", "cors": "2.8.5",
"dotenv": "16.3.1", "dotenv": "16.3.1",
@@ -25,7 +28,9 @@
"slp-token-media": "1.2.10", "slp-token-media": "1.2.10",
"winston": "3.11.0", "winston": "3.11.0",
"winston-daily-rotate-file": "4.7.1", "winston-daily-rotate-file": "4.7.1",
"x402-bch-express": "2.0.0" "x402-axios": "1.1.0",
"x402-express": "1.1.0",
"x402-fetch": "1.1.0"
}, },
"devDependencies": { "devDependencies": {
"apidoc": "1.2.0", "apidoc": "1.2.0",
+8 -5
View File
@@ -28,14 +28,17 @@ const normalizeBoolean = (value, defaultValue) => {
// By default, the price per API call is 200 satoshis. // By default, the price per API call is 200 satoshis.
// But the user can override this value by setting the X402_PRICE_SAT environment variable. // But the user can override this value by setting the X402_PRICE_SAT environment variable.
const parsedPriceSat = Number(process.env.X402_PRICE_SAT) const parsedPrice = Number(process.env.X402_PRICE_USDC)
const priceSat = Number.isFinite(parsedPriceSat) && parsedPriceSat > 0 ? parsedPriceSat : 200 const priceUSDC = Number.isFinite(parsedPrice) && parsedPrice > 0 ? parsedPrice : 200
const x402Defaults = { const x402Defaults = {
enabled: normalizeBoolean(process.env.X402_ENABLED, true), enabled: normalizeBoolean(process.env.X402_ENABLED, true),
facilitatorUrl: process.env.FACILITATOR_URL || 'http://localhost:4345/facilitator', facilitatorUrl: process.env.x402_FACILITATOR_URL || 'http://localhost:4022',
serverAddress: process.env.SERVER_BCH_ADDRESS || 'bitcoincash:qqsrke9lh257tqen99dkyy2emh4uty0vky9y0z0lsr', serverAddress: process.env.SERVER_BASE_ADDRESS || 'bitcoincash:qqsrke9lh257tqen99dkyy2emh4uty0vky9y0z0lsr',
priceSat facilitatorKeyId: process.env.FACILITATOR_KEY_ID || '',
facilitatorSecretKey: process.env.FACILITATOR_SECRET_KEY || '',
network: process.env.x402_NETWORK || 'base-sepolia',
priceUSDC
} }
const basicAuthDefaults = { const basicAuthDefaults = {
+54 -7
View File
@@ -1,8 +1,10 @@
import config from './index.js' import config from './index.js'
import { generateJwt } from '@coinbase/cdp-sdk/auth'
const DEFAULT_DESCRIPTION = 'Access to protected psf-bch-api resources' const DEFAULT_DESCRIPTION = 'Access to protected psf-bch-api resources'
const DEFAULT_TIMEOUT_SECONDS = 60 const DEFAULT_TIMEOUT_SECONDS = 120
const NETWORK = 'bch' const NETWORK = config.x402.network
if (!NETWORK) throw new Error('x402_NETWORK env required!')
/** /**
* Builds a route configuration map for x402-bch middleware. * Builds a route configuration map for x402-bch middleware.
@@ -23,11 +25,19 @@ export function buildX402Routes (apiPrefix = '/v6') {
return { return {
network: NETWORK, network: NETWORK,
[routeKey]: { [routeKey]: {
price: config.x402.priceSat, price: config.x402.priceUSDC,
network: NETWORK, network: NETWORK,
config: { config: {
description: `${DEFAULT_DESCRIPTION} (${config.x402.priceSat} satoshis)`, description: `${DEFAULT_DESCRIPTION} (${config.x402.priceUSDC} USDC)`,
maxTimeoutSeconds: DEFAULT_TIMEOUT_SECONDS maxTimeoutSeconds: DEFAULT_TIMEOUT_SECONDS,
mimeType: 'application/json',
outputSchema: {
input: {
type: 'http',
method: 'GET',
discoverable: true
}
}
} }
} }
} }
@@ -36,9 +46,11 @@ export function buildX402Routes (apiPrefix = '/v6') {
export function getX402Settings () { export function getX402Settings () {
return { return {
enabled: Boolean(config.x402?.enabled), enabled: Boolean(config.x402?.enabled),
facilitatorUrl: config.x402?.facilitatorUrl, facilitatorUrl: config.x402?.facilitatorUrl, // https://docs.cdp.coinbase.com/x402/quickstart-for-sellers
facilitatorKeyId: config.x402?.facilitatorKeyId,
facilitatorSecretKey: config.x402?.facilitatorSecretKey,
serverAddress: config.x402?.serverAddress, serverAddress: config.x402?.serverAddress,
priceSat: config.x402?.priceSat priceUSDC: config.x402?.priceUSDC
} }
} }
@@ -48,3 +60,38 @@ export function getBasicAuthSettings () {
token: config.basicAuth?.token || '' token: config.basicAuth?.token || ''
} }
} }
/**
*
* Auth headers for interact with mainnet coin base facilitator endpoints.
*
*/
// https://docs.cdp.coinbase.com/get-started/authentication/jwt-authentication#javascript-2
// https://docs.cdp.coinbase.com/api-reference/v2/rest-api/x402-facilitator/verify-a-payment
export async function createAuthHeader () {
// /verify endpoint jwt
const verifyToken = await generateJwt({
apiKeyId: config.x402?.facilitatorKeyId,
apiKeySecret: config.x402?.facilitatorSecretKey,
requestMethod: 'POST',
requestHost: 'api.cdp.coinbase.com',
requestPath: '/platform/v2/x402/verify'
})
// /settle endpoint jwt
const settleToken = await generateJwt({
apiKeyId: config.x402?.facilitatorKeyId,
apiKeySecret: config.x402?.facilitatorSecretKey,
requestMethod: 'POST',
requestHost: 'api.cdp.coinbase.com',
requestPath: '/platform/v2/x402/settle'
})
return {
verify: {
Authorization: `Bearer ${verifyToken}`
},
settle: {
Authorization: `Bearer ${settleToken}`
}
}
}