mirror of
https://github.com/Permissionless-Software-Foundation/psf-bch-api-base.git
synced 2026-09-21 16:52:00 -07:00
Merge pull request #12 from Permissionless-Software-Foundation/round-robin-facilitator
Round robin facilitator
This commit is contained in:
+1
-1
@@ -27,7 +27,7 @@ export PORT=5942
|
||||
# x402 payments required to access this API?
|
||||
export X402_ENABLED=true
|
||||
export SERVER_BASE_ADDRESS=0xd32585CE60815654C50CAf350e18de8096061e63
|
||||
export X402_PRICE_USDC=0.1
|
||||
export X402_PRICE_USDC=0.001
|
||||
|
||||
# Network configuration (CAIP-2 format required by CDP)
|
||||
# Use 'eip155:8453' for Base mainnet
|
||||
|
||||
@@ -15,7 +15,7 @@ The API surface remains focused on BCH infrastructure (BCH full node, Fulcrum, a
|
||||
- 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).
|
||||
- **Bazaar** discovery metadata on payment requirements via `@x402/extensions/bazaar`, and optional **multi-facilitator** mode (`ACTIVE_FACILITATORS=cdp,dexter,payai`) with round-robin settle + failover across CDP, Dexter, and PayAI.
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -88,7 +88,7 @@ All configuration is loaded from environment variables (typically from `.env`).
|
||||
- `X402_PRICE_USDC` (USDC charged per request)
|
||||
- `x402_NETWORK` (CAIP-2 chain ID; e.g. `eip155:8453` or `eip155:84532`)
|
||||
- `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).
|
||||
- `ACTIVE_FACILITATORS` — optional comma-separated list (e.g. `dexter,payai`). When set with multi-facilitator mode, the server registers multiple facilitator backends. `PRIMARY_FACILITATOR` is always first in the active list; remaining names follow in listed order, deduped. Verify uses this order with fallback. Settle uses round-robin per `(x402Version, network, scheme)` with failover to remaining facilitators. 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`.
|
||||
@@ -155,6 +155,35 @@ PRIMARY_FACILITATOR=payai
|
||||
|
||||
Protected endpoints return `402 Payment Required` when no valid payment is attached. x402-capable clients can pay and retry automatically.
|
||||
|
||||
### 3b) Round-Robin Facilitator Mode (Settle)
|
||||
|
||||
Use this mode when you want settlement load distributed across multiple facilitators while preserving compatibility for verify calls.
|
||||
|
||||
```bash
|
||||
X402_ENABLED=true
|
||||
USE_BASIC_AUTH=false
|
||||
SERVER_BASE_ADDRESS=0xYourBaseAddress
|
||||
X402_PRICE_USDC=0.1
|
||||
x402_NETWORK=eip155:8453
|
||||
PRIMARY_FACILITATOR=cdp
|
||||
ACTIVE_FACILITATORS=cdp,dexter,payai
|
||||
FACILITATOR_KEY_ID=your_key_id
|
||||
FACILITATOR_SECRET_KEY=your_secret
|
||||
```
|
||||
|
||||
How it works:
|
||||
|
||||
- Verify path: tries facilitators in configured order (`PRIMARY_FACILITATOR` first), falling back on errors.
|
||||
- Settle path: uses round-robin rotation per payment kind (`x402Version + network + scheme`), starting from the next facilitator each successful settlement.
|
||||
- Settle failover: if the selected facilitator fails, the server tries the remaining facilitators in circular order for that request.
|
||||
- URL behavior: with more than one active facilitator, per-provider default URLs are used; `x402_FACILITATOR_URL` is only for single-facilitator mode.
|
||||
|
||||
How to confirm it is active:
|
||||
|
||||
1. Start the server and check startup logs for `settle strategy: round-robin-failover`.
|
||||
2. Send multiple paid requests to the same protected endpoint.
|
||||
3. Confirm settlement calls alternate over `cdp -> dexter -> payai` (with failover if one is unavailable).
|
||||
|
||||
### 4) x402 + Bearer Bypass
|
||||
|
||||
When `X402_ENABLED=true`, the server also exposes machine-discovery endpoints:
|
||||
|
||||
+8
-13
@@ -8,7 +8,7 @@ import express from 'express'
|
||||
import cors from 'cors'
|
||||
import dotenv from 'dotenv'
|
||||
import { paymentMiddleware as x402PaymentMiddleware } from '@x402/express'
|
||||
import { HTTPFacilitatorClient, x402ResourceServer } from '@x402/core/server'
|
||||
import { x402ResourceServer } from '@x402/core/server'
|
||||
import { registerExactEvmScheme } from '@x402/evm/exact/server'
|
||||
|
||||
import { fileURLToPath } from 'url'
|
||||
@@ -21,6 +21,7 @@ import wlogger from '../src/adapters/wlogger.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'
|
||||
import { createFacilitatorClient } from '../src/lib/x402/facilitator-client-factory.js'
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config()
|
||||
@@ -127,16 +128,13 @@ class Server {
|
||||
const routes = buildX402Routes(this.config.apiPrefix)
|
||||
|
||||
const connectionOpts = getFacilitatorConnectionOptions()
|
||||
const facilitatorClients = connectionOpts.map(o => new HTTPFacilitatorClient({
|
||||
url: o.url,
|
||||
createAuthHeaders: o.requiresAuth ? createAuthHeader : null
|
||||
}))
|
||||
const facilitator = createFacilitatorClient(connectionOpts, createAuthHeader)
|
||||
|
||||
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(', ')}]`)
|
||||
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(', ')}] [settle strategy: ${facilitator.strategy}]`)
|
||||
|
||||
// x402 v2 exports use lowercase class names (x402ResourceServer).
|
||||
// eslint-disable-next-line new-cap
|
||||
const resourceServer = new x402ResourceServer(facilitatorClients)
|
||||
const resourceServer = new x402ResourceServer(facilitator.client)
|
||||
registerExactEvmScheme(resourceServer, {})
|
||||
const x402Mw = x402PaymentMiddleware(routes, resourceServer)
|
||||
|
||||
@@ -154,15 +152,12 @@ class Server {
|
||||
} else if (x402Settings.enabled && !basicAuthSettings.enabled) {
|
||||
const routes = buildX402Routes(this.config.apiPrefix)
|
||||
const connectionOpts = getFacilitatorConnectionOptions()
|
||||
const facilitatorClients = connectionOpts.map(o => new HTTPFacilitatorClient({
|
||||
url: o.url,
|
||||
createAuthHeaders: o.requiresAuth ? createAuthHeader : null
|
||||
}))
|
||||
const facilitator = createFacilitatorClient(connectionOpts, createAuthHeader)
|
||||
|
||||
wlogger.info(`x402 v2 middleware enabled (basic auth disabled); enforcing ${x402Settings.priceUSDC} USDC per request [facilitators: ${connectionOpts.map(o => o.name).join(', ')}]`)
|
||||
wlogger.info(`x402 v2 middleware enabled (basic auth disabled); enforcing ${x402Settings.priceUSDC} USDC per request [facilitators: ${connectionOpts.map(o => o.name).join(', ')}] [settle strategy: ${facilitator.strategy}]`)
|
||||
|
||||
// eslint-disable-next-line new-cap
|
||||
const resourceServer = new x402ResourceServer(facilitatorClients)
|
||||
const resourceServer = new x402ResourceServer(facilitator.client)
|
||||
registerExactEvmScheme(resourceServer, {})
|
||||
app.use(x402PaymentMiddleware(routes, resourceServer))
|
||||
} else if (basicAuthSettings.enabled && !x402Settings.enabled) {
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
# Round-Robin Facilitator Settlement (2026-04-22)
|
||||
|
||||
## Why this change was made
|
||||
|
||||
The previous multi-facilitator behavior relied on `@x402/core` default precedence selection, which effectively routed `verify` and `settle` to the first matching facilitator for a kind (`x402Version + network + scheme`). This provided fallback, but did not distribute settlement load across facilitators.
|
||||
|
||||
The goal of this change was to:
|
||||
|
||||
- keep existing verify compatibility and fallback behavior
|
||||
- distribute settlement requests across configured facilitators
|
||||
- retain resiliency by failing over if the selected facilitator cannot settle
|
||||
|
||||
## What was changed
|
||||
|
||||
### 1) New wrapper client for round-robin settlement
|
||||
|
||||
Added:
|
||||
|
||||
- `src/lib/x402/round-robin-facilitator-client.js`
|
||||
|
||||
This wrapper implements the same surface used by `x402ResourceServer`:
|
||||
|
||||
- `getSupported()`
|
||||
- `verify(paymentPayload, paymentRequirements)`
|
||||
- `settle(paymentPayload, paymentRequirements)`
|
||||
|
||||
Behavior:
|
||||
|
||||
- `verify()` preserves ordered precedence with fallback on errors.
|
||||
- `settle()` performs round-robin rotation per `(x402Version, network, scheme)`.
|
||||
- `settle()` fails over to remaining facilitators in circular order if the selected one fails.
|
||||
- `getSupported()` merges/deduplicates `kinds`, `extensions`, and `signers` across facilitators.
|
||||
|
||||
### 2) New facilitator factory
|
||||
|
||||
Added:
|
||||
|
||||
- `src/lib/x402/facilitator-client-factory.js`
|
||||
|
||||
Factory behavior:
|
||||
|
||||
- If only one facilitator is active, returns a single `HTTPFacilitatorClient` with strategy `single`.
|
||||
- If multiple facilitators are active, returns `RoundRobinFacilitatorClient` with strategy `round-robin-failover`.
|
||||
- Preserves per-facilitator auth configuration (CDP JWT headers still apply only to facilitators that require auth).
|
||||
|
||||
### 3) Server wiring update
|
||||
|
||||
Updated:
|
||||
|
||||
- `bin/server.js`
|
||||
|
||||
Changes:
|
||||
|
||||
- Replaced direct `HTTPFacilitatorClient[]` creation/injection with factory-based client creation.
|
||||
- `x402ResourceServer` now receives the factory-produced client (single or wrapper).
|
||||
- Startup logs now include settle strategy marker:
|
||||
- `settle strategy: single`
|
||||
- `settle strategy: round-robin-failover`
|
||||
|
||||
### 4) Documentation update
|
||||
|
||||
Updated:
|
||||
|
||||
- `README.md`
|
||||
|
||||
Added/updated docs for:
|
||||
|
||||
- `ACTIVE_FACILITATORS` behavior under the new strategy
|
||||
- verify vs settle behavior differences
|
||||
- round-robin configuration example
|
||||
- quick runtime checks for confirming the strategy is active
|
||||
|
||||
## Tests added
|
||||
|
||||
Added:
|
||||
|
||||
- `test/unit/lib/x402/round-robin-facilitator-client-unit.js`
|
||||
- `test/unit/lib/x402/facilitator-client-factory-unit.js`
|
||||
|
||||
Coverage intent:
|
||||
|
||||
- settle rotates across 3 facilitators
|
||||
- settle failover works when selected facilitator errors
|
||||
- aggregate error is returned when all facilitators fail
|
||||
- verify still follows ordered precedence/fallback semantics
|
||||
- merged/deduped `getSupported()` data shape is stable
|
||||
- factory returns correct strategy/client type for single vs multi facilitator setups
|
||||
|
||||
## Validation performed
|
||||
|
||||
### Targeted unit test run
|
||||
|
||||
Command:
|
||||
|
||||
- `npm run test -- --grep "round-robin-facilitator-client|facilitator-client-factory"`
|
||||
|
||||
Result:
|
||||
|
||||
- Passing tests for wrapper + factory (`7 passing` at execution time)
|
||||
|
||||
### Local smoke simulation
|
||||
|
||||
Executed a local script using the new wrapper with three mocked facilitators to verify settle rotation sequence.
|
||||
|
||||
Observed order:
|
||||
|
||||
- `cdp-tx`
|
||||
- `dexter-tx`
|
||||
- `payai-tx`
|
||||
- `cdp-tx`
|
||||
- `dexter-tx`
|
||||
|
||||
## Operational notes for developers
|
||||
|
||||
- Round-robin cursor state is in-memory and process-local; it resets on restart.
|
||||
- Rotation is keyed by payment kind (`x402Version + network + scheme`), not by endpoint or payer.
|
||||
- In multi-facilitator mode, provider default URLs are used per facilitator; `x402_FACILITATOR_URL` remains a single-facilitator override.
|
||||
- If one facilitator has intermittent settle failures, traffic still advances via failover and cursor updates on successful settlement.
|
||||
|
||||
+1
-4
@@ -1,4 +1 @@
|
||||
# Developer Documentation
|
||||
|
||||
- [prompt.md](./prompt.md) - This is the original prompt [trout](https://github.com/christroutner) gave the AI to build the app. He uses Cursor 2.0 in Planning mode and the Composer 1 LLM.
|
||||
- [plan](./rest2nostr-poxy-api.plan.md) - This is the plan created by the Cursor AI from the prompt and support files it was given. This is the plan it used to build the app.
|
||||
Have AI create summaries of the work it does and store it here.
|
||||
@@ -1,34 +0,0 @@
|
||||
## Build a REST API implementing REST2NOSTR Proxy
|
||||
|
||||
Your task is to plan out the building of a REST API server that implements the idea for a REST2NOSTR proxy. The code for this REST API server app should be placed in the `/app` directory. You can edit any files in the `/app` directory. The REST API server should be built using node.js JavaScript and the express.js library. It should also use dotenv to manage the use of environment variables. It should use [api-doc](https://www.npmjs.com/package/api-doc) to generate API documentation for the REST API.
|
||||
|
||||
### Nostr
|
||||
|
||||
Nostr is a social media protocol. It is defined by nip-01.md in the `nostr/` directory. This is the primary specification that defines Nostr, but there are many other NIPS for other features. If you need to dig deeper into the Nostr standards you can explore the [NIPs on Github](https://github.com/nostr-protocol/nips/tree/master).
|
||||
|
||||
The `nostr-sandbox/` directory contains a series of small example code snippets for common social media use-cases using Nostr. You can study this code understand how a Nostr Client would interact with a Nostr Relay.
|
||||
|
||||
[This discussion thread on GitHub](https://github.com/nostr-protocol/nips/issues/1549) introduces a concept called REST2NOSTR. It solves an common issue experienced by JavaScript web developers: a high preference for REST APIs over Websockets. The proposal is to create a REST API server that operates as a proxy for the Websockets protocol used by Nostr Relays. This would allow Client developers to interact with a Nostr Relay over a familiar REST API, instead of using Websockets. Here is a summary of the API endpoints the discussion proposes:
|
||||
|
||||
| REST Method | Endpoint | Nostr WebSocket Equivalent | Purpose |
|
||||
| --- | --- | --- | --- |
|
||||
| `POST` | `/event` | `["EVENT", <event>]` | Publish a signed Nostr event to the relay. |
|
||||
| `GET` | `/req/` | `["REQ", <sub_id>, <filters>]` | Retrieve a list of events based on filters (stateless query). |
|
||||
| `POST`/`PUT` | `/req/` | `["REQ", <sub_id>, <filters>]` | Establish a subscription (for long-polling or SSE). |
|
||||
| `DELETE` | `/req/` | `["CLOSE", <sub_id>]` | Close an existing subscription. |
|
||||
|
||||
Your task is to plan out the building of a REST API server that implements the idea for a REST2NOSTR proxy. All code examples in the `nostr-sandbox/` directory that interact with a Relay over Websockets should be able to be implemented using the new REST API. The code examples are a good benchmark to use as a frame of reference as to weather the REST API has been implemented correctly. It would be a good idea to create an `examples/` directory that contain many of these code example, refactored for use with the new REST API.
|
||||
|
||||
There is a similar implementation of REST2NOSTR available at [https://nostr-api.com/](https://nostr-api.com/). While the API and documentation are available, the source code for that implementation is not available. However, it is good to study as an example of the kind of output we are looking for.
|
||||
|
||||
### Follow the Clean Architecture code pattern
|
||||
|
||||
As you plan out the code layout, you should follow the Clean Architecture patterns. Here is background information you can follow to ensure you follow the Clean Architecture pattern:
|
||||
|
||||
* [Clean Architecture Summary](https://raw.githubusercontent.com/christroutner/trouts-blog/refs/heads/master/blog/2021-07-06-clean-architecture/index.md) - This is a markdown document that summarizes the Clean Architecture pattern, and links to additional support information.
|
||||
* [ipfs-service-provider](https://github.com/Permissionless-Software-Foundation/ipfs-service-provider) - This is a node.js JavaScript code base that follows the Clean Architecture patterns. Notice that within the `src` directory, the sub-directories are split up according to the guidance in the Clean Architecture Summary article. This is the primary pattern you should follow.
|
||||
* A copy of the ipfs-service-provider repository has been copied to the `clean-architecture/` directory, so that you can study the code locally.
|
||||
|
||||
### Summary
|
||||
|
||||
Your task is to plan out the building of a REST API server that implements the idea for a REST2NOSTR proxy. The code for this REST API server app should be placed in the `/app` directory. You can edit any files in the `/app` directory. The REST API server should be built using node.js JavaScript and the express.js library. It should also use dotenv to manage the use of environment variables. It should use [api-doc](https://www.npmjs.com/package/api-doc) to generate API documentation for the REST API.
|
||||
@@ -1,163 +0,0 @@
|
||||
# REST2NOSTR Proxy API Implementation Plan
|
||||
|
||||
## Overview
|
||||
|
||||
Build a REST API server in `/app` that proxies Nostr WebSocket protocol to REST endpoints, enabling JavaScript developers to interact with Nostr relays via familiar REST APIs instead of WebSockets.
|
||||
|
||||
## Architecture
|
||||
|
||||
Follow Clean Architecture pattern with these layers:
|
||||
|
||||
- **Entities** (`src/entities/`): Domain models (Event, Subscription, etc.)
|
||||
- **Use Cases** (`src/use-cases/`): Business logic (PublishEvent, QueryEvents, ManageSubscription)
|
||||
- **Adapters** (`src/adapters/`): External interfaces (NostrRelay WebSocket client, logger)
|
||||
- **Controllers** (`src/controllers/rest-api/`): Express.js route handlers
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
/app
|
||||
├── src/
|
||||
│ ├── entities/
|
||||
│ │ └── event.js
|
||||
│ ├── use-cases/
|
||||
│ │ ├── index.js
|
||||
│ │ ├── publish-event.js
|
||||
│ │ ├── query-events.js
|
||||
│ │ └── manage-subscription.js
|
||||
│ ├── adapters/
|
||||
│ │ ├── index.js
|
||||
│ │ ├── nostr-relay.js
|
||||
│ │ └── wlogger.js
|
||||
│ ├── controllers/
|
||||
│ │ ├── index.js
|
||||
│ │ └── rest-api/
|
||||
│ │ ├── index.js
|
||||
│ │ ├── event/
|
||||
│ │ │ ├── controller.js
|
||||
│ │ │ └── index.js
|
||||
│ │ └── req/
|
||||
│ │ ├── controller.js
|
||||
│ │ └── index.js
|
||||
│ └── config/
|
||||
│ ├── index.js
|
||||
│ └── env/
|
||||
│ ├── common.js
|
||||
│ ├── development.js
|
||||
│ └── production.js
|
||||
├── examples/
|
||||
│ └── [refactored sandbox examples]
|
||||
├── bin/
|
||||
│ └── server.js
|
||||
├── .env.example
|
||||
├── .env
|
||||
├── index.js
|
||||
├── package.json
|
||||
└── apidoc.json
|
||||
```
|
||||
|
||||
## Core Endpoints
|
||||
|
||||
### POST /event
|
||||
|
||||
- Maps to: `["EVENT", <event>]`
|
||||
- Publish a signed Nostr event to relay
|
||||
- Body: JSON event object
|
||||
- Response: `{"accepted": true/false, "message": ""}` (maps to `["OK", ...]`)
|
||||
|
||||
### GET /req/:subId
|
||||
|
||||
- Maps to: `["REQ", <sub_id>, <filters>]`
|
||||
- Stateless query - returns events immediately
|
||||
- Query params: filters (JSON encoded or separate params)
|
||||
- Response: Array of events
|
||||
|
||||
### POST /req/:subId
|
||||
|
||||
- Maps to: `["REQ", <sub_id>, <filters>]`
|
||||
- Establish subscription for Server-Sent Events (SSE)
|
||||
- Body: filters object
|
||||
- Response: SSE stream of events
|
||||
|
||||
### DELETE /req/:subId
|
||||
|
||||
- Maps to: `["CLOSE", <sub_id>]`
|
||||
- Close an existing subscription
|
||||
- Response: Confirmation
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### 1. Package Dependencies
|
||||
|
||||
- `express`: REST API framework
|
||||
- `dotenv`: Environment variable management
|
||||
- `apidoc`: API documentation generation
|
||||
- `ws` or `nostr-tools`: WebSocket client for Nostr relays
|
||||
- `winston`: Logging (following example pattern)
|
||||
|
||||
### 2. Adapter Layer (`src/adapters/`)
|
||||
|
||||
- **nostr-relay.js**: WebSocket client wrapper
|
||||
- Connect to configured relay(s)
|
||||
- Send `EVENT`, `REQ`, `CLOSE` messages
|
||||
- Handle relay responses (`EVENT`, `OK`, `EOSE`, `CLOSED`, `NOTICE`)
|
||||
- Manage connection pooling for multiple relays
|
||||
- **wlogger.js**: Winston-based logger
|
||||
|
||||
### 3. Use Cases (`src/use-cases/`)
|
||||
|
||||
- **publish-event.js**: Validate event, send to relay, return OK response
|
||||
- **query-events.js**: Stateless query - send REQ, collect events until EOSE, return results
|
||||
- **manage-subscription.js**: Create/close subscriptions, handle SSE streaming
|
||||
|
||||
### 4. Controllers (`src/controllers/rest-api/`)
|
||||
|
||||
- **event/controller.js**: Handle POST /event
|
||||
- **req/controller.js**: Handle GET/POST/DELETE /req/:subId
|
||||
- Express middleware for validation, error handling
|
||||
- SSE support for subscription endpoint
|
||||
|
||||
### 5. Configuration (`src/config/`)
|
||||
|
||||
- Environment-based config (development, production, test)
|
||||
- Default relay URL(s) from environment variables
|
||||
- Port, logging level, etc.
|
||||
|
||||
### 6. Subscription Management
|
||||
|
||||
- Store active subscriptions in memory (Map with subId as key)
|
||||
- Map subscription IDs to WebSocket connections
|
||||
- Handle cleanup on DELETE /req/:subId
|
||||
- Support SSE streaming for POST /req/:subId
|
||||
|
||||
### 7. Examples (`examples/`)
|
||||
|
||||
Refactor sandbox examples to use REST API:
|
||||
|
||||
- `01-create-account/` - Use REST API to publish kind 0 event
|
||||
- `02-read-posts/` - Use GET /req/:subId for stateless query
|
||||
- `03-write-post/` - Use POST /event
|
||||
- `04-read-alice-posts/` - Use GET /req/:subId with author filter
|
||||
- `14-get-follow-list/` - Use GET /req/:subId with kind 3 filter
|
||||
- Additional examples as needed
|
||||
|
||||
### 8. API Documentation
|
||||
|
||||
- Use api-doc annotations in controller files
|
||||
- Generate docs with `npm run docs`
|
||||
- Follow pattern from clean-architecture example
|
||||
|
||||
## Environment Variables
|
||||
|
||||
- `NOSTR_RELAY_URL`: Default relay WebSocket URL (e.g., `wss://nostr-relay.psfoundation.info`)
|
||||
- `PORT`: Server port (default: 3000)
|
||||
- `NODE_ENV`: Environment (development, production, test)
|
||||
- `LOG_LEVEL`: Logging level (info, debug, error)
|
||||
|
||||
## Key Implementation Notes
|
||||
|
||||
- WebSocket connections: Maintain persistent connections to relay(s) in adapter
|
||||
- Error handling: Map Nostr relay errors to appropriate HTTP status codes
|
||||
- Validation: Validate Nostr events before forwarding (event structure, signature)
|
||||
- SSE: Use Express response.write() for Server-Sent Events in subscription endpoint
|
||||
- Stateless queries: For GET /req/:subId, collect events until EOSE, then close subscription automatically
|
||||
@@ -1,161 +0,0 @@
|
||||
# Test Plan for REST2NOSTR Application
|
||||
|
||||
## Overview
|
||||
|
||||
Create unit and integration tests for the REST2NOSTR Express.js application following patterns from `tests/testing-example-code/`. Tests will use mocha, chai, and sinon, and cover all code paths exercised by the examples in `/app/examples/`.
|
||||
|
||||
## Test Structure
|
||||
|
||||
Create test directory structure:
|
||||
|
||||
- `app/test/unit/` - Unit tests with mocked dependencies
|
||||
- `app/test/integration/` - Integration tests with real dependencies
|
||||
- `app/test/unit/mocks/` - Mock data for unit tests
|
||||
|
||||
## Unit Tests
|
||||
|
||||
### 1. Entity Tests (`test/unit/entities/`)
|
||||
|
||||
- **event-unit.js**: Test Event entity validation and serialization
|
||||
- Test `isValid()` with valid events
|
||||
- Test `isValid()` with invalid events (missing fields, wrong types, wrong lengths)
|
||||
- Test `toJSON()` serialization
|
||||
- Use mock event data from `mocks/event-mocks.js`
|
||||
|
||||
### 2. Use Case Tests (`test/unit/use-cases/`)
|
||||
|
||||
- **publish-event-unit.js**: Test PublishEventUseCase
|
||||
- Mock NostrRelayAdapter.sendEvent()
|
||||
- Test successful event publishing
|
||||
- Test invalid event rejection
|
||||
- Test adapter error handling
|
||||
- Use mocks from `mocks/nostr-relay-mocks.js`
|
||||
|
||||
- **query-events-unit.js**: Test QueryEventsUseCase
|
||||
- Mock NostrRelayAdapter.sendReq() and sendClose()
|
||||
- Test successful query with events returned
|
||||
- Test query timeout handling
|
||||
- Test CLOSED message handling
|
||||
- Test EOSE handling
|
||||
|
||||
- **manage-subscription-unit.js**: Test ManageSubscriptionUseCase
|
||||
- Mock NostrRelayAdapter.sendReq() and sendClose()
|
||||
- Test subscription creation
|
||||
- Test subscription closure
|
||||
- Test duplicate subscription prevention
|
||||
- Test handler callbacks (onEvent, onEose, onClosed)
|
||||
|
||||
### 3. Controller Tests (`test/unit/controllers/`)
|
||||
|
||||
- **event-controller-unit.js**: Test EventRESTControllerLib
|
||||
- Mock UseCases.publishEvent.execute()
|
||||
- Test POST /event with valid event data
|
||||
- Test POST /event with missing event data
|
||||
- Test error handling
|
||||
- Use Express request/response mocks
|
||||
|
||||
- **req-controller-unit.js**: Test ReqRESTControllerLib
|
||||
- Mock UseCases.queryEvents.execute() and manageSubscription methods
|
||||
- Test GET /req/:subId with filters (JSON string and parsed)
|
||||
- Test GET /req/:subId with individual query params
|
||||
- Test POST /req/:subId for SSE subscription
|
||||
- Test DELETE /req/:subId for closing subscription
|
||||
- Test error handling for invalid filters
|
||||
- Test missing subscription ID validation
|
||||
|
||||
### 4. Adapter Tests (`test/unit/adapters/`)
|
||||
|
||||
- **nostr-relay-unit.js**: Test NostrRelayAdapter
|
||||
- Mock WebSocket connections
|
||||
- Test connection establishment
|
||||
- Test sendEvent() and OK response handling
|
||||
- Test sendReq() and EVENT/EOSE/CLOSED message handling
|
||||
- Test sendClose()
|
||||
- Test message queuing when disconnected
|
||||
- Test reconnection logic
|
||||
- Test error handling
|
||||
|
||||
### 5. Server Tests (`test/unit/bin/`)
|
||||
|
||||
- **server-unit.js**: Test Server class
|
||||
- Mock Express app, Controllers, and adapters
|
||||
- Test server initialization
|
||||
- Test middleware attachment
|
||||
- Test route attachment
|
||||
- Test error handling
|
||||
- Test health check endpoint
|
||||
|
||||
## Integration Tests
|
||||
|
||||
### 1. API Endpoint Tests (`test/integration/api/`)
|
||||
|
||||
- **event-integration.js**: Test POST /event endpoint
|
||||
- Create a test server instance
|
||||
- Test publishing kind 0 event (profile metadata) - covers example 01
|
||||
- Test publishing kind 1 event (text post) - covers example 03
|
||||
- Test publishing kind 3 event (follow list) - covers example 06
|
||||
- Test publishing kind 7 event (reaction/like) - covers example 07
|
||||
- Test invalid event rejection
|
||||
- Use real Nostr relay connection (may need test relay or mock relay)
|
||||
|
||||
- **req-integration.js**: Test GET /req/:subId endpoint
|
||||
- Create a test server instance
|
||||
- Test querying kind 1 events (posts) - covers examples 02, 04
|
||||
- Test querying kind 3 events (follow list) - covers example 05
|
||||
- Test with various filter combinations
|
||||
- Test with filters as JSON string query param
|
||||
- Test with individual query params
|
||||
- Test error handling
|
||||
|
||||
- **subscription-integration.js**: Test POST /req/:subId SSE subscription
|
||||
- Create a test server instance
|
||||
- Test SSE subscription creation
|
||||
- Test event streaming
|
||||
- Test EOSE handling
|
||||
- Test subscription closure
|
||||
- Test DELETE /req/:subId endpoint
|
||||
|
||||
### 2. Use Case Integration Tests (`test/integration/use-cases/`)
|
||||
|
||||
- **publish-event-integration.js**: Test PublishEventUseCase with real adapter
|
||||
- **query-events-integration.js**: Test QueryEventsUseCase with real adapter
|
||||
- **manage-subscription-integration.js**: Test ManageSubscriptionUseCase with real adapter
|
||||
|
||||
## Mock Data Files
|
||||
|
||||
Create mock data files in `test/unit/mocks/`:
|
||||
|
||||
- **event-mocks.js**: Mock event data for various event kinds (0, 1, 3, 7)
|
||||
- **nostr-relay-mocks.js**: Mock responses from Nostr relay (OK, EVENT, EOSE, CLOSED)
|
||||
- **controller-mocks.js**: Mock Express request/response objects
|
||||
|
||||
## Key Considerations
|
||||
|
||||
1. **ES Modules**: The app uses ES modules (import/export) while the example uses CommonJS. Tests should use ES modules with `.js` extension and proper import syntax.
|
||||
|
||||
2. **Test Environment**: Integration tests will need a running Nostr relay. Consider:
|
||||
|
||||
- Using a test relay URL from config
|
||||
- Or creating a mock WebSocket server for integration tests
|
||||
- Or documenting that tests require a relay URL in environment
|
||||
|
||||
3. **Test Coverage**: Ensure tests cover:
|
||||
|
||||
- All endpoints used by examples (POST /event, GET /req/:subId)
|
||||
- All event kinds (0, 1, 3, 7)
|
||||
- Error paths and edge cases
|
||||
- Validation logic
|
||||
|
||||
4. **Test Data**: Use the same test keys and data patterns from examples where applicable (e.g., Alice's private key, Bob's public key)
|
||||
|
||||
5. **Async Handling**: Properly handle async/await in tests, especially for WebSocket operations and SSE streams
|
||||
|
||||
6. **Cleanup**: Ensure proper cleanup of WebSocket connections and subscriptions in tests
|
||||
|
||||
## Test Execution
|
||||
|
||||
Tests will run using existing npm scripts:
|
||||
|
||||
- `npm test` - Runs unit tests with linting and coverage
|
||||
- `npm run test:integration` - Runs integration tests with extended timeout
|
||||
- `npm run coverage` - Generates coverage report
|
||||
@@ -1,13 +0,0 @@
|
||||
The express.js app in the `app/` directory needs unit and integration tests. The scope of this task is to create a plan for adding these
|
||||
|
||||
When considering the creation of tests, follow the patters provided in the example available in the `testing/testing-example-code/` directory. This is simple node.js application which uses the following testing libraries:
|
||||
|
||||
- mocha as a test runner
|
||||
- chai as an assertion library
|
||||
- sinon as a stubing library
|
||||
|
||||
The code example show common patterns for writing both unit and integration tests.
|
||||
|
||||
The `/app/examples/` directory contains working code that I've tested and verified works correctly. The unit and integration tests you create should cover the same code paths exercised by these examples.
|
||||
|
||||
The testing dependencies are already installed and listed in the package.json file under the `app/` directory. The package.json file also contains the `test`, `test:integration`, and `coverage` scripts that we'll use to run tests and measure test coverage.
|
||||
@@ -1,21 +0,0 @@
|
||||
# 2026-03-16 Update Log
|
||||
|
||||
## Summary
|
||||
|
||||
Reduced noisy error logging for common SLP transaction misses in the `/v6/slp/txid` path.
|
||||
|
||||
## Changes Made
|
||||
|
||||
- Updated `src/use-cases/slp-use-cases.js` in `getTxid()`:
|
||||
- Added a guard for expected missing-record errors (`404` + `Key not found in database`).
|
||||
- Skips `wlogger.error()` for that specific, common case.
|
||||
- Still rethrows the error so API response behavior is unchanged.
|
||||
- Updated `src/controllers/rest-api/slp/controller.js` in `handleError()`:
|
||||
- Added the same guard to suppress duplicate error-level logs for the same expected case.
|
||||
- Keeps normal error logging for all other errors.
|
||||
|
||||
## Outcome
|
||||
|
||||
- The common "Key not found in database" case no longer pollutes error logs.
|
||||
- Unexpected failures continue to be logged at error level.
|
||||
- Client-facing status and error message behavior remains unchanged.
|
||||
@@ -1,76 +0,0 @@
|
||||
# 2026-03-17 Update Log
|
||||
|
||||
## Summary
|
||||
|
||||
Enhanced REST request logging to capture client network identity in Winston logs, enabled proxy-aware IP resolution, and reduced Fulcrum error-log noise for expected missing-transaction requests.
|
||||
|
||||
## Changes Made
|
||||
|
||||
- Updated `bin/server.js`:
|
||||
- Set Express proxy handling with `app.set('trust proxy', true)`.
|
||||
- Kept the current Winston request message format (`"${req.method} ${req.path}"`).
|
||||
- Added structured Winston metadata fields to request logs:
|
||||
- `client_ip` from `req.ip`
|
||||
- `remote_address` from `req.socket.remoteAddress`
|
||||
- Updated `src/adapters/fulcrum-api.js`:
|
||||
- Added parsing helpers to normalize Fulcrum error messages from multiple response shapes.
|
||||
- Mapped common daemon missing-TX error (`No such mempool or blockchain transaction`) to:
|
||||
- status `404`
|
||||
- message `Transaction not found`
|
||||
- Updated `src/use-cases/fulcrum-use-cases.js`:
|
||||
- Removed duplicate error logging in `getTransactionDetails()` and now rethrows adapter errors without a second error-level log.
|
||||
- Updated `src/controllers/rest-api/fulcrum/controller.js`:
|
||||
- Added TXID validation (`64`-character hex) for `GET /v6/fulcrum/tx/data/:txid`.
|
||||
- Updated `handleError()` logging policy:
|
||||
- `Transaction not found` (`404`) logs at `info`
|
||||
- other `4xx` logs at `warn`
|
||||
- `5xx` logs at `error`
|
||||
|
||||
## Useful Fields Available for REST Request Logging
|
||||
|
||||
- Routing and request basics:
|
||||
- `method` (`req.method`)
|
||||
- `path` (`req.path`)
|
||||
- `original_url` (`req.originalUrl`)
|
||||
- `query` (`req.query`)
|
||||
- Client network identity:
|
||||
- `client_ip` (`req.ip`)
|
||||
- `forwarded_ips` (`req.ips`, when behind one or more proxies)
|
||||
- `remote_address` (`req.socket.remoteAddress`)
|
||||
- HTTP and transport:
|
||||
- `protocol` (`req.protocol`)
|
||||
- `secure` (`req.secure`)
|
||||
- `http_version` (`req.httpVersion`)
|
||||
- `host` (`req.get('host')`)
|
||||
- `origin` (`req.get('origin')`)
|
||||
- `referer` (`req.get('referer')`)
|
||||
- `user_agent` (`req.get('user-agent')`)
|
||||
- Request/response performance and size:
|
||||
- `status_code` (`res.statusCode`, from `res.on('finish')`)
|
||||
- `duration_ms` (elapsed time between request start and response finish)
|
||||
- `request_size_bytes` (`req.get('content-length')`)
|
||||
- `response_size_bytes` (`res.getHeader('content-length')`)
|
||||
- App-specific request context in this codebase:
|
||||
- `basic_auth_valid` (`req.locals.basicAuthValid`)
|
||||
- x402 decision/bypass status (derived from middleware path and config)
|
||||
|
||||
## Already Logging
|
||||
|
||||
- In Winston request logs:
|
||||
- `message` with method + path (for example `GET /v6/full-node/blockchain/getBlockCount`)
|
||||
- `client_ip`
|
||||
- `remote_address`
|
||||
- `timestamp` (from Winston timestamp formatter)
|
||||
- `level`
|
||||
- In console endpoint logs:
|
||||
- Request line with method, path, and `req.ip`
|
||||
- Response line with method, path, and final `res.statusCode`
|
||||
|
||||
## Outcome
|
||||
|
||||
- Request logs now preserve existing behavior while adding IP attribution fields.
|
||||
- `trust proxy` ensures `req.ip` is proxy-aware when the server is deployed behind a reverse proxy.
|
||||
- The project now has a documented list of high-value request fields for future logging expansion.
|
||||
- Fulcrum missing-transaction lookups now return cleaner API semantics (`404 Transaction not found`).
|
||||
- Duplicate error logs for a single missing TX lookup were removed.
|
||||
- Invalid TXIDs are rejected early with a `400` validation error.
|
||||
@@ -1,72 +0,0 @@
|
||||
# 2026-04-01 Update Log
|
||||
|
||||
## Summary
|
||||
|
||||
Added x402-gated discovery endpoints for agent/tool self-discovery, built a docs-derived artifact pipeline from apiDoc annotations, and documented the new flow and behavior.
|
||||
|
||||
## Changes Made
|
||||
|
||||
- Added new discovery controller/router:
|
||||
- `src/controllers/discovery/controller.js`
|
||||
- `src/controllers/discovery/router.js`
|
||||
- Added five root-path discovery endpoints:
|
||||
- `GET /.well-known/x402`
|
||||
- `GET /openapi.json`
|
||||
- `GET /swagger.json`
|
||||
- `GET /llms.txt`
|
||||
- `GET /.well-known/agent.json`
|
||||
- Added x402-enabled gating:
|
||||
- All five endpoints now return `404` when `X402_ENABLED=false`.
|
||||
- When enabled, payloads are returned with x402-bch v2-oriented metadata.
|
||||
- Added apiDoc-derived document builder:
|
||||
- `src/discovery/build-documents.js`
|
||||
- Parses `@api` annotations and builds OpenAPI, Swagger, llms, and agent documents.
|
||||
- Uses `docs/discovery-artifacts.json` if present, otherwise builds in-process.
|
||||
- Added artifact generation script:
|
||||
- `scripts/build-discovery-artifacts.js`
|
||||
- Writes `docs/discovery-artifacts.json`
|
||||
- Updated npm scripts in `package.json`:
|
||||
- `docs:discovery`
|
||||
- `docs:all` (runs `docs` then `docs:discovery`)
|
||||
- Wired discovery routes into server bootstrap:
|
||||
- `bin/server.js`
|
||||
- Added tests:
|
||||
- `test/unit/controllers/discovery-controller-unit.js`
|
||||
- `test/unit/controllers/discovery-router-unit.js`
|
||||
- `test/unit/controllers/discovery-documents-unit.js`
|
||||
- Updated docs/config examples:
|
||||
- `README.md` (discovery endpoints + docs workflow)
|
||||
- `.env-example` (x402 gating note for discovery endpoints)
|
||||
|
||||
## Why This Was Changed
|
||||
|
||||
- Endpoint probes for discovery paths are common from API tooling and AI agents.
|
||||
- Serving structured discovery metadata improves machine interoperability for:
|
||||
- API clients and SDK tooling (`openapi.json`, `swagger.json`)
|
||||
- LLM retrieval workflows (`llms.txt`)
|
||||
- Agent capability discovery (`agent.json`)
|
||||
- x402 payment discovery (`/.well-known/x402`)
|
||||
- Gating by `X402_ENABLED` keeps discovery aligned with monetization mode and avoids advertising payment surfaces when x402 is disabled.
|
||||
|
||||
## Validation Notes
|
||||
|
||||
- Lint passed.
|
||||
- New discovery-focused unit tests passed.
|
||||
- Full `npm test` run showed one pre-existing timeout failure in `test/unit/use-cases/price-use-cases-unit.js` unrelated to discovery endpoint changes.
|
||||
|
||||
## References
|
||||
|
||||
- Local protocol spec:
|
||||
- `../x402-bch/specs/x402-bch-specification-v2.2.md`
|
||||
- OpenAPI Specification:
|
||||
- https://spec.openapis.org/oas/latest.html
|
||||
- Swagger / OpenAPI 2.0:
|
||||
- https://swagger.io/specification/v2/
|
||||
- llms.txt proposal:
|
||||
- https://www.llmstxt.org/index.html
|
||||
- Agent manifest draft reference:
|
||||
- https://agentwebprotocol.org/spec
|
||||
- x402 HTTP 402 background:
|
||||
- https://docs.x402.org/core-concepts/http-402
|
||||
- x402 DNS discovery draft:
|
||||
- https://www.ietf.org/archive/id/draft-jeftovic-x402-dns-discovery-00.html
|
||||
@@ -1,19 +0,0 @@
|
||||
# 2026-04-02 Update Log
|
||||
|
||||
## Summary
|
||||
|
||||
Docker deployments now run `npm run docs` at container start via an entrypoint script, and optionally apply `APIDOC_URL` from the environment so apiDoc HTML matches each deployment subdomain or public URL.
|
||||
|
||||
## Changes Made
|
||||
|
||||
- Added `scripts/patch-apidoc-from-env.js` to set `apidoc.json` and `package.json` `apidoc.url` from `APIDOC_URL` (dotenv loads the project `.env`, same path as `bin/server.js`).
|
||||
- Added `production/docker/entrypoint.sh` to run the patch script, `npm run docs`, then `exec npm start`.
|
||||
- Updated `production/docker/Dockerfile` to remove build-time `npm run docs`, copy the entrypoint and patch script from the build context, and use `ENTRYPOINT` for the shell script.
|
||||
- Updated `production/docker/docker-compose.yml` to use build `context: ../..` and `dockerfile: production/docker/Dockerfile` so COPY paths resolve from the repository root.
|
||||
- Compose mounts `./.env` at `/home/safeuser/psf-bch-api/.env` so the app and entrypoint share one file (not `/home/safeuser/.env`).
|
||||
- Documented `APIDOC_URL` in `.env-example`, `production/docker/.env-example`, and `README.md` (Production Docker section).
|
||||
|
||||
## Outcome
|
||||
|
||||
- Operators can set `APIDOC_URL` in the mounted `.env` (for example `https://api.example.com`) per instance without rebuilding the image for each subdomain.
|
||||
- The HTML apiDoc bundle is regenerated on every container start so it stays aligned with the configured URL.
|
||||
@@ -15,13 +15,15 @@ import { x402Client, wrapAxiosWithPayment } from '@x402/axios'
|
||||
import { registerExactEvmScheme } from '@x402/evm/exact/client'
|
||||
import { toClientEvmSigner } from '@x402/evm'
|
||||
|
||||
const baseURL = 'http://localhost:5942' // Local
|
||||
// const baseURL = 'https://x402.fullstack.cash' // Production
|
||||
// const baseURL = 'http://localhost:5942' // Local
|
||||
const baseURL = 'https://x402.fullstack.cash' // Production
|
||||
|
||||
const endpointPath = '/v6/full-node/blockchain/getBlockchainInfo'
|
||||
const pKey = process.env.PRIVATE_KEY || process.env.EVM_PRIVATE_KEY || ''
|
||||
console.log('pKey: ', pKey)
|
||||
|
||||
const x402Network = 'eip155:8453' // sepolia eip155:84532
|
||||
// const x402Network = 'eip155:84532' // sepolia eip155:84532
|
||||
const x402Network = 'eip155:8453' // mainnet eip155:8453
|
||||
|
||||
if (!pKey) throw new Error('PRIVATE_KEY or EVM_PRIVATE_KEY env required!')
|
||||
|
||||
@@ -53,6 +55,7 @@ const api = wrapAxiosWithPayment(
|
||||
}),
|
||||
client
|
||||
)
|
||||
|
||||
const request = async () => {
|
||||
console.log('\n\nStep 1: Making first call, expecting a 402 error returned.')
|
||||
try {
|
||||
@@ -78,10 +81,12 @@ 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('err: ', err)
|
||||
console.log(
|
||||
`Error StatusText: ${JSON.stringify(err.response.statusText, null, 2)}`
|
||||
)
|
||||
console.log(`Error data: ${JSON.stringify(err.response.data, null, 2)}`)
|
||||
console.log('payment-required header: ', err.response.headers['payment-required'])
|
||||
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* x402 v2 — fetch a protected URL like curl -i, then decode PAYMENT-REQUIRED.
|
||||
* Run from repo root:
|
||||
* `node examples/02-show-x402-header.js`
|
||||
* Optional: `X402_SAMPLE_URL=https://host/path node examples/02-show-x402-header.js`
|
||||
*/
|
||||
import axios from 'axios'
|
||||
import { decodePaymentRequiredHeader } from '@x402/core/http'
|
||||
|
||||
const url =
|
||||
process.env.X402_SAMPLE_URL ||
|
||||
'https://x402.fullstack.cash/v6/full-node/blockchain/getBlockchainInfo'
|
||||
|
||||
function headerString (headers, name) {
|
||||
const lower = name.toLowerCase()
|
||||
const raw =
|
||||
headers?.[lower] ??
|
||||
headers?.[name] ??
|
||||
(typeof headers?.get === 'function'
|
||||
? headers.get(lower) ?? headers.get(name)
|
||||
: undefined)
|
||||
return typeof raw === 'string' ? raw : Array.isArray(raw) ? raw[0] : undefined
|
||||
}
|
||||
|
||||
async function main () {
|
||||
const res = await axios.get(url, {
|
||||
validateStatus: () => true
|
||||
})
|
||||
|
||||
console.log(`HTTP ${res.status}`)
|
||||
console.log(`content-type: ${headerString(res.headers, 'content-type') ?? '(none)'}`)
|
||||
console.log(
|
||||
`access-control-expose-headers: ${headerString(res.headers, 'access-control-expose-headers') ?? '(none)'}`
|
||||
)
|
||||
|
||||
const paymentRequiredRaw = headerString(res.headers, 'payment-required')
|
||||
if (!paymentRequiredRaw) {
|
||||
console.error('\nNo PAYMENT-REQUIRED header on this response.')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('\n--- PAYMENT-REQUIRED (decoded) ---\n')
|
||||
try {
|
||||
const decoded = decodePaymentRequiredHeader(paymentRequiredRaw)
|
||||
console.log(JSON.stringify(decoded, null, 2))
|
||||
} catch (err) {
|
||||
console.error('\nFailed to decode PAYMENT-REQUIRED:', err?.message || err)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('\n--- response body ---\n')
|
||||
const body = res.data
|
||||
console.log(
|
||||
typeof body === 'string' ? body : JSON.stringify(body, null, 2)
|
||||
)
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -51,7 +51,7 @@ RUN git clone https://github.com/Permissionless-Software-Foundation/psf-bch-api-
|
||||
# and `stage` has the most up-to-date changes.
|
||||
WORKDIR /home/safeuser/psf-bch-api-base
|
||||
|
||||
#RUN git checkout ct-unstable
|
||||
RUN git checkout round-robin-facilitator
|
||||
|
||||
# Install dependencies
|
||||
RUN npm install
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { HTTPFacilitatorClient } from '@x402/core/server'
|
||||
import { RoundRobinFacilitatorClient } from './round-robin-facilitator-client.js'
|
||||
|
||||
function withSettleLogging (entry) {
|
||||
return {
|
||||
async getSupported () {
|
||||
return entry.client.getSupported()
|
||||
},
|
||||
async verify (paymentPayload, paymentRequirements) {
|
||||
return entry.client.verify(paymentPayload, paymentRequirements)
|
||||
},
|
||||
async settle (paymentPayload, paymentRequirements) {
|
||||
const result = await entry.client.settle(paymentPayload, paymentRequirements)
|
||||
const tx = result?.transaction || 'n/a'
|
||||
console.log(`[x402] payment settled via ${entry.name || entry.key || 'facilitator'} (${entry.url || 'no-url'}) tx=${tx}`)
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createFacilitatorClient (connectionOpts = [], createAuthHeaders, ClientClass = HTTPFacilitatorClient) {
|
||||
const entries = connectionOpts.map(o => ({
|
||||
key: o.key,
|
||||
name: o.name,
|
||||
url: o.url,
|
||||
client: new ClientClass({
|
||||
url: o.url,
|
||||
createAuthHeaders: o.requiresAuth ? createAuthHeaders : null
|
||||
})
|
||||
}))
|
||||
|
||||
if (entries.length === 0) {
|
||||
throw new Error('No facilitator connection options found.')
|
||||
}
|
||||
|
||||
if (entries.length === 1) {
|
||||
return {
|
||||
client: withSettleLogging(entries[0]),
|
||||
strategy: 'single'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
client: new RoundRobinFacilitatorClient(entries),
|
||||
strategy: 'round-robin-failover'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
export class RoundRobinFacilitatorClient {
|
||||
constructor (facilitatorEntries = []) {
|
||||
if (!Array.isArray(facilitatorEntries) || facilitatorEntries.length === 0) {
|
||||
throw new Error('RoundRobinFacilitatorClient requires at least one facilitator entry.')
|
||||
}
|
||||
|
||||
this.entries = facilitatorEntries
|
||||
this.cursorByKey = new Map()
|
||||
}
|
||||
|
||||
async getSupported () {
|
||||
const supportedList = await Promise.all(this.entries.map(async (entry) => {
|
||||
const supported = await entry.client.getSupported()
|
||||
return {
|
||||
supported,
|
||||
entry
|
||||
}
|
||||
}))
|
||||
|
||||
const kindsByKey = new Map()
|
||||
const extensionsByKey = new Map()
|
||||
const signers = {}
|
||||
|
||||
for (const { supported } of supportedList) {
|
||||
for (const kind of (supported?.kinds || [])) {
|
||||
const key = `${kind.x402Version}|${kind.network}|${kind.scheme}`
|
||||
if (!kindsByKey.has(key)) {
|
||||
kindsByKey.set(key, kind)
|
||||
}
|
||||
}
|
||||
|
||||
for (const ext of (supported?.extensions || [])) {
|
||||
const key = JSON.stringify(ext)
|
||||
if (!extensionsByKey.has(key)) {
|
||||
extensionsByKey.set(key, ext)
|
||||
}
|
||||
}
|
||||
|
||||
const signerMap = supported?.signers || {}
|
||||
for (const [networkKey, addresses] of Object.entries(signerMap)) {
|
||||
if (!Array.isArray(addresses)) continue
|
||||
if (!Array.isArray(signers[networkKey])) {
|
||||
signers[networkKey] = []
|
||||
}
|
||||
for (const addr of addresses) {
|
||||
if (!signers[networkKey].includes(addr)) {
|
||||
signers[networkKey].push(addr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kinds: Array.from(kindsByKey.values()),
|
||||
extensions: Array.from(extensionsByKey.values()),
|
||||
signers
|
||||
}
|
||||
}
|
||||
|
||||
async verify (paymentPayload, paymentRequirements) {
|
||||
let lastError
|
||||
for (const entry of this.entries) {
|
||||
try {
|
||||
return await entry.client.verify(paymentPayload, paymentRequirements)
|
||||
} catch (err) {
|
||||
lastError = err
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error('All facilitators failed verify().')
|
||||
}
|
||||
|
||||
async settle (paymentPayload, paymentRequirements) {
|
||||
const key = this.getRoundRobinKey(paymentPayload, paymentRequirements)
|
||||
const length = this.entries.length
|
||||
const start = this.cursorByKey.get(key) || 0
|
||||
const attempts = []
|
||||
|
||||
for (let offset = 0; offset < length; offset++) {
|
||||
const idx = (start + offset) % length
|
||||
const entry = this.entries[idx]
|
||||
try {
|
||||
const result = await entry.client.settle(paymentPayload, paymentRequirements)
|
||||
this.cursorByKey.set(key, (idx + 1) % length)
|
||||
const tx = result?.transaction || 'n/a'
|
||||
console.log(`[x402] payment settled via ${entry.name || entry.key || `facilitator-${idx}`} (${entry.url || 'no-url'}) tx=${tx}`)
|
||||
return result
|
||||
} catch (err) {
|
||||
attempts.push({
|
||||
name: entry.name || entry.key || `facilitator-${idx}`,
|
||||
url: entry.url || '',
|
||||
error: err?.message || String(err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const message = attempts
|
||||
.map(a => `${a.name}${a.url ? ` (${a.url})` : ''}: ${a.error}`)
|
||||
.join(' | ')
|
||||
throw new Error(`All facilitators failed settle() for ${key}. Attempts: ${message}`)
|
||||
}
|
||||
|
||||
getRoundRobinKey (paymentPayload, paymentRequirements) {
|
||||
const version = paymentPayload?.x402Version || 'unknown'
|
||||
const network = paymentRequirements?.network || 'unknown'
|
||||
const scheme = paymentRequirements?.scheme || 'unknown'
|
||||
return `${version}|${network}|${scheme}`
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { assert } from 'chai'
|
||||
import { createFacilitatorClient } from '../../../../src/lib/x402/facilitator-client-factory.js'
|
||||
import { RoundRobinFacilitatorClient } from '../../../../src/lib/x402/round-robin-facilitator-client.js'
|
||||
|
||||
class FakeFacilitatorClient {
|
||||
constructor (opts) {
|
||||
this.opts = opts
|
||||
}
|
||||
}
|
||||
|
||||
describe('#facilitator-client-factory', () => {
|
||||
it('returns single strategy when only one facilitator is configured', () => {
|
||||
const connectionOpts = [
|
||||
{ key: 'cdp', name: 'Coinbase CDP', url: 'https://cdp.example.com', requiresAuth: true }
|
||||
]
|
||||
|
||||
const result = createFacilitatorClient(connectionOpts, async () => ({}), FakeFacilitatorClient)
|
||||
|
||||
assert.equal(result.strategy, 'single')
|
||||
assert.isFunction(result.client.verify)
|
||||
assert.isFunction(result.client.settle)
|
||||
assert.isFunction(result.client.getSupported)
|
||||
})
|
||||
|
||||
it('returns round-robin strategy when multiple facilitators are configured', () => {
|
||||
const connectionOpts = [
|
||||
{ key: 'cdp', name: 'Coinbase CDP', url: 'https://cdp.example.com', requiresAuth: true },
|
||||
{ key: 'dexter', name: 'Dexter', url: 'https://dexter.example.com', requiresAuth: false }
|
||||
]
|
||||
|
||||
const result = createFacilitatorClient(connectionOpts, async () => ({}), FakeFacilitatorClient)
|
||||
|
||||
assert.equal(result.strategy, 'round-robin-failover')
|
||||
assert.instanceOf(result.client, RoundRobinFacilitatorClient)
|
||||
assert.lengthOf(result.client.entries, 2)
|
||||
assert.equal(result.client.entries[0].key, 'cdp')
|
||||
assert.equal(result.client.entries[1].key, 'dexter')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,138 @@
|
||||
import { assert } from 'chai'
|
||||
import { RoundRobinFacilitatorClient } from '../../../../src/lib/x402/round-robin-facilitator-client.js'
|
||||
|
||||
function makeEntry (name, handlers = {}) {
|
||||
return {
|
||||
key: name.toLowerCase(),
|
||||
name,
|
||||
url: `https://${name.toLowerCase()}.example.com`,
|
||||
client: {
|
||||
getSupported: handlers.getSupported || (async () => ({
|
||||
kinds: [{ x402Version: 2, network: 'eip155:8453', scheme: 'exact' }],
|
||||
extensions: [],
|
||||
signers: {}
|
||||
})),
|
||||
verify: handlers.verify || (async () => ({ isValid: true })),
|
||||
settle: handlers.settle || (async () => ({ success: true, transaction: `${name}-tx` }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('#round-robin-facilitator-client', () => {
|
||||
it('rotates settle winner across facilitators', async () => {
|
||||
const uut = new RoundRobinFacilitatorClient([
|
||||
makeEntry('CDP'),
|
||||
makeEntry('Dexter'),
|
||||
makeEntry('PayAI')
|
||||
])
|
||||
|
||||
const paymentPayload = { x402Version: 2 }
|
||||
const paymentRequirements = { network: 'eip155:8453', scheme: 'exact' }
|
||||
|
||||
const first = await uut.settle(paymentPayload, paymentRequirements)
|
||||
const second = await uut.settle(paymentPayload, paymentRequirements)
|
||||
const third = await uut.settle(paymentPayload, paymentRequirements)
|
||||
|
||||
assert.equal(first.transaction, 'CDP-tx')
|
||||
assert.equal(second.transaction, 'Dexter-tx')
|
||||
assert.equal(third.transaction, 'PayAI-tx')
|
||||
})
|
||||
|
||||
it('fails over when selected facilitator fails settle', async () => {
|
||||
let cdpCalls = 0
|
||||
const uut = new RoundRobinFacilitatorClient([
|
||||
makeEntry('CDP', {
|
||||
settle: async () => {
|
||||
cdpCalls++
|
||||
throw new Error('cdp settle failed')
|
||||
}
|
||||
}),
|
||||
makeEntry('Dexter'),
|
||||
makeEntry('PayAI')
|
||||
])
|
||||
|
||||
const paymentPayload = { x402Version: 2 }
|
||||
const paymentRequirements = { network: 'eip155:8453', scheme: 'exact' }
|
||||
|
||||
const first = await uut.settle(paymentPayload, paymentRequirements)
|
||||
const second = await uut.settle(paymentPayload, paymentRequirements)
|
||||
|
||||
assert.equal(cdpCalls, 1)
|
||||
assert.equal(first.transaction, 'Dexter-tx')
|
||||
assert.equal(second.transaction, 'PayAI-tx')
|
||||
})
|
||||
|
||||
it('throws an aggregate error after all settle attempts fail', async () => {
|
||||
const uut = new RoundRobinFacilitatorClient([
|
||||
makeEntry('CDP', { settle: async () => { throw new Error('down') } }),
|
||||
makeEntry('Dexter', { settle: async () => { throw new Error('down') } })
|
||||
])
|
||||
|
||||
try {
|
||||
await uut.settle(
|
||||
{ x402Version: 2 },
|
||||
{ network: 'eip155:8453', scheme: 'exact' }
|
||||
)
|
||||
assert.fail('Expected settle() to throw')
|
||||
} catch (err) {
|
||||
assert.include(err.message, 'All facilitators failed settle()')
|
||||
assert.include(err.message, 'CDP')
|
||||
assert.include(err.message, 'Dexter')
|
||||
}
|
||||
})
|
||||
|
||||
it('uses precedence/fallback behavior for verify', async () => {
|
||||
let cdpCalls = 0
|
||||
let dexterCalls = 0
|
||||
const uut = new RoundRobinFacilitatorClient([
|
||||
makeEntry('CDP', {
|
||||
verify: async () => {
|
||||
cdpCalls++
|
||||
throw new Error('bad verify')
|
||||
}
|
||||
}),
|
||||
makeEntry('Dexter', {
|
||||
verify: async () => {
|
||||
dexterCalls++
|
||||
return { isValid: true, payer: '0x123' }
|
||||
}
|
||||
})
|
||||
])
|
||||
|
||||
const result = await uut.verify(
|
||||
{ x402Version: 2 },
|
||||
{ network: 'eip155:8453', scheme: 'exact' }
|
||||
)
|
||||
|
||||
assert.equal(cdpCalls, 1)
|
||||
assert.equal(dexterCalls, 1)
|
||||
assert.equal(result.payer, '0x123')
|
||||
})
|
||||
|
||||
it('merges and deduplicates getSupported data', async () => {
|
||||
const uut = new RoundRobinFacilitatorClient([
|
||||
makeEntry('CDP', {
|
||||
getSupported: async () => ({
|
||||
kinds: [{ x402Version: 2, network: 'eip155:8453', scheme: 'exact' }],
|
||||
extensions: [{ key: 'bazaar' }],
|
||||
signers: { 'eip155:8453': ['0xaaa'] }
|
||||
})
|
||||
}),
|
||||
makeEntry('Dexter', {
|
||||
getSupported: async () => ({
|
||||
kinds: [
|
||||
{ x402Version: 2, network: 'eip155:8453', scheme: 'exact' },
|
||||
{ x402Version: 2, network: 'eip155:84532', scheme: 'exact' }
|
||||
],
|
||||
extensions: [{ key: 'bazaar' }, { key: 'other' }],
|
||||
signers: { 'eip155:8453': ['0xaaa', '0xbbb'] }
|
||||
})
|
||||
})
|
||||
])
|
||||
|
||||
const supported = await uut.getSupported()
|
||||
assert.lengthOf(supported.kinds, 2)
|
||||
assert.lengthOf(supported.extensions, 2)
|
||||
assert.deepEqual(supported.signers['eip155:8453'], ['0xaaa', '0xbbb'])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user