feat(x402): Updated to x402 V2

This commit is contained in:
Daniel Gonzalez
2026-04-06 16:27:52 -04:00
parent 62029c25a2
commit 08d9c1b0a0
6 changed files with 325 additions and 4775 deletions
+45 -36
View File
@@ -7,7 +7,9 @@
import express from 'express'
import cors from 'cors'
import dotenv from 'dotenv'
import { paymentMiddleware as x402PaymentMiddleware } from 'x402-express'
import { paymentMiddleware as x402PaymentMiddleware } from '@x402/express'
import { HTTPFacilitatorClient, x402ResourceServer } from '@x402/core/server'
import { registerExactEvmScheme } from '@x402/evm/exact/server'
import { fileURLToPath } from 'url'
import { dirname, join } from 'path'
@@ -74,7 +76,22 @@ class Server {
origin: true, // Allow all origins (more reliable than '*')
credentials: false, // Set to true if you need to support credentials
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With']
allowedHeaders: [
'Content-Type',
'Authorization',
'X-Requested-With',
'PAYMENT-SIGNATURE',
'PAYMENT-REQUIRED',
'PAYMENT-RESPONSE',
'X-PAYMENT',
'X-PAYMENT-RESPONSE'
],
exposedHeaders: [
'PAYMENT-REQUIRED',
'PAYMENT-RESPONSE',
'PAYMENT-SIGNATURE',
'X-PAYMENT-RESPONSE'
]
}))
// URL normalization middleware - collapse multiple slashes
@@ -108,54 +125,46 @@ class Server {
if (x402Settings.enabled && basicAuthSettings.enabled) {
// X402_ENABLED=true AND USE_BASIC_AUTH=true: Apply x402 conditionally
const routes = buildX402Routes(this.config.apiPrefix)
let facilitatorOptions = x402Settings.facilitatorUrl
const facilitatorOptions = x402Settings.facilitatorUrl
? {
url: x402Settings.facilitatorUrl,
createAuthHeaders: createAuthHeader
}
: undefined
if (facilitatorOptions) {
facilitatorOptions = {
url: x402Settings.facilitatorUrl,
createAuthHeaders: createAuthHeader
}
}
wlogger.info(`x402 v2 middleware enabled with basic auth bypass; enforcing ${x402Settings.priceUSDC} USDC per request (unless basic auth provided)`)
wlogger.info(`x402 middleware enabled with basic auth bypass; enforcing ${x402Settings.priceUSDC} satoshis per request (unless basic auth provided)`)
const facilitatorClient = new HTTPFacilitatorClient(facilitatorOptions)
// x402 v2 exports use lowercase class names (x402ResourceServer).
// eslint-disable-next-line new-cap
const resourceServer = new x402ResourceServer(facilitatorClient)
registerExactEvmScheme(resourceServer, {})
const x402Mw = x402PaymentMiddleware(routes, resourceServer)
// Create conditional x402 middleware that bypasses if basic auth is valid
const conditionalX402Middleware = (req, res, next) => {
// req.headers['accept'] = 'application/json';
// If basic auth is valid, bypass x402
if (req.locals?.basicAuthValid === true) {
return next()
}
// Otherwise, apply x402 middleware
return x402PaymentMiddleware(
x402Settings.serverAddress,
routes,
facilitatorOptions
)(req, res, next)
return x402Mw(req, res, next)
}
app.use(conditionalX402Middleware)
} else if (x402Settings.enabled && !basicAuthSettings.enabled) {
// X402_ENABLED=true AND USE_BASIC_AUTH=false: Apply x402 unconditionally (no basic auth bypass)
const routes = buildX402Routes(this.config.apiPrefix)
let facilitatorOptions = x402Settings.facilitatorUrl
const facilitatorOptions = x402Settings.facilitatorUrl
? {
url: x402Settings.facilitatorUrl,
createAuthHeaders: createAuthHeader
}
: undefined
if (facilitatorOptions) {
facilitatorOptions = {
url: x402Settings.facilitatorUrl,
createAuthHeaders: createAuthHeader
}
}
wlogger.info(`x402 v2 middleware enabled (basic auth disabled); enforcing ${x402Settings.priceUSDC} USDC per request`)
wlogger.info(`x402 middleware enabled (basic auth disabled); enforcing ${x402Settings.priceUSDC} satoshis per request`)
// Apply x402 middleware unconditionally - no basic auth bypass
app.use(x402PaymentMiddleware(
x402Settings.serverAddress,
routes,
facilitatorOptions
))
const facilitatorClient = new HTTPFacilitatorClient(facilitatorOptions)
// eslint-disable-next-line new-cap
const resourceServer = new x402ResourceServer(facilitatorClient)
registerExactEvmScheme(resourceServer, {})
app.use(x402PaymentMiddleware(routes, resourceServer))
} else if (basicAuthSettings.enabled && !x402Settings.enabled) {
// USE_BASIC_AUTH=true AND X402_ENABLED=false: Require basic auth, reject unauthenticated requests
wlogger.info('Basic auth enforcement enabled (x402 disabled)')
+48 -21
View File
@@ -1,38 +1,58 @@
import axios from 'axios'
import { withPaymentInterceptor } from 'x402-axios'
/**
* x402 v2 axios example — USDC on Base (CAIP-2). Run from repo root:
* `node examples/01-x402-axios-client.js`
* The x402 stack pulls in `siwe`, which expects `ethers` as a peer — it is listed in package.json.
*
* Uses `toClientEvmSigner(account, publicClient)` so the signer has `readContract` (required by
* @x402/evm ClientEvmSigner). Optional: `BASE_RPC_URL` for Base / Base Sepolia HTTP RPC.
*/
import axios, { AxiosHeaders } from 'axios'
import { createPublicClient, http } from 'viem'
import { base, baseSepolia } from 'viem/chains'
import { privateKeyToAccount } from 'viem/accounts'
import { base } from 'viem/chains'
// import { baseSepolia } from 'viem/chains' // Testnet
import { x402Client, wrapAxiosWithPayment } from '@x402/axios'
import { registerExactEvmScheme } from '@x402/evm/exact/client'
import { toClientEvmSigner } from '@x402/evm'
import { createWalletClient, http } from 'viem'
// const baseURL = 'http://localhost:5942' // Local
const baseURL = 'https://x402.fullstack.cash' // Production
const baseURL = 'https://localhost:5942' // Local
// const baseURL = 'https://x402.fullstack.cash' // Production
const endpointPath = '/v6/full-node/blockchain/getBlockchainInfo'
const pKey = process.env.PRIVATE_KEY || ''
const pKey = process.env.PRIVATE_KEY || process.env.EVM_PRIVATE_KEY || ''
const x402Network = 'eip155:8453' // sepolia eip155:84532
if (!pKey) throw new Error('PRIVATE_KEY or EVM_PRIVATE_KEY env required!')
// 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 chain = x402Network === 'eip155:84532' ? baseSepolia : base
const defaultRpc =
x402Network === 'eip155:84532'
? 'https://sepolia.base.org'
: 'https://mainnet.base.org'
const publicClient = createPublicClient({
chain,
transport: http(process.env.BASE_RPC_URL || defaultRpc)
})
const signer = toClientEvmSigner(account, publicClient)
// eslint-disable-next-line new-cap
const client = new x402Client()
registerExactEvmScheme(client, {
signer,
networks: [x402Network]
})
const api = withPaymentInterceptor(
const api = wrapAxiosWithPayment(
axios.create({
baseURL,
headers: {
headers: new AxiosHeaders({
'Content-Type': 'application/json'
}
})
}),
client
)
const request = async () => {
console.log('\n\nStep 1: Making first call, expecting a 402 error returned.')
try {
@@ -42,6 +62,10 @@ const request = async () => {
} catch (err) {
console.log(`Status code: ${err?.response?.status}`)
console.log(`Error data: ${JSON.stringify(err.response.data, null, 2)}`)
console.log(
`Error StatusText: ${JSON.stringify(err.response.statusText, null, 2)}`
)
console.log('\n\n')
}
@@ -54,6 +78,9 @@ const request = async () => {
} catch (err) {
console.log('Step 2 failed. Expected a 200 success status code.')
console.log(`Status code: ${err?.response?.status}`)
console.log(
`Error StatusText: ${JSON.stringify(err.response.statusText, null, 2)}`
)
console.log(`Error data: ${JSON.stringify(err.response.data, null, 2)}`)
process.exit(1)
+73 -4671
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -19,20 +19,20 @@
"dependencies": {
"@coinbase/cdp-sdk": "1.45.0",
"@psf/bch-js": "7.1.14",
"@x402/axios": "2.6.0",
"@x402/core": "2.6.0",
"@x402/evm": "2.6.0",
"@x402/express": "2.6.0",
"axios": "1.7.7",
"cors": "2.8.5",
"dotenv": "16.3.1",
"ethers": "6.13.5",
"express": "5.1.0",
"minimal-slp-wallet": "7.1.5",
"psffpp": "1.2.1",
"slp-token-media": "1.2.10",
"winston": "3.11.0",
"winston-daily-rotate-file": "4.7.1",
"x402-axios": "1.1.0",
"x402-express": "1.1.0",
"x402-fetch": "1.1.0"
"winston-daily-rotate-file": "4.7.1"
},
"devDependencies": {
"apidoc": "1.2.0",
+23 -8
View File
@@ -26,26 +26,41 @@ const normalizeBoolean = (value, defaultValue) => {
return defaultValue
}
// 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.
// Default price per API call in USDC (x402 on Base). Override with X402_PRICE_USDC.
const parsedPrice = Number(process.env.X402_PRICE_USDC)
const priceUSDC = Number.isFinite(parsedPrice) && parsedPrice > 0 ? parsedPrice : 200
// Network configuration: CDP requires CAIP-2 format (eip155:chainId)
// base-sepolia = eip155:84532, base-mainnet = eip155:8453
const x402Network = process.env.x402_NETWORK || 'eip155:8453'
// x402 v2 uses CAIP-2 network ids (see https://docs.x402.org/guides/migration-v1-to-v2).
// Accept legacy short names from env for convenience. Also accept X402_NETWORK or x402_NETWORK.
function toV2Caip2Network (raw) {
const s = String(raw ?? '').trim()
if (!s) return 'eip155:8453'
const lower = s.toLowerCase()
if (lower === 'base' || lower === 'eip155:8453') return 'eip155:8453'
if (lower === 'base-sepolia' || lower === 'eip155:84532') return 'eip155:84532'
if (lower.startsWith('eip155:')) return s
return s
}
const x402NetworkRaw =
process.env.x402_NETWORK ||
process.env.X402_NETWORK ||
'eip155:8453'
const x402Network = toV2Caip2Network(x402NetworkRaw)
const x402Defaults = {
enabled: normalizeBoolean(process.env.X402_ENABLED, true),
// CDP Facilitator: https://api.cdp.coinbase.com/platform/v2/x402
// Custom/Local Facilitator: http://localhost:4022
facilitatorUrl: process.env.x402_FACILITATOR_URL || 'https://api.cdp.coinbase.com/platform/v2/x402',
serverAddress: process.env.SERVER_BASE_ADDRESS || 'bitcoincash:qqsrke9lh257tqen99dkyy2emh4uty0vky9y0z0lsr',
// EVM 0x… address for USDC settlement; required for x402 exact scheme (no legacy BCH default).
serverAddress: (process.env.SERVER_BASE_ADDRESS || '').trim(),
facilitatorKeyId: process.env.FACILITATOR_KEY_ID || '',
facilitatorSecretKey: process.env.FACILITATOR_SECRET_KEY || '',
// CDP requires CAIP-2 network format: eip155:8453 (base) or eip155:84532 (base-sepolia)
network: x402Network,
priceUSDC
priceUSDC,
// Optional: USDC token contract (0x…). If unset, Base / Base Sepolia use built-in USDC addresses.
usdcAssetAddress: (process.env.X402_USDC_ASSET || '').trim()
}
const basicAuthDefaults = {
+132 -35
View File
@@ -5,10 +5,23 @@ const DEFAULT_DESCRIPTION = 'Access to protected psf-bch-api resources'
const DEFAULT_TIMEOUT_SECONDS = 120
/**
* 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
* Resolve USDC contract + EIP-712 domain hints for the configured network.
* @param {string} network CAIP-2
*/
/** x402 `exact` on EVM expects a checksummable 0x address for `payTo`. */
function assertEvmPayTo (payTo) {
if (typeof payTo !== 'string' || !/^0x[a-fA-F0-9]{40}$/.test(payTo)) {
throw new Error(
'SERVER_BASE_ADDRESS must be a 0x-prefixed 40-hex EVM address (Base USDC settlement).'
)
}
}
/**
* Builds x402 v2 RoutesConfig for @x402/express (CAIP-2 network + accepts[]).
* Payment asset is explicitly USDC (ERC-20) via AssetAmount, not generic "money".
* @see https://docs.x402.org/guides/migration-v1-to-v2
*/
export function buildX402Routes (apiPrefix = '/v6') {
const normalizedPrefix = apiPrefix.endsWith('/')
@@ -18,29 +31,27 @@ export function buildX402Routes (apiPrefix = '/v6') {
? normalizedPrefix
: `/${normalizedPrefix}`
const routeKey = `${prefixWithSlash}/*`
const routeKey = `* ${prefixWithSlash}/*`
const network = config.x402.network
if (!network) throw new Error('x402 network is required (set x402_NETWORK / X402_NETWORK).')
// Get network from config (now supports CAIP-2 format: eip155:8453)
const NETWORK = config.x402.network
if (!NETWORK) throw new Error('x402_NETWORK env required!')
const payTo = config.x402.serverAddress
if (!payTo) throw new Error('SERVER_BASE_ADDRESS is required for x402 v2 payTo.')
assertEvmPayTo(payTo)
return {
network: NETWORK,
[routeKey]: {
price: config.x402.priceUSDC,
network: NETWORK,
config: {
description: `${DEFAULT_DESCRIPTION} (${config.x402.priceUSDC} USDC)`,
maxTimeoutSeconds: DEFAULT_TIMEOUT_SECONDS,
mimeType: 'application/json',
outputSchema: {
input: {
type: 'http',
method: 'GET',
discoverable: true
}
accepts: [
{
scheme: 'exact',
payTo,
price: config.x402.priceUSDC,
network,
maxTimeoutSeconds: DEFAULT_TIMEOUT_SECONDS
}
}
],
description: `${DEFAULT_DESCRIPTION} (${config.x402.priceUSDC} USDC)`,
mimeType: 'application/json'
}
}
}
@@ -48,11 +59,82 @@ export function buildX402Routes (apiPrefix = '/v6') {
export function getX402Settings () {
return {
enabled: Boolean(config.x402?.enabled),
facilitatorUrl: config.x402?.facilitatorUrl, // https://docs.cdp.coinbase.com/x402/quickstart-for-sellers
facilitatorUrl: config.x402?.facilitatorUrl,
facilitatorKeyId: config.x402?.facilitatorKeyId,
facilitatorSecretKey: config.x402?.facilitatorSecretKey,
serverAddress: config.x402?.serverAddress,
priceUSDC: config.x402?.priceUSDC
priceUSDC: config.x402?.priceUSDC,
usdcAssetAddress: config.x402?.usdcAssetAddress,
network: config.x402?.network
}
}
/**
* JSON for `GET /.well-known/x402` — matches `buildX402Routes` (Base USDC, exact scheme).
* @param {string} [apiPrefix]
*/
export function getX402WellKnownManifest (apiPrefix = '/v6') {
const normalizedPrefix = apiPrefix.endsWith('/')
? apiPrefix.slice(0, -1)
: apiPrefix
const prefixWithSlash = normalizedPrefix.startsWith('/')
? normalizedPrefix
: `/${normalizedPrefix}`
const network = config.x402.network
if (!network) throw new Error('x402 network is required (set x402_NETWORK / X402_NETWORK).')
const payTo = config.x402.serverAddress
if (!payTo) throw new Error('SERVER_BASE_ADDRESS is required for x402 v2 payTo.')
assertEvmPayTo(payTo)
return {
x402Version: 2,
network,
facilitator: {
url: config.x402.facilitatorUrl
},
resources: [
{
resource: `${prefixWithSlash}/*`,
type: 'http',
x402Version: 2,
accepts: [
{
scheme: 'exact',
network,
price: config.x402.priceUSDC,
payTo,
maxTimeoutSeconds: DEFAULT_TIMEOUT_SECONDS,
description: `${DEFAULT_DESCRIPTION} (${config.x402.priceUSDC} USDC)`,
mimeType: 'application/json',
extra: { }
}
]
}
]
}
}
/**
* Snapshot for `build-documents` agent manifest — Base USDC / exact scheme.
* @returns {object|null} null when x402 is off or payTo/network invalid.
*/
export function getX402AgentAuthPricing () {
const x402 = config.x402
if (!x402?.enabled || !x402.serverAddress || !x402.network) return null
try {
assertEvmPayTo(x402.serverAddress)
} catch {
return null
}
return {
scheme: 'exact',
x402Version: 2,
network: x402.network,
payTo: x402.serverAddress,
priceUSDC: x402.priceUSDC,
facilitatorUrl: x402.facilitatorUrl
}
}
@@ -64,29 +146,41 @@ export function getBasicAuthSettings () {
}
/**
*
* Auth headers for interact with mainnet coin base facilitator endpoints.
*
* CDP JWT auth for facilitator HTTPFacilitatorClient (verify / settle / supported).
* @see https://docs.cdp.coinbase.com/get-started/authentication/jwt-authentication#javascript-2
*/
// 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 id = config.x402?.facilitatorKeyId
const secret = config.x402?.facilitatorSecretKey
if (!id || !secret) {
return {
verify: {},
settle: {},
supported: {}
}
}
const verifyToken = await generateJwt({
apiKeyId: config.x402?.facilitatorKeyId,
apiKeySecret: config.x402?.facilitatorSecretKey,
apiKeyId: id,
apiKeySecret: secret,
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,
apiKeyId: id,
apiKeySecret: secret,
requestMethod: 'POST',
requestHost: 'api.cdp.coinbase.com',
requestPath: '/platform/v2/x402/settle'
})
const supportedToken = await generateJwt({
apiKeyId: id,
apiKeySecret: secret,
requestMethod: 'GET',
requestHost: 'api.cdp.coinbase.com',
requestPath: '/platform/v2/x402/supported'
})
return {
verify: {
@@ -94,6 +188,9 @@ export async function createAuthHeader () {
},
settle: {
Authorization: `Bearer ${settleToken}`
},
supported: {
Authorization: `Bearer ${supportedToken}`
}
}
}