feat(x402): Integrated payai facilitator

This commit is contained in:
Daniel Gonzalez
2026-04-13 21:05:17 -04:00
parent f36840682b
commit ff6e2342d6
10 changed files with 289 additions and 53 deletions
+15 -7
View File
@@ -34,16 +34,24 @@ export X402_PRICE_USDC=0.1
# Use 'eip155:84532' for Base Sepolia (testnet)
export x402_NETWORK=eip155:8453
# Primary Facilitator
# Options: 'cdp' (Coinbase) or 'dexter' (Dexter.cash - no API keys required)
export PRIMARY_FACILITATOR=cdp
# Primary Facilitator (used when ACTIVE_FACILITATORS is unset — single-facilitator mode)
# Options: 'cdp' (Coinbase), 'dexter' (Dexter.cash), or 'payai' (PayAI)
export PRIMARY_FACILITATOR=dexter
# Facilitator URLs
# For Coinbase CDP (recommended): https://api.cdp.coinbase.com/platform/v2/x402
# For Dexter (no API keys):https://x402.dexter.cash
# URL are not used when PRIMARY_FACILITATOR is set
# Multi-facilitator (x402 v2): comma-separated *additional* facilitators; PRIMARY_FACILITATOR is always first (precedence).
# x402_FACILITATOR_URL applies only in single-facilitator mode.
# export ACTIVE_FACILITATORS=dexter,payai
# Bazaar discovery extension on 402 payment requirements (default on). Set false to omit.
# export X402_BAZAAR_ENABLED=true
# Facilitator URL override (optional). If set, this URL is used regardless of PRIMARY_FACILITATOR.
# Otherwise the URL is chosen from PRIMARY_FACILITATOR (built-in defaults per provider).
# export x402_FACILITATOR_URL=https://api.cdp.coinbase.com/platform/v2/x402
# PayAI only: optional custom facilitator host (default https://facilitator.payai.network)
# export PAYAI_FACILITATOR_URL=https://facilitator.payai.network
# CDP API Credentials (required when using CDP facilitator)
# Get these from https://cdp.coinbase.com - API Keys section
# Not required for Dexter facilitator
+21 -2
View File
@@ -14,6 +14,8 @@ The API surface remains focused on BCH infrastructure (BCH full node, Fulcrum, a
- Pricing is configured in `X402_PRICE_USDC` (USDC amount), not BCH satoshis.
- x402 network is configured using CAIP-2 format (for example `eip155:8453` for Base mainnet and `eip155:84532` for Base Sepolia).
- Facilitator auth headers are generated using Coinbase CDP JWT auth (`FACILITATOR_KEY_ID` and `FACILITATOR_SECRET_KEY`) when using CDP endpoints.
- Optional facilitators: **Dexter** (`PRIMARY_FACILITATOR=dexter`) and **PayAI** (`PRIMARY_FACILITATOR=payai`, default URL `https://facilitator.payai.network`, no API keys).
- **Bazaar** discovery metadata on payment requirements via `@x402/extensions/bazaar`, and optional **multi-facilitator** mode (`ACTIVE_FACILITATORS=cdp,dexter,payai`) so clients can verify/settle through CDP, Dexter, or PayAI (`@x402/core` picks a matching facilitator).
## Architecture
@@ -85,7 +87,11 @@ All configuration is loaded from environment variables (typically from `.env`).
- `SERVER_BASE_ADDRESS` (EVM address receiving x402 settlements)
- `X402_PRICE_USDC` (USDC charged per request)
- `x402_NETWORK` (CAIP-2 chain ID; e.g. `eip155:8453` or `eip155:84532`)
- `x402_FACILITATOR_URL` (default: `https://api.cdp.coinbase.com/platform/v2/x402`)
- `PRIMARY_FACILITATOR` (default: `cdp`) — one of `cdp`, `dexter`, or `payai`. Used for **single-facilitator** mode when `ACTIVE_FACILITATORS` is unset; selects the default facilitator base URL unless `x402_FACILITATOR_URL` is set.
- `ACTIVE_FACILITATORS` — optional comma-separated list (e.g. `dexter,payai`). When set with multi-facilitator mode, the server registers **multiple** `HTTPFacilitatorClient` instances. **`PRIMARY_FACILITATOR` is always first** in that list (x402 gives earlier facilitators precedence); remaining names follow in the order listed, deduped. Each facilitator uses its default base URL (`x402_FACILITATOR_URL` applies only in **single-facilitator** mode).
- `X402_BAZAAR_ENABLED` (default: `true`) — attach Bazaar **discovery** extension to protected route payment requirements (for facilitator catalogs). Set `false` to disable.
- `x402_FACILITATOR_URL` — optional override for the facilitator HTTP base URL (must expose `/verify`, `/settle`, and `/supported` like the x402 reference facilitator). When unset, the URL is derived from `PRIMARY_FACILITATOR`. **Ignored per-facilitator when `ACTIVE_FACILITATORS` lists more than one entry** (each entry uses its provider URL).
- `PAYAI_FACILITATOR_URL` — optional; when `PRIMARY_FACILITATOR=payai` and `x402_FACILITATOR_URL` is unset, defaults to `https://facilitator.payai.network`.
- `FACILITATOR_KEY_ID` (required for CDP facilitator auth)
- `FACILITATOR_SECRET_KEY` (required for CDP facilitator auth)
@@ -123,17 +129,30 @@ Authorization: Bearer my-secret-token
### 3) x402 Payments on Base (USDC)
**Coinbase CDP facilitator**
```bash
X402_ENABLED=true
USE_BASIC_AUTH=false
SERVER_BASE_ADDRESS=0xYourBaseAddress
X402_PRICE_USDC=0.1
x402_NETWORK=eip155:8453
x402_FACILITATOR_URL=https://api.cdp.coinbase.com/platform/v2/x402
PRIMARY_FACILITATOR=cdp
FACILITATOR_KEY_ID=your_key_id
FACILITATOR_SECRET_KEY=your_secret
```
**PayAI facilitator** (no CDP keys; default URL `https://facilitator.payai.network`)
```bash
X402_ENABLED=true
USE_BASIC_AUTH=false
SERVER_BASE_ADDRESS=0xYourBaseAddress
X402_PRICE_USDC=0.1
x402_NETWORK=eip155:8453
PRIMARY_FACILITATOR=payai
```
Protected endpoints return `402 Payment Required` when no valid payment is attached. x402-capable clients can pay and retry automatically.
### 4) x402 + Bearer Bypass
+16 -21
View File
@@ -18,7 +18,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, getBasicAuthSettings, createAuthHeader, getFacilitatorConfig } from '../src/config/x402.js'
import { buildX402Routes, getX402Settings, getBasicAuthSettings, createAuthHeader, getFacilitatorConnectionOptions } from '../src/config/x402.js'
import { basicAuthMiddleware } from '../src/middleware/basic-auth.js'
import DiscoveryRouter from '../src/controllers/discovery/router.js'
@@ -126,19 +126,17 @@ class Server {
// X402_ENABLED=true AND USE_BASIC_AUTH=true: Apply x402 conditionally
const routes = buildX402Routes(this.config.apiPrefix)
const primaryFacilitator = x402Settings.primaryFacilitator || 'cdp'
const facilitatorConfig = getFacilitatorConfig(primaryFacilitator)
const facilitatorOptions = {
url: facilitatorConfig.url,
createAuthHeaders: facilitatorConfig.requiresAuth ? createAuthHeader : null
}
const connectionOpts = getFacilitatorConnectionOptions()
const facilitatorClients = connectionOpts.map(o => new HTTPFacilitatorClient({
url: o.url,
createAuthHeaders: o.requiresAuth ? createAuthHeader : null
}))
wlogger.info(`x402 v2 middleware enabled with basic auth bypass; enforcing ${x402Settings.priceUSDC} USDC per request (unless basic auth provided) [facilitator: ${facilitatorConfig.name}]`)
wlogger.info(`x402 v2 middleware enabled with basic auth bypass; enforcing ${x402Settings.priceUSDC} USDC per request (unless basic auth provided) [facilitators: ${connectionOpts.map(o => o.name).join(', ')}]`)
const facilitatorClient = new HTTPFacilitatorClient(facilitatorOptions)
// x402 v2 exports use lowercase class names (x402ResourceServer).
// eslint-disable-next-line new-cap
const resourceServer = new x402ResourceServer(facilitatorClient)
const resourceServer = new x402ResourceServer(facilitatorClients)
registerExactEvmScheme(resourceServer, {})
const x402Mw = x402PaymentMiddleware(routes, resourceServer)
@@ -152,22 +150,19 @@ class Server {
app.use(conditionalX402Middleware)
// Display facilitator running url
console.log(`Facilitator running on: ${facilitatorOptions.url}`)
console.log(`Facilitator(s): ${connectionOpts.map(o => `${o.name}${o.url}`).join(' | ')}`)
} else if (x402Settings.enabled && !basicAuthSettings.enabled) {
const routes = buildX402Routes(this.config.apiPrefix)
const primaryFacilitator = x402Settings.primaryFacilitator || 'cdp'
const facilitatorConfig = getFacilitatorConfig(primaryFacilitator)
const facilitatorOptions = {
url: facilitatorConfig.url,
createAuthHeaders: facilitatorConfig.requiresAuth ? createAuthHeader : null
}
const connectionOpts = getFacilitatorConnectionOptions()
const facilitatorClients = connectionOpts.map(o => new HTTPFacilitatorClient({
url: o.url,
createAuthHeaders: o.requiresAuth ? createAuthHeader : null
}))
wlogger.info(`x402 v2 middleware enabled (basic auth disabled); enforcing ${x402Settings.priceUSDC} USDC per request [facilitator: ${facilitatorConfig.name}]`)
wlogger.info(`x402 v2 middleware enabled (basic auth disabled); enforcing ${x402Settings.priceUSDC} USDC per request [facilitators: ${connectionOpts.map(o => o.name).join(', ')}]`)
const facilitatorClient = new HTTPFacilitatorClient(facilitatorOptions)
// eslint-disable-next-line new-cap
const resourceServer = new x402ResourceServer(facilitatorClient)
const resourceServer = new x402ResourceServer(facilitatorClients)
registerExactEvmScheme(resourceServer, {})
app.use(x402PaymentMiddleware(routes, resourceServer))
} else if (basicAuthSettings.enabled && !x402Settings.enabled) {
+1
View File
@@ -15,6 +15,7 @@
"@x402/core": "2.6.0",
"@x402/evm": "2.6.0",
"@x402/express": "2.6.0",
"@x402/extensions": "2.6.0",
"axios": "1.7.7",
"cors": "2.8.5",
"dotenv": "16.3.1",
+1
View File
@@ -23,6 +23,7 @@
"@x402/core": "2.6.0",
"@x402/evm": "2.6.0",
"@x402/express": "2.6.0",
"@x402/extensions": "2.6.0",
"axios": "1.7.7",
"cors": "2.8.5",
"dotenv": "16.3.1",
+16 -2
View File
@@ -30,9 +30,23 @@ export X402_ENABLED=true
export SERVER_BASE_ADDRESS=0xd32585CE60815654C50CAf350e18de8096061e63
export X402_PRICE_USDC=0.1
export x402_NETWORK=base
export ACTIVE_FACILITATORS=dexter,payai
export X402_BAZAAR_ENABLED=true
export PRIMARY_FACILITATOR=cdp
# Coinbase CDP Facilitator (for Bazaar registration)
export x402_FACILITATOR_URL=https://api.cdp.coinbase.com/platform/v2/x402
# Facilitator: cdp (default) | dexter | payai. Default base URL follows PRIMARY_FACILITATOR unless x402_FACILITATOR_URL is set.
# export PRIMARY_FACILITATOR=payai
# Multi-facilitator (CDP + Dexter + PayAI): unset x402_FACILITATOR_URL or use single-facilitator mode only.
# export ACTIVE_FACILITATORS=cdp,dexter,payai
# Bazaar discovery on 402 responses (default on)
# export X402_BAZAAR_ENABLED=true
# Optional PayAI host override (default https://facilitator.payai.network)
# export PAYAI_FACILITATOR_URL=https://facilitator.payai.network
# Optional: force a specific facilitator base URL (overrides PRIMARY_FACILITATOR URL resolution)
# export x402_FACILITATOR_URL=https://api.cdp.coinbase.com/platform/v2/x402
# CDP JWT credentials (required only when using Coinbase CDP)
export FACILITATOR_KEY_ID=your_key_id_here
export FACILITATOR_SECRET_KEY=your_secret_key_here
+24 -4
View File
@@ -48,16 +48,36 @@ const x402NetworkRaw =
'eip155:8453'
const x402Network = toV2Caip2Network(x402NetworkRaw)
const primaryFacilitatorRaw = (process.env.PRIMARY_FACILITATOR || 'cdp').trim().toLowerCase()
const x402PrimaryKeys = new Set(['cdp', 'dexter', 'payai'])
const primaryFacilitator = x402PrimaryKeys.has(primaryFacilitatorRaw)
? primaryFacilitatorRaw
: 'cdp'
function resolveX402FacilitatorUrl () {
const explicit = (process.env.x402_FACILITATOR_URL || '').trim()
if (explicit) return explicit.replace(/\/$/, '')
if (primaryFacilitator === 'dexter') return 'https://x402.dexter.cash'
if (primaryFacilitator === 'payai') {
return (process.env.PAYAI_FACILITATOR_URL || 'https://facilitator.payai.network')
.trim()
.replace(/\/$/, '')
}
return 'https://api.cdp.coinbase.com/platform/v2/x402'
}
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',
// Bazaar discovery extension on payment requirements (for facilitator catalogs). @x402/express loads @x402/extensions/bazaar when enabled.
bazaarEnabled: normalizeBoolean(process.env.X402_BAZAAR_ENABLED, true),
// Resolved from PRIMARY_FACILITATOR unless x402_FACILITATOR_URL is set (see resolveX402FacilitatorUrl).
facilitatorUrl: resolveX402FacilitatorUrl(),
// 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 || '',
primaryFacilitator: process.env.PRIMARY_FACILITATOR || 'cdp',
primaryFacilitator,
network: x402Network,
priceUSDC,
// Optional: USDC token contract (0x…). If unset, Base / Base Sepolia use built-in USDC addresses.
+114 -17
View File
@@ -1,5 +1,6 @@
import config from './index.js'
import { generateJwt } from '@coinbase/cdp-sdk/auth'
import { declareDiscoveryExtension } from '@x402/extensions/bazaar'
const DEFAULT_DESCRIPTION = 'Access to protected psf-bch-api resources'
const DEFAULT_TIMEOUT_SECONDS = 120
@@ -20,6 +21,12 @@ const FACILITATORS = {
url: 'https://x402.dexter.cash',
requiresAuth: false,
authType: 'none'
},
payai: {
name: 'PayAI',
url: 'https://facilitator.payai.network',
requiresAuth: false,
authType: 'none'
}
}
@@ -54,26 +61,114 @@ export function buildX402Routes (apiPrefix = '/v6') {
if (!payTo) throw new Error('SERVER_BASE_ADDRESS is required for x402 v2 payTo.')
assertEvmPayTo(payTo)
return {
[routeKey]: {
accepts: [
{
scheme: 'exact',
payTo,
price: config.x402.priceUSDC,
network,
maxTimeoutSeconds: DEFAULT_TIMEOUT_SECONDS
}
],
description: `${DEFAULT_DESCRIPTION} (${config.x402.priceUSDC} USDC)`,
mimeType: 'application/json'
}
const entry = {
accepts: [
{
scheme: 'exact',
payTo,
price: config.x402.priceUSDC,
network,
maxTimeoutSeconds: DEFAULT_TIMEOUT_SECONDS
}
],
description: `${DEFAULT_DESCRIPTION} (${config.x402.priceUSDC} USDC)`,
mimeType: 'application/json'
}
if (config.x402.bazaarEnabled) {
entry.extensions = declareDiscoveryExtension({
output: {
example: {
service: 'psf-bch-api',
apiPrefix: prefixWithSlash,
description: 'Bitcoin Cash full node, Fulcrum, and SLP indexer REST proxy (x402 USDC on Base)'
}
}
})
}
return {
[routeKey]: entry
}
}
/**
* Default facilitator order when ACTIVE_FACILITATORS lists CDP, Dexter, and PayAI (Bazaar-friendly multi-facilitator).
*/
export const DEFAULT_MULTI_FACILITATORS = ['cdp', 'dexter', 'payai']
/**
* Per-facilitator HTTP base URL (no trailing slash). Used when multiple facilitators are active.
* @param {string} key
*/
export function getFacilitatorHttpUrl (key) {
const k = String(key || 'cdp').trim().toLowerCase()
if (k === 'dexter') return 'https://x402.dexter.cash'
if (k === 'payai') {
return (process.env.PAYAI_FACILITATOR_URL || 'https://facilitator.payai.network')
.trim()
.replace(/\/$/, '')
}
return 'https://api.cdp.coinbase.com/platform/v2/x402'
}
/**
* Resolved primary facilitator key (env PRIMARY_FACILITATOR, then config, then cdp).
* @returns {string}
*/
export function resolvePrimaryFacilitatorKey () {
const primaryRaw = (
process.env.PRIMARY_FACILITATOR ||
config.x402?.primaryFacilitator ||
'cdp'
).trim().toLowerCase()
return FACILITATORS[primaryRaw] ? primaryRaw : 'cdp'
}
/**
* Active facilitator keys: PRIMARY_FACILITATOR is always first (x402 core gives earlier
* facilitator clients precedence). Remaining keys follow ACTIVE_FACILITATORS order, deduped.
* @returns {string[]}
*/
export function getActiveFacilitatorKeys () {
const primary = resolvePrimaryFacilitatorKey()
const raw = (process.env.ACTIVE_FACILITATORS || '').trim()
if (!raw) {
return [primary]
}
const fromEnv = raw.split(',').map(s => s.trim().toLowerCase()).filter(k => FACILITATORS[k])
if (fromEnv.length === 0) {
return [primary]
}
const rest = fromEnv.filter(k => k !== primary)
return [primary, ...rest]
}
/**
* Options for constructing one {@link import('@x402/core/server').HTTPFacilitatorClient} per facilitator.
* When only one facilitator is active, `url` is `config.x402.facilitatorUrl` so `x402_FACILITATOR_URL` still applies.
* @returns {{ key: string, name: string, url: string, requiresAuth: boolean }[]}
*/
export function getFacilitatorConnectionOptions () {
const keys = getActiveFacilitatorKeys()
return keys.map(key => {
const cfg = getFacilitatorConfig(key)
const url =
keys.length === 1 ? config.x402.facilitatorUrl : getFacilitatorHttpUrl(key)
return {
key,
name: cfg.name,
url,
requiresAuth: Boolean(cfg.requiresAuth)
}
})
}
export function getX402Settings () {
return {
enabled: Boolean(config.x402?.enabled),
bazaarEnabled: Boolean(config.x402?.bazaarEnabled),
activeFacilitatorKeys: getActiveFacilitatorKeys(),
facilitatorUrl: config.x402?.facilitatorUrl,
facilitatorKeyId: config.x402?.facilitatorKeyId,
facilitatorSecretKey: config.x402?.facilitatorSecretKey,
@@ -214,11 +309,12 @@ export async function createAuthHeader () {
/**
* Get facilitator configuration by name
* @param {string} name - Facilitator name ('cdp' or 'dexter')
* @param {string} name - Facilitator name (`cdp`, `dexter`, or `payai`)
* @returns {Object} Facilitator config
*/
export function getFacilitatorConfig (name = 'cdp') {
return FACILITATORS[name] || FACILITATORS.cdp
const key = String(name || 'cdp').trim().toLowerCase()
return FACILITATORS[key] || FACILITATORS.cdp
}
/**
@@ -227,6 +323,7 @@ export function getFacilitatorConfig (name = 'cdp') {
* @returns {boolean}
*/
export function facilitatorRequiresAuth (name = 'cdp') {
const cfg = FACILITATORS[name]
const key = String(name || 'cdp').trim().toLowerCase()
const cfg = FACILITATORS[key]
return cfg ? cfg.requiresAuth : false
}
@@ -0,0 +1,30 @@
/*
Unit tests for x402 facilitator registry (PayAI, Dexter, CDP).
*/
import { assert } from 'chai'
import {
getFacilitatorConfig,
facilitatorRequiresAuth
} from '../../../src/config/x402.js'
describe('#x402 facilitators', () => {
it('should register PayAI without auth', () => {
assert.equal(facilitatorRequiresAuth('payai'), false)
const cfg = getFacilitatorConfig('payai')
assert.equal(cfg.name, 'PayAI')
assert.equal(cfg.url, 'https://facilitator.payai.network')
assert.equal(cfg.requiresAuth, false)
})
it('should normalize facilitator key case', () => {
const cfg = getFacilitatorConfig('PAYAI')
assert.equal(cfg.name, 'PayAI')
})
it('should require auth for CDP only', () => {
assert.equal(facilitatorRequiresAuth('cdp'), true)
assert.equal(facilitatorRequiresAuth('dexter'), false)
})
})
@@ -0,0 +1,51 @@
/*
Multi-facilitator and Bazaar route config (env-dependent helpers).
*/
import { assert } from 'chai'
import {
getActiveFacilitatorKeys,
getFacilitatorHttpUrl
} from '../../../src/config/x402.js'
describe('#x402 multi-facilitator + bazaar helpers', () => {
const saved = {}
beforeEach(() => {
saved.ACTIVE_FACILITATORS = process.env.ACTIVE_FACILITATORS
saved.PRIMARY_FACILITATOR = process.env.PRIMARY_FACILITATOR
delete process.env.ACTIVE_FACILITATORS
process.env.PRIMARY_FACILITATOR = 'cdp'
})
afterEach(() => {
if (saved.ACTIVE_FACILITATORS === undefined) delete process.env.ACTIVE_FACILITATORS
else process.env.ACTIVE_FACILITATORS = saved.ACTIVE_FACILITATORS
if (saved.PRIMARY_FACILITATOR === undefined) delete process.env.PRIMARY_FACILITATOR
else process.env.PRIMARY_FACILITATOR = saved.PRIMARY_FACILITATOR
})
it('getActiveFacilitatorKeys puts PRIMARY_FACILITATOR first, then ACTIVE_FACILITATORS', () => {
process.env.PRIMARY_FACILITATOR = 'cdp'
process.env.ACTIVE_FACILITATORS = 'payai,cdp,dexter'
assert.deepEqual(getActiveFacilitatorKeys(), ['cdp', 'payai', 'dexter'])
})
it('getActiveFacilitatorKeys prepends primary when not listed in ACTIVE_FACILITATORS', () => {
process.env.PRIMARY_FACILITATOR = 'cdp'
process.env.ACTIVE_FACILITATORS = 'dexter,payai'
assert.deepEqual(getActiveFacilitatorKeys(), ['cdp', 'dexter', 'payai'])
})
it('getActiveFacilitatorKeys falls back to PRIMARY_FACILITATOR', () => {
process.env.PRIMARY_FACILITATOR = 'dexter'
assert.deepEqual(getActiveFacilitatorKeys(), ['dexter'])
})
it('getFacilitatorHttpUrl returns known bases', () => {
assert.include(getFacilitatorHttpUrl('cdp'), 'cdp.coinbase.com')
assert.equal(getFacilitatorHttpUrl('dexter'), 'https://x402.dexter.cash')
assert.equal(getFacilitatorHttpUrl('payai'), 'https://facilitator.payai.network')
})
})