8 Commits
45 changed files with 3398 additions and 2599 deletions
+4
View File
@@ -0,0 +1,4 @@
# Full Node Connection
RPC_BASEURL=http://172.17.0.1:8332
RPC_USERNAME=bitcoin
RPC_PASSWORD=password
+22
View File
@@ -6,3 +6,25 @@ This is a REST API for communicating with Bitcoin Cash infrastructure. It replac
[MIT](./LICENSE.md)
## x402-bch Payments
All REST endpoints exposed under the `/v6` prefix are protected by the [`x402-bch-express`](https://www.npmjs.com/package/x402-bch-express) middleware. Each API call requires a BCH payment authorization for **2000 satoshis**. The middleware advertises payment requirements via HTTP 402 responses and validates incoming `X-PAYMENT` headers with a configured Facilitator.
### Configuration
Environment variables control the payment flow:
- `X402_ENABLED` — set to `false` (case-insensitive) to disable the middleware. Defaults to enabled.
- `SERVER_BCH_ADDRESS` — BCH cash address that receives funding transactions. Defaults to `bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d`.
- `FACILITATOR_URL` — Root URL of the facilitator service (e.g., `http://localhost:4345/facilitator`).
- `X402_PRICE_SAT` — Optional; override the satoshi price per call (defaults to `2000`).
When `X402_ENABLED=false`, the server continues to operate without payment headers for local development or trusted deployments.
### Manual Verification
1. Start or point to an `x402-bch` facilitator service (the example facilitator listens at `http://localhost:4345/facilitator`).
2. Run the API server with the default configuration: `npm start`.
3. Call a protected endpoint without an `X-PAYMENT` header, e.g. `curl -i http://localhost:5942/v6/full-node/control/getNetworkInfo`. The server will respond with HTTP `402` and include payment requirements.
4. Restart the server with `X402_ENABLED=false npm start` to confirm that the same request now bypasses the middleware (useful for local development without payments).
+5 -5
View File
@@ -1,9 +1,9 @@
{
"name": "REST2NOSTR Proxy API",
"name": "psf-bch-api REST API",
"version": "1.0.0",
"description": "REST API proxy for Nostr WebSocket protocol",
"title": "REST2NOSTR Proxy API",
"url": "https://nostr-relay-api.psfoundation.info",
"sampleUrl": "https://nostr-relay-api.psfoundation.info"
"description": "REST API proxy to Bitcoin Cash infrastructure",
"title": "psf-bch-api REST API",
"url": "http://localhost:5942",
"sampleUrl": "http://localhost:5942"
}
+22
View File
@@ -7,6 +7,7 @@
import express from 'express'
import cors from 'cors'
import dotenv from 'dotenv'
import { paymentMiddleware as x402PaymentMiddleware } from 'x402-bch-express'
import { fileURLToPath } from 'url'
import { dirname, join } from 'path'
@@ -14,6 +15,7 @@ import { dirname, join } from 'path'
import config from '../src/config/index.js'
import Controllers from '../src/controllers/index.js'
import wlogger from '../src/adapters/wlogger.js'
import { buildX402Routes, getX402Settings } from '../src/config/x402.js'
// Load environment variables
dotenv.config()
@@ -57,6 +59,8 @@ class Server {
// Create an Express instance.
const app = express()
const x402Settings = getX402Settings()
// MIDDLEWARE START
app.use(express.json())
app.use(express.urlencoded({ extended: true }))
@@ -68,6 +72,24 @@ class Server {
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With']
}))
if (x402Settings.enabled) {
const routes = buildX402Routes(this.config.apiPrefix)
const facilitatorOptions = x402Settings.facilitatorUrl
? { url: x402Settings.facilitatorUrl }
: undefined
wlogger.info(`x402 middleware enabled; enforcing ${x402Settings.priceSat} satoshis per request`)
app.use(
x402PaymentMiddleware(
x402Settings.serverAddress,
routes,
facilitatorOptions
)
)
} else {
wlogger.info('x402 middleware disabled via configuration')
}
// Endpoint logging middleware
app.use((req, res, next) => {
console.log(`Endpoint called: ${req.method} ${req.path}`)
+1145 -85
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -15,11 +15,13 @@
"license": "MIT",
"description": "REST API proxy to Bitcoin Cash infrastructure",
"dependencies": {
"axios": "1.7.7",
"cors": "2.8.5",
"dotenv": "16.3.1",
"express": "5.1.0",
"winston": "3.11.0",
"winston-daily-rotate-file": "4.7.1"
"winston-daily-rotate-file": "4.7.1",
"x402-bch-express": "1.1.1"
},
"devDependencies": {
"apidoc": "1.2.0",
+133
View File
@@ -0,0 +1,133 @@
/*
Adapter library for interacting with a BCH full node over JSON-RPC.
*/
import axios from 'axios'
import wlogger from './wlogger.js'
import config from '../config/index.js'
class FullNodeRPCAdapter {
constructor (localConfig = {}) {
this.config = localConfig.config || config
if (!this.config.fullNode || !this.config.fullNode.rpcBaseUrl) {
throw new Error('Full node RPC configuration is required')
}
const {
rpcBaseUrl,
rpcUsername,
rpcPassword,
rpcTimeoutMs = 15000
} = this.config.fullNode
this.requestIdPrefix = this.config.fullNode.rpcRequestIdPrefix || 'psf-bch-api'
this.http = axios.create({
baseURL: rpcBaseUrl,
timeout: rpcTimeoutMs,
auth: {
username: rpcUsername,
password: rpcPassword
}
})
this.defaultRequestPayload = {
jsonrpc: '1.0'
}
}
async call (method, params = [], requestId) {
const id = requestId || `${this.requestIdPrefix}-${method}`
try {
const response = await this.http.post('', {
...this.defaultRequestPayload,
id,
method,
params
})
if (response.data && response.data.error) {
const rpcError = this._formatError(response.data.error.message, 400)
throw rpcError
}
return response.data.result
} catch (err) {
throw this._handleError(err)
}
}
_handleError (err) {
const { status, message } = this.decodeError(err)
const error = new Error(message)
error.status = status
error.originalError = err
return error
}
decodeError (err) {
try {
if (
err.response &&
err.response.data &&
err.response.data.error &&
err.response.data.error.message
) {
return this._formatError(err.response.data.error.message, 400)
}
if (err.response && err.response.data) {
return this._formatError(err.response.data, err.response.status || 500)
}
if (err.message) {
if (err.message.includes('ENOTFOUND') || err.message.includes('ENETUNREACH') || err.message.includes('EAI_AGAIN')) {
return this._formatError(
'Network error: Could not communicate with full node or other external service.',
503
)
}
}
if (err.code && (err.code === 'ECONNABORTED' || err.code === 'ECONNREFUSED')) {
return this._formatError(
'Network error: Could not communicate with full node or other external service.',
503
)
}
if (err.error && typeof err.error === 'string' && err.error.includes('429')) {
return this._formatError('429 Too Many Requests', 429)
}
if (err.message) {
return this._formatError(err.message, err.status || 422)
}
return this._formatError('Unhandled full node error', 500)
} catch (decodeError) {
wlogger.error('Unhandled error in FullNodeRPCAdapter.decodeError()', decodeError)
return this._formatError('Internal server error', 500)
}
}
validateArraySize (length, options = {}) {
const { isProUser = false } = options
const freemiumLimit = Number(this.config.fullNode?.freemiumArrayLimit || 20)
const proLimit = Number(this.config.fullNode?.proArrayLimit || freemiumLimit)
const limit = isProUser ? proLimit : freemiumLimit
return length <= limit
}
_formatError (message, status = 500) {
return {
message: message || 'Internal server error',
status: status || 500
}
}
}
export default FullNodeRPCAdapter
+3
View File
@@ -6,6 +6,7 @@
// Load individual adapter libraries.
// import NostrRelayAdapter from './nostr-relay.js'
import FullNodeRPCAdapter from './full-node-rpc.js'
import config from '../config/index.js'
class Adapters {
@@ -30,6 +31,8 @@ class Adapters {
// Maintain backward compatibility: expose first relay as nostrRelay
// This allows existing code to work during transition
// this.nostrRelay = this.nostrRelays[0]
this.fullNode = new FullNodeRPCAdapter({ config: this.config })
}
async start () {
-241
View File
@@ -1,241 +0,0 @@
/*
Nostr Relay WebSocket adapter.
Handles WebSocket connections to Nostr relays and manages message sending/receiving.
*/
import WebSocket from 'ws'
import config from '../config/index.js'
import wlogger from './wlogger.js'
class NostrRelayAdapter {
constructor (localConfig = {}) {
this.config = config
this.relayUrl = localConfig.relayUrl || config.nostrRelayUrl
this.ws = null
this.isConnected = false
this.reconnectAttempts = 0
this.maxReconnectAttempts = 5
this.reconnectDelay = 5000 // 5 seconds
this.messageHandlers = new Map() // Map subscription_id to handlers
this.pendingMessages = [] // Queue messages while disconnected
this.eventResolvers = new Map() // Map event_id to promise resolvers for OK responses
this.subscriptionHandlers = new Map() // Map subscription_id to event handlers
// Bind methods
this.connect = this.connect.bind(this)
this.disconnect = this.disconnect.bind(this)
this.sendEvent = this.sendEvent.bind(this)
this.sendReq = this.sendReq.bind(this)
this.sendClose = this.sendClose.bind(this)
this.handleMessage = this.handleMessage.bind(this)
this.handleError = this.handleError.bind(this)
this.handleClose = this.handleClose.bind(this)
}
async connect () {
if (this.ws && this.isConnected) {
return true
}
return new Promise((resolve, reject) => {
try {
wlogger.info(`Connecting to Nostr relay: ${this.relayUrl}`)
this.ws = new WebSocket(this.relayUrl)
this.ws.on('open', () => {
wlogger.info('Connected to Nostr relay')
this.isConnected = true
this.reconnectAttempts = 0
// Send any pending messages
while (this.pendingMessages.length > 0) {
const message = this.pendingMessages.shift()
this.ws.send(JSON.stringify(message))
}
resolve(true)
})
this.ws.on('message', (data) => {
try {
const message = JSON.parse(data.toString())
this.handleMessage(message)
} catch (err) {
wlogger.error('Error parsing relay message:', err)
}
})
this.ws.on('error', this.handleError)
this.ws.on('close', this.handleClose)
// Timeout after 10 seconds
setTimeout(() => {
if (!this.isConnected) {
reject(new Error('Connection timeout'))
}
}, 10000)
} catch (err) {
wlogger.error('Error connecting to relay:', err)
reject(err)
}
})
}
async disconnect () {
if (this.ws) {
this.ws.close()
this.ws = null
this.isConnected = false
wlogger.info('Disconnected from Nostr relay')
}
}
handleMessage (message) {
if (!Array.isArray(message) || message.length === 0) {
return
}
const [type, ...args] = message
switch (type) {
case 'EVENT':
// ["EVENT", <subscription_id>, <event>]
if (args.length >= 2) {
const subscriptionId = args[0]
const event = args[1]
const handler = this.subscriptionHandlers.get(subscriptionId)
if (handler) {
handler.onEvent(event)
}
}
break
case 'OK':
// ["OK", <event_id>, <true|false>, <message>]
if (args.length >= 2) {
const eventId = args[0]
const accepted = args[1]
const message = args[2] || ''
const resolver = this.eventResolvers.get(eventId)
if (resolver) {
resolver({ accepted, message })
this.eventResolvers.delete(eventId)
}
}
break
case 'EOSE':
// ["EOSE", <subscription_id>]
if (args.length >= 1) {
const subscriptionId = args[0]
const handler = this.subscriptionHandlers.get(subscriptionId)
if (handler) {
handler.onEose()
}
}
break
case 'CLOSED':
// ["CLOSED", <subscription_id>, <message>]
if (args.length >= 1) {
const subscriptionId = args[0]
const message = args[1] || ''
const handler = this.subscriptionHandlers.get(subscriptionId)
if (handler) {
handler.onClosed(message)
}
}
break
case 'NOTICE':
// ["NOTICE", <message>]
if (args.length >= 1) {
const message = args[0]
wlogger.warn('Relay notice:', message)
}
break
default:
wlogger.warn('Unknown message type from relay:', type)
}
}
handleError (error) {
wlogger.error('WebSocket error:', error)
this.isConnected = false
}
handleClose () {
const now = new Date()
wlogger.warn(`WebSocket connection closed at ${now.toLocaleString()}`)
this.isConnected = false
// Attempt to reconnect
if (this.reconnectAttempts < this.maxReconnectAttempts) {
this.reconnectAttempts++
wlogger.info(`Attempting to reconnect (${this.reconnectAttempts}/${this.maxReconnectAttempts})...`)
setTimeout(() => {
this.connect().catch(err => {
wlogger.error('Reconnection failed:', err)
})
}, this.reconnectDelay)
}
}
async sendMessage (message) {
if (!this.isConnected || !this.ws) {
// Queue message for when connection is established
this.pendingMessages.push(message)
await this.connect()
return
}
try {
this.ws.send(JSON.stringify(message))
} catch (err) {
wlogger.error('Error sending message:', err)
throw err
}
}
async sendEvent (event) {
// ["EVENT", <event>]
const message = ['EVENT', event]
await this.sendMessage(message)
// Return a promise that resolves when we get the OK response
return new Promise((resolve, reject) => {
this.eventResolvers.set(event.id, resolve)
// Timeout after 30 seconds
setTimeout(() => {
if (this.eventResolvers.has(event.id)) {
this.eventResolvers.delete(event.id)
reject(new Error('Timeout waiting for OK response'))
}
}, 30000)
})
}
async sendReq (subscriptionId, filters, handlers) {
// ["REQ", <subscription_id>, <filters>]
await this.connect()
// Store handlers for this subscription
this.subscriptionHandlers.set(subscriptionId, handlers)
const message = ['REQ', subscriptionId, ...filters]
await this.sendMessage(message)
}
async sendClose (subscriptionId) {
// ["CLOSE", <subscription_id>]
const message = ['CLOSE', subscriptionId]
await this.sendMessage(message)
// Clean up handlers
this.subscriptionHandlers.delete(subscriptionId)
this.messageHandlers.delete(subscriptionId)
}
}
export default NostrRelayAdapter
+36
View File
@@ -3,16 +3,38 @@
environments.
*/
import dotenv from 'dotenv'
// Hack to get __dirname back.
// https://blog.logrocket.com/alternatives-dirname-node-js-es-modules/
import * as url from 'url'
import { readFileSync } from 'fs'
dotenv.config()
const __dirname = url.fileURLToPath(new URL('.', import.meta.url))
const pkgInfo = JSON.parse(readFileSync(`${__dirname.toString()}/../../../package.json`))
const version = pkgInfo.version
const normalizeBoolean = (value, defaultValue) => {
if (value === undefined || value === null || value === '') return defaultValue
const normalized = String(value).trim().toLowerCase()
if (['false', '0', 'no', 'off'].includes(normalized)) return false
if (['true', '1', 'yes', 'on'].includes(normalized)) return true
return defaultValue
}
const parsedPriceSat = Number(process.env.X402_PRICE_SAT)
const priceSat = Number.isFinite(parsedPriceSat) && parsedPriceSat > 0 ? parsedPriceSat : 2000
const x402Defaults = {
enabled: normalizeBoolean(process.env.X402_ENABLED, true),
facilitatorUrl: process.env.FACILITATOR_URL || 'http://localhost:4345/facilitator',
serverAddress: process.env.SERVER_BCH_ADDRESS || 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d',
priceSat
}
export default {
// Server port
port: process.env.PORT || 5942,
@@ -20,6 +42,9 @@ export default {
// Environment
env: process.env.NODE_ENV || 'development',
// API prefix for REST controllers
apiPrefix: process.env.API_PREFIX || '/v6',
// Logging level
logLevel: process.env.LOG_LEVEL || 'info',
@@ -47,6 +72,17 @@ export default {
return ['wss://nostr-relay.psfoundation.info', 'wss://relay.damus.io']
})(),
// Full node RPC configuration
fullNode: {
rpcBaseUrl: process.env.RPC_BASEURL || 'http://127.0.0.1:8332',
rpcUsername: process.env.RPC_USERNAME || '',
rpcPassword: process.env.RPC_PASSWORD || '',
rpcTimeoutMs: Number(process.env.RPC_TIMEOUT_MS || 15000),
rpcRequestIdPrefix: process.env.RPC_REQUEST_ID_PREFIX || 'psf-bch-api'
},
x402: x402Defaults,
// Version
version
}
+43
View File
@@ -0,0 +1,43 @@
import config from './index.js'
const DEFAULT_DESCRIPTION = 'Access to protected psf-bch-api resources'
const DEFAULT_TIMEOUT_SECONDS = 60
const NETWORK = 'bch'
/**
* Builds a route configuration map for x402-bch middleware.
*
* @param {string} apiPrefix Express API prefix (e.g., "/v6")
* @returns {Object} Routes configuration compatible with x402-bch-express
*/
export function buildX402Routes (apiPrefix = '/v6') {
const normalizedPrefix = apiPrefix.endsWith('/')
? apiPrefix.slice(0, -1)
: apiPrefix
const prefixWithSlash = normalizedPrefix.startsWith('/')
? normalizedPrefix
: `/${normalizedPrefix}`
const routeKey = `${prefixWithSlash}/*`
return {
network: NETWORK,
[routeKey]: {
price: config.x402.priceSat,
network: NETWORK,
config: {
description: `${DEFAULT_DESCRIPTION} (2000 satoshis)`,
maxTimeoutSeconds: DEFAULT_TIMEOUT_SECONDS
}
}
}
}
export function getX402Settings () {
return {
enabled: Boolean(config.x402?.enabled),
facilitatorUrl: config.x402?.facilitatorUrl,
serverAddress: config.x402?.serverAddress,
priceSat: config.x402?.priceSat
}
}
+3 -1
View File
@@ -18,6 +18,7 @@ class Controllers {
this.useCases = new UseCases({ adapters: this.adapters })
this.config = config
this.timerController = new TimerController({ adapters: this.adapters, useCases: this.useCases })
this.apiPrefix = this.config.apiPrefix || '/v6'
// Bind 'this' object to all subfunctions
this.initAdapters = this.initAdapters.bind(this)
@@ -45,7 +46,8 @@ class Controllers {
attachRESTControllers (app) {
const restControllers = new RESTControllers({
adapters: this.adapters,
useCases: this.useCases
useCases: this.useCases,
apiPrefix: this.apiPrefix
})
// Attach the REST API Controllers to the Express app.
@@ -1,99 +0,0 @@
/*
REST API Controller library for the /event route
*/
// Local libraries
import wlogger from '../../../adapters/wlogger.js'
class EventRESTControllerLib {
constructor (localConfig = {}) {
// Dependency Injection.
this.adapters = localConfig.adapters
if (!this.adapters) {
throw new Error(
'Instance of Adapters library required when instantiating /event REST Controller.'
)
}
this.useCases = localConfig.useCases
if (!this.useCases) {
throw new Error(
'Instance of Use Cases library required when instantiating /event REST Controller.'
)
}
// Bind 'this' object to all subfunctions
this.publishEvent = this.publishEvent.bind(this)
this.handleError = this.handleError.bind(this)
}
/**
* @api {post} /event Publish a Nostr event
* @apiPermission public
* @apiName PublishEvent
* @apiGroup Event
*
* @apiDescription Publish a signed Nostr event to the relay. Maps to the Nostr WebSocket protocol message: ["EVENT", <event>]
*
* @apiParam {String} id Event ID (32-bytes lowercase hex-encoded sha256)
* @apiParam {String} pubkey Public key of event creator (32-bytes lowercase hex-encoded)
* @apiParam {Number} created_at Unix timestamp in seconds
* @apiParam {Number} kind Integer between 0 and 65535
* @apiParam {Array} tags Array of tag arrays
* @apiParam {String} content Event content (arbitrary string)
* @apiParam {String} sig Signature (64-bytes lowercase hex)
*
* @apiExample {json} Example usage:
* {
* "id": "5c83da77af1dec6d7289834998ad7aafbd9e2191396d75ec3cc27f5a77226f36",
* "pubkey": "2c7e76c0f8dc1dca9d0197c7d19be580a8d074ccada6a2f6ebe056ae41092e9",
* "created_at": 1672531200,
* "kind": 1,
* "tags": [],
* "content": "Hello, Nostr!",
* "sig": "abc123..."
* }
*
* @apiSuccess {Boolean} accepted Whether the event was accepted by the relay
* @apiSuccess {String} message Optional message from the relay
* @apiSuccess {String} eventId The event ID
*
* @apiError {String} error Error message
*/
async publishEvent (req, res) {
try {
const eventData = req.body
// Check if eventData is missing or empty
if (!eventData || (typeof eventData === 'object' && Object.keys(eventData).length === 0)) {
return res.status(400).json({
error: 'Event data is required'
})
}
const result = await this.useCases.publishEvent.execute(eventData)
if (result.accepted) {
return res.status(200).json(result)
} else {
return res.status(400).json(result)
}
} catch (err) {
return this.handleError(err, req, res)
}
}
handleError (err, req, res) {
wlogger.error('Error in EventRESTController:', err)
// Return 400 for validation errors, 500 for other errors
// Validation errors indicate the client sent bad data
const isValidationError = err.message && err.message.includes('Invalid event structure')
const statusCode = isValidationError ? 400 : 500
return res.status(statusCode).json({
error: err.message || 'Internal server error'
})
}
}
export default EventRESTControllerLib
-55
View File
@@ -1,55 +0,0 @@
/*
REST API library for the /event route.
*/
// Public npm libraries.
import express from 'express'
// Local libraries.
import EventRESTControllerLib from './controller.js'
class EventRouter {
constructor (localConfig = {}) {
// Dependency Injection.
this.adapters = localConfig.adapters
if (!this.adapters) {
throw new Error(
'Instance of Adapters library required when instantiating Event REST Controller.'
)
}
this.useCases = localConfig.useCases
if (!this.useCases) {
throw new Error(
'Instance of Use Cases library required when instantiating Event REST Controller.'
)
}
const dependencies = {
adapters: this.adapters,
useCases: this.useCases
}
// Encapsulate dependencies.
this.eventRESTController = new EventRESTControllerLib(dependencies)
// Instantiate the router and set the base route.
this.baseUrl = '/event'
this.router = express.Router()
}
attach (app) {
if (!app) {
throw new Error(
'Must pass app object when attaching REST API controllers.'
)
}
// Define the routes and attach the controller.
this.router.post('/', this.eventRESTController.publishEvent)
// Attach the Controller routes to the Express app.
app.use(this.baseUrl, this.router)
}
}
export default EventRouter
@@ -0,0 +1,553 @@
/*
REST API Controller for the /full-node/blockchain routes.
*/
import wlogger from '../../../../adapters/wlogger.js'
class BlockchainRESTController {
constructor (localConfig = {}) {
this.adapters = localConfig.adapters
if (!this.adapters) {
throw new Error(
'Instance of Adapters library required when instantiating Blockchain REST Controller.'
)
}
this.useCases = localConfig.useCases
if (!this.useCases || !this.useCases.blockchain) {
throw new Error(
'Instance of Blockchain use cases required when instantiating Blockchain REST Controller.'
)
}
this.blockchainUseCases = this.useCases.blockchain
// Bind functions
this.root = this.root.bind(this)
this.getBestBlockHash = this.getBestBlockHash.bind(this)
this.getBlockchainInfo = this.getBlockchainInfo.bind(this)
this.getBlockCount = this.getBlockCount.bind(this)
this.getBlockHeaderSingle = this.getBlockHeaderSingle.bind(this)
this.getBlockHeaderBulk = this.getBlockHeaderBulk.bind(this)
this.getChainTips = this.getChainTips.bind(this)
this.getDifficulty = this.getDifficulty.bind(this)
this.getMempoolEntrySingle = this.getMempoolEntrySingle.bind(this)
this.getMempoolEntryBulk = this.getMempoolEntryBulk.bind(this)
this.getMempoolAncestorsSingle = this.getMempoolAncestorsSingle.bind(this)
this.getMempoolInfo = this.getMempoolInfo.bind(this)
this.getRawMempool = this.getRawMempool.bind(this)
this.getTxOut = this.getTxOut.bind(this)
this.getTxOutPost = this.getTxOutPost.bind(this)
this.getTxOutProofSingle = this.getTxOutProofSingle.bind(this)
this.getTxOutProofBulk = this.getTxOutProofBulk.bind(this)
this.verifyTxOutProofSingle = this.verifyTxOutProofSingle.bind(this)
this.verifyTxOutProofBulk = this.verifyTxOutProofBulk.bind(this)
this.getBlock = this.getBlock.bind(this)
this.getBlockHash = this.getBlockHash.bind(this)
this.handleError = this.handleError.bind(this)
}
/**
* @api {get} /v6/full-node/blockchain/ Service status
* @apiName BlockchainRoot
* @apiGroup Blockchain
*
* @apiDescription Returns the status of the blockchain service.
*
* @apiSuccess {String} status Service identifier
*/
async root (req, res) {
return res.status(200).json({ status: 'blockchain' })
}
/**
* @api {get} /v6/full-node/blockchain/getBestBlockHash Get best block hash
* @apiName GetBestBlockHash
* @apiGroup Blockchain
* @apiDescription Returns the hash of the best (tip) block in the longest block chain.
*
* @apiExample Example usage:
* curl -X GET "https://api.fullstack.cash/v6/full-node/blockchain/getBestBlockHash" -H "accept: application/json"
*
* @apiSuccess {String} bestBlockHash Hash of the best block
*/
async getBestBlockHash (req, res) {
try {
const result = await this.blockchainUseCases.getBestBlockHash()
return res.status(200).json(result)
} catch (err) {
return this.handleError(err, res)
}
}
/**
* @api {get} /v6/full-node/blockchain/getBlockchainInfo Get blockchain info
* @apiName GetBlockchainInfo
* @apiGroup Blockchain
* @apiDescription Returns various state info regarding blockchain processing.
*/
async getBlockchainInfo (req, res) {
try {
const result = await this.blockchainUseCases.getBlockchainInfo()
return res.status(200).json(result)
} catch (err) {
return this.handleError(err, res)
}
}
/**
* @api {get} /v6/full-node/blockchain/getBlockCount Get block count
* @apiName GetBlockCount
* @apiGroup Blockchain
* @apiDescription Returns the number of blocks in the longest blockchain.
*/
async getBlockCount (req, res) {
try {
const result = await this.blockchainUseCases.getBlockCount()
return res.status(200).json(result)
} catch (err) {
return this.handleError(err, res)
}
}
/**
* @api {get} /v6/full-node/blockchain/getBlockHeader/:hash Get single block header
* @apiName GetSingleBlockHeader
* @apiGroup Blockchain
* @apiDescription Returns serialized block header data.
*
* @apiParam {String} hash Block hash
* @apiParam {Boolean} verbose Return verbose data (default false)
*/
async getBlockHeaderSingle (req, res) {
try {
const hash = req.params.hash
if (!hash) {
return res.status(400).json({ error: 'hash can not be empty' })
}
const verbose = req.query.verbose?.toString() === 'true'
const result = await this.blockchainUseCases.getBlockHeader({ hash, verbose })
return res.status(200).json(result)
} catch (err) {
return this.handleError(err, res)
}
}
/**
* @api {post} /v6/full-node/blockchain/getBlockHeader Get multiple block headers
* @apiName GetBulkBlockHeader
* @apiGroup Blockchain
* @apiDescription Returns serialized block header data for multiple hashes.
*
* @apiParam {String[]} hashes Block hashes
* @apiParam {Boolean} verbose Return verbose data (default false)
*/
async getBlockHeaderBulk (req, res) {
try {
const hashes = req.body.hashes
const verbose = !!req.body.verbose
if (!Array.isArray(hashes)) {
return res.status(400).json({
error: 'hashes needs to be an array. Use GET for single hash.'
})
}
if (!this.adapters.fullNode.validateArraySize(hashes.length, { isProUser: Boolean(req.locals?.proLimit) })) {
return res.status(400).json({ error: 'Array too large.' })
}
for (const hash of hashes) {
if (!hash || hash.length !== 64) {
return res.status(400).json({ error: `This is not a hash: ${hash}` })
}
}
const result = await this.blockchainUseCases.getBlockHeaders({ hashes, verbose })
return res.status(200).json(result)
} catch (err) {
return this.handleError(err, res)
}
}
/**
* @api {get} /v6/full-node/blockchain/getChainTips Get chain tips
* @apiName GetChainTips
* @apiGroup Blockchain
* @apiDescription Returns information about known tips in the block tree.
*/
async getChainTips (req, res) {
try {
const result = await this.blockchainUseCases.getChainTips()
return res.status(200).json(result)
} catch (err) {
return this.handleError(err, res)
}
}
/**
* @api {get} /v6/full-node/blockchain/getDifficulty Get difficulty
* @apiName GetDifficulty
* @apiGroup Blockchain
* @apiDescription Returns the current difficulty value.
*/
async getDifficulty (req, res) {
try {
const result = await this.blockchainUseCases.getDifficulty()
return res.status(200).json(result)
} catch (err) {
return this.handleError(err, res)
}
}
/**
* @api {get} /v6/full-node/blockchain/getMempoolEntry/:txid Get single mempool entry
* @apiName GetMempoolEntry
* @apiGroup Blockchain
* @apiDescription Returns mempool data for a transaction.
*/
async getMempoolEntrySingle (req, res) {
try {
const txid = req.params.txid
if (!txid) {
return res.status(400).json({ error: 'txid can not be empty' })
}
const result = await this.blockchainUseCases.getMempoolEntry({ txid })
return res.status(200).json(result)
} catch (err) {
return this.handleError(err, res)
}
}
/**
* @api {post} /v6/full-node/blockchain/getMempoolEntry Get bulk mempool entry
* @apiName GetMempoolEntryBulk
* @apiGroup Blockchain
* @apiDescription Returns mempool data for multiple transactions.
*/
async getMempoolEntryBulk (req, res) {
try {
const txids = req.body.txids
if (!Array.isArray(txids)) {
return res.status(400).json({
error: 'txids needs to be an array. Use GET for single txid.'
})
}
if (!this.adapters.fullNode.validateArraySize(txids.length, { isProUser: Boolean(req.locals?.proLimit) })) {
return res.status(400).json({ error: 'Array too large.' })
}
for (const txid of txids) {
if (!txid || txid.length !== 64) {
return res.status(400).json({ error: 'This is not a txid' })
}
}
const result = await this.blockchainUseCases.getMempoolEntries({ txids })
return res.status(200).json(result)
} catch (err) {
return this.handleError(err, res)
}
}
/**
* @api {get} /v6/full-node/blockchain/getMempoolAncestors/:txid Get mempool ancestors
* @apiName GetMempoolAncestors
* @apiGroup Blockchain
* @apiDescription Returns mempool ancestor data for a transaction.
*/
async getMempoolAncestorsSingle (req, res) {
try {
const txid = req.params.txid
if (!txid) {
return res.status(400).json({ error: 'txid can not be empty' })
}
let verbose = false
if (req.query.verbose && req.query.verbose.toString() === 'true') {
verbose = true
}
const result = await this.blockchainUseCases.getMempoolAncestors({ txid, verbose })
return res.status(200).json(result)
} catch (err) {
return this.handleError(err, res)
}
}
/**
* @api {get} /v6/full-node/blockchain/getMempoolInfo Get mempool info
* @apiName GetMempoolInfo
* @apiGroup Blockchain
* @apiDescription Returns details on the state of the mempool.
*/
async getMempoolInfo (req, res) {
try {
const result = await this.blockchainUseCases.getMempoolInfo()
return res.status(200).json(result)
} catch (err) {
return this.handleError(err, res)
}
}
/**
* @api {get} /v6/full-node/blockchain/getRawMempool Get raw mempool
* @apiName GetRawMempool
* @apiGroup Blockchain
* @apiDescription Returns all transaction ids in the mempool.
*
* @apiParam {Boolean} verbose Return verbose data (default false)
*/
async getRawMempool (req, res) {
try {
const verbose = req.query.verbose === 'true'
const result = await this.blockchainUseCases.getRawMempool({ verbose })
return res.status(200).json(result)
} catch (err) {
return this.handleError(err, res)
}
}
/**
* @api {get} /v6/full-node/blockchain/getTxOut/:txid/:n Get transaction output
* @apiName GetTxOut
* @apiGroup Blockchain
* @apiDescription Returns details about an unspent transaction output.
*/
async getTxOut (req, res) {
try {
const txid = req.params.txid
if (!txid) {
return res.status(400).json({ error: 'txid can not be empty' })
}
const nRaw = req.params.n
if (nRaw === undefined || nRaw === '') {
return res.status(400).json({ error: 'n can not be empty' })
}
const n = parseInt(nRaw)
const includeMempool = req.query.includeMempool === 'true'
const result = await this.blockchainUseCases.getTxOut({
txid,
n,
includeMempool
})
return res.status(200).json(result)
} catch (err) {
return this.handleError(err, res)
}
}
/**
* @api {post} /v6/full-node/blockchain/getTxOut Validate a UTXO
* @apiName GetTxOutPost
* @apiGroup Blockchain
* @apiDescription Returns details about an unspent transaction output.
*/
async getTxOutPost (req, res) {
try {
const txid = req.body.txid
if (!txid) {
return res.status(400).json({ error: 'txid can not be empty' })
}
const voutRaw = req.body.vout
if (voutRaw === undefined || voutRaw === '') {
return res.status(400).json({ error: 'vout can not be empty' })
}
const n = parseInt(voutRaw)
const mempool = req.body.mempool !== undefined ? !!req.body.mempool : true
const result = await this.blockchainUseCases.getTxOut({
txid,
n,
includeMempool: mempool
})
return res.status(200).json(result)
} catch (err) {
return this.handleError(err, res)
}
}
/**
* @api {get} /v6/full-node/blockchain/getTxOutProof/:txid Get TxOut proof
* @apiName GetTxOutProofSingle
* @apiGroup Blockchain
* @apiDescription Returns a hex-encoded proof that the transaction was included in a block.
*/
async getTxOutProofSingle (req, res) {
try {
const txid = req.params.txid
if (!txid) {
return res.status(400).json({ error: 'txid can not be empty' })
}
const result = await this.blockchainUseCases.getTxOutProof({ txid })
return res.status(200).json(result)
} catch (err) {
return this.handleError(err, res)
}
}
/**
* @api {post} /v6/full-node/blockchain/getTxOutProof Get TxOut proofs
* @apiName GetTxOutProofBulk
* @apiGroup Blockchain
* @apiDescription Returns hex-encoded proofs for transactions.
*/
async getTxOutProofBulk (req, res) {
try {
const txids = req.body.txids
if (!Array.isArray(txids)) {
return res.status(400).json({
error: 'txids needs to be an array. Use GET for single txid.'
})
}
if (!this.adapters.fullNode.validateArraySize(txids.length, { isProUser: Boolean(req.locals?.proLimit) })) {
return res.status(400).json({ error: 'Array too large.' })
}
for (const txid of txids) {
if (!txid || txid.length !== 64) {
return res.status(400).json({
error: `Invalid txid. Double check your txid is valid: ${txid}`
})
}
}
const result = await this.blockchainUseCases.getTxOutProofs({ txids })
return res.status(200).json(result)
} catch (err) {
return this.handleError(err, res)
}
}
/**
* @api {get} /v6/full-node/blockchain/verifyTxOutProof/:proof Verify TxOut proof
* @apiName VerifyTxOutProofSingle
* @apiGroup Blockchain
* @apiDescription Verifies a hex-encoded proof was included in a block.
*/
async verifyTxOutProofSingle (req, res) {
try {
const proof = req.params.proof
if (!proof) {
return res.status(400).json({ error: 'proof can not be empty' })
}
const result = await this.blockchainUseCases.verifyTxOutProof({ proof })
return res.status(200).json(result)
} catch (err) {
return this.handleError(err, res)
}
}
/**
* @api {post} /v6/full-node/blockchain/verifyTxOutProof Verify TxOut proofs
* @apiName VerifyTxOutProofBulk
* @apiGroup Blockchain
* @apiDescription Verifies hex-encoded proofs were included in blocks.
*/
async verifyTxOutProofBulk (req, res) {
try {
const proofs = req.body.proofs
if (!Array.isArray(proofs)) {
return res.status(400).json({
error: 'proofs needs to be an array. Use GET for single proof.'
})
}
if (!this.adapters.fullNode.validateArraySize(proofs.length, { isProUser: Boolean(req.locals?.proLimit) })) {
return res.status(400).json({ error: 'Array too large.' })
}
for (const proof of proofs) {
if (!proof) {
return res.status(400).json({ error: `proof can not be empty: ${proof}` })
}
}
const result = await this.blockchainUseCases.verifyTxOutProofs({ proofs })
const flattened = result.map(entry => Array.isArray(entry) ? entry[0] : entry)
return res.status(200).json(flattened)
} catch (err) {
return this.handleError(err, res)
}
}
/**
* @api {post} /v6/full-node/blockchain/getBlock Get block details
* @apiName GetBlock
* @apiGroup Blockchain
* @apiDescription Returns block details for a hash.
*/
async getBlock (req, res) {
try {
const blockhash = req.body.blockhash
if (!blockhash) {
return res.status(400).json({ error: 'blockhash can not be empty' })
}
let verbosity = req.body.verbosity
if (verbosity === undefined || verbosity === null) {
verbosity = 1
}
const result = await this.blockchainUseCases.getBlock({
blockhash,
verbosity
})
return res.status(200).json(result)
} catch (err) {
return this.handleError(err, res)
}
}
/**
* @api {get} /v6/full-node/blockchain/getBlockHash/:height Get block hash
* @apiName GetBlockHash
* @apiGroup Blockchain
* @apiDescription Returns the hash of a block by height.
*/
async getBlockHash (req, res) {
try {
const heightRaw = req.params.height
if (!heightRaw) {
return res.status(400).json({ error: 'height can not be empty' })
}
const height = parseInt(heightRaw)
const result = await this.blockchainUseCases.getBlockHash({ height })
return res.status(200).json(result)
} catch (err) {
return this.handleError(err, res)
}
}
handleError (err, res) {
wlogger.error('Error in BlockchainRESTController:', err)
const status = err.status || 500
const message = err.message || 'Internal server error'
return res.status(status).json({ error: message })
}
}
export default BlockchainRESTController
@@ -0,0 +1,70 @@
/*
REST API router for /full-node/blockchain routes.
*/
import express from 'express'
import BlockchainRESTController from './controller.js'
class BlockchainRouter {
constructor (localConfig = {}) {
this.adapters = localConfig.adapters
if (!this.adapters) {
throw new Error(
'Instance of Adapters library required when instantiating Blockchain REST Router.'
)
}
this.useCases = localConfig.useCases
if (!this.useCases) {
throw new Error(
'Instance of Use Cases library required when instantiating Blockchain REST Router.'
)
}
const dependencies = {
adapters: this.adapters,
useCases: this.useCases
}
this.blockchainController = new BlockchainRESTController(dependencies)
this.apiPrefix = (localConfig.apiPrefix || '').replace(/\/$/, '')
this.baseUrl = `${this.apiPrefix}/full-node/blockchain`
if (!this.baseUrl.startsWith('/')) {
this.baseUrl = `/${this.baseUrl}`
}
this.router = express.Router()
}
attach (app) {
if (!app) {
throw new Error('Must pass app object when attaching REST API controllers.')
}
this.router.get('/', this.blockchainController.root)
this.router.get('/getBestBlockHash', this.blockchainController.getBestBlockHash)
this.router.get('/getBlockchainInfo', this.blockchainController.getBlockchainInfo)
this.router.get('/getBlockCount', this.blockchainController.getBlockCount)
this.router.get('/getBlockHeader/:hash', this.blockchainController.getBlockHeaderSingle)
this.router.post('/getBlockHeader', this.blockchainController.getBlockHeaderBulk)
this.router.get('/getChainTips', this.blockchainController.getChainTips)
this.router.get('/getDifficulty', this.blockchainController.getDifficulty)
this.router.get('/getMempoolEntry/:txid', this.blockchainController.getMempoolEntrySingle)
this.router.post('/getMempoolEntry', this.blockchainController.getMempoolEntryBulk)
this.router.get('/getMempoolAncestors/:txid', this.blockchainController.getMempoolAncestorsSingle)
this.router.get('/getMempoolInfo', this.blockchainController.getMempoolInfo)
this.router.get('/getRawMempool', this.blockchainController.getRawMempool)
this.router.get('/getTxOut/:txid/:n', this.blockchainController.getTxOut)
this.router.post('/getTxOut', this.blockchainController.getTxOutPost)
this.router.get('/getTxOutProof/:txid', this.blockchainController.getTxOutProofSingle)
this.router.post('/getTxOutProof', this.blockchainController.getTxOutProofBulk)
this.router.get('/verifyTxOutProof/:proof', this.blockchainController.verifyTxOutProofSingle)
this.router.post('/verifyTxOutProof', this.blockchainController.verifyTxOutProofBulk)
this.router.post('/getBlock', this.blockchainController.getBlock)
this.router.get('/getBlockHash/:height', this.blockchainController.getBlockHash)
app.use(this.baseUrl, this.router)
}
}
export default BlockchainRouter
@@ -0,0 +1,68 @@
/*
REST API Controller for the /full-node/control routes.
*/
import wlogger from '../../../../adapters/wlogger.js'
class ControlRESTController {
constructor (localConfig = {}) {
this.adapters = localConfig.adapters
if (!this.adapters) {
throw new Error(
'Instance of Adapters library required when instantiating Control REST Controller.'
)
}
this.useCases = localConfig.useCases
if (!this.useCases || !this.useCases.control) {
throw new Error(
'Instance of Control use cases required when instantiating Control REST Controller.'
)
}
this.controlUseCases = this.useCases.control
this.root = this.root.bind(this)
this.getNetworkInfo = this.getNetworkInfo.bind(this)
this.handleError = this.handleError.bind(this)
}
/**
* @api {get} /v6/full-node/control/ Service status
* @apiName ControlRoot
* @apiGroup Control
*
* @apiDescription Returns the status of the control service.
*
* @apiSuccess {String} status Service identifier
*/
async root (req, res) {
return res.status(200).json({ status: 'control' })
}
/**
* @api {get} /v6/full-node/control/getNetworkInfo Get Network Info
* @apiName GetNetworkInfo
* @apiGroup Control
* @apiDescription RPC call that gets basic full node information.
*/
async getNetworkInfo (req, res) {
try {
const result = await this.controlUseCases.getNetworkInfo()
return res.status(200).json(result)
} catch (err) {
return this.handleError(err, res)
}
}
handleError (err, res) {
wlogger.error('Error in ControlRESTController:', err)
const status = err.status || 500
const message = err.message || 'Internal server error'
return res.status(status).json({ error: message })
}
}
export default ControlRESTController
@@ -0,0 +1,51 @@
/*
REST API router for /full-node/control routes.
*/
import express from 'express'
import ControlRESTController from './controller.js'
class ControlRouter {
constructor (localConfig = {}) {
this.adapters = localConfig.adapters
if (!this.adapters) {
throw new Error(
'Instance of Adapters library required when instantiating Control REST Router.'
)
}
this.useCases = localConfig.useCases
if (!this.useCases) {
throw new Error(
'Instance of Use Cases library required when instantiating Control REST Router.'
)
}
const dependencies = {
adapters: this.adapters,
useCases: this.useCases
}
this.controlController = new ControlRESTController(dependencies)
this.apiPrefix = (localConfig.apiPrefix || '').replace(/\/$/, '')
this.baseUrl = `${this.apiPrefix}/full-node/control`
if (!this.baseUrl.startsWith('/')) {
this.baseUrl = `/${this.baseUrl}`
}
this.router = express.Router()
}
attach (app) {
if (!app) {
throw new Error('Must pass app object when attaching REST API controllers.')
}
this.router.get('/', this.controlController.root)
this.router.get('/getNetworkInfo', this.controlController.getNetworkInfo)
app.use(this.baseUrl, this.router)
}
}
export default ControlRouter
@@ -0,0 +1,90 @@
/*
REST API Controller for the /full-node/dsproof routes.
*/
import wlogger from '../../../../adapters/wlogger.js'
class DSProofRESTController {
constructor (localConfig = {}) {
this.adapters = localConfig.adapters
if (!this.adapters) {
throw new Error(
'Instance of Adapters library required when instantiating DSProof REST Controller.'
)
}
this.useCases = localConfig.useCases
if (!this.useCases || !this.useCases.dsproof) {
throw new Error(
'Instance of DSProof use cases required when instantiating DSProof REST Controller.'
)
}
this.dsproofUseCases = this.useCases.dsproof
this.root = this.root.bind(this)
this.getDSProof = this.getDSProof.bind(this)
this.handleError = this.handleError.bind(this)
}
/**
* @api {get} /v6/full-node/dsproof/ Service status
* @apiName DSProofRoot
* @apiGroup DSProof
*
* @apiDescription Returns the status of the dsproof service.
*
* @apiSuccess {String} status Service identifier
*/
async root (req, res) {
return res.status(200).json({ status: 'dsproof' })
}
/**
* @api {get} /v6/full-node/dsproof/getDSProof/:txid Get Double-Spend Proof
* @apiName GetDSProof
* @apiGroup DSProof
* @apiDescription Get information for a double-spend proof.
*
* @apiParam {String} txid Transaction ID
* @apiParam {String} verbose Verbose level (`false`, `true`) for compatibility with legacy API
*/
async getDSProof (req, res) {
try {
const txid = req.params.txid
if (!txid) {
return res.status(400).json({
success: false,
error: 'txid can not be empty'
})
}
if (txid.length !== 64) {
return res.status(400).json({
success: false,
error: `txid must be of length 64 (not ${txid.length})`
})
}
let verbose = 2
if (req.query.verbose === 'true') verbose = 3
const result = await this.dsproofUseCases.getDSProof({ txid, verbose })
return res.status(200).json(result)
} catch (err) {
return this.handleError(err, res)
}
}
handleError (err, res) {
wlogger.error('Error in DSProofRESTController:', err)
const status = err.status || 500
const message = err.message || 'Internal server error'
return res.status(status).json({ error: message })
}
}
export default DSProofRESTController
@@ -0,0 +1,51 @@
/*
REST API router for /full-node/dsproof routes.
*/
import express from 'express'
import DSProofRESTController from './controller.js'
class DSProofRouter {
constructor (localConfig = {}) {
this.adapters = localConfig.adapters
if (!this.adapters) {
throw new Error(
'Instance of Adapters library required when instantiating DSProof REST Router.'
)
}
this.useCases = localConfig.useCases
if (!this.useCases) {
throw new Error(
'Instance of Use Cases library required when instantiating DSProof REST Router.'
)
}
const dependencies = {
adapters: this.adapters,
useCases: this.useCases
}
this.dsproofController = new DSProofRESTController(dependencies)
this.apiPrefix = (localConfig.apiPrefix || '').replace(/\/$/, '')
this.baseUrl = `${this.apiPrefix}/full-node/dsproof`
if (!this.baseUrl.startsWith('/')) {
this.baseUrl = `/${this.baseUrl}`
}
this.router = express.Router()
}
attach (app) {
if (!app) {
throw new Error('Must pass app object when attaching REST API controllers.')
}
this.router.get('/', this.dsproofController.root)
this.router.get('/getDSProof/:txid', this.dsproofController.getDSProof)
app.use(this.baseUrl, this.router)
}
}
export default DSProofRouter
+23 -4
View File
@@ -7,6 +7,9 @@
// Local libraries
// import EventRouter from './event/index.js'
// import ReqRouter from './req/index.js'
import BlockchainRouter from './full-node/blockchain/index.js'
import ControlRouter from './full-node/control/index.js'
import DSProofRouter from './full-node/dsproof/index.js'
import config from '../../config/index.js'
class RESTControllers {
@@ -25,6 +28,12 @@ class RESTControllers {
)
}
// Allow overriding the API prefix for testing, default to v6.
this.apiPrefix = localConfig.apiPrefix || '/v6'
if (this.apiPrefix.length > 1 && this.apiPrefix.endsWith('/')) {
this.apiPrefix = this.apiPrefix.slice(0, -1)
}
// Bind 'this' object to all subfunctions.
this.attachRESTControllers = this.attachRESTControllers.bind(this)
@@ -33,10 +42,11 @@ class RESTControllers {
}
attachRESTControllers (app) {
// const dependencies = {
// adapters: this.adapters,
// useCases: this.useCases
// }
const dependencies = {
adapters: this.adapters,
useCases: this.useCases,
apiPrefix: this.apiPrefix
}
// Attach the REST API Controllers associated with the /event route
// const eventRouter = new EventRouter(dependencies)
@@ -45,6 +55,15 @@ class RESTControllers {
// Attach the REST API Controllers associated with the /req route
// const reqRouter = new ReqRouter(dependencies)
// reqRouter.attach(app)
const blockchainRouter = new BlockchainRouter(dependencies)
blockchainRouter.attach(app)
const controlRouter = new ControlRouter(dependencies)
controlRouter.attach(app)
const dsproofRouter = new DSProofRouter(dependencies)
dsproofRouter.attach(app)
}
}
-296
View File
@@ -1,296 +0,0 @@
/*
REST API Controller library for the /req route
*/
// Local libraries
import wlogger from '../../../adapters/wlogger.js'
class ReqRESTControllerLib {
constructor (localConfig = {}) {
// Dependency Injection.
this.adapters = localConfig.adapters
if (!this.adapters) {
throw new Error(
'Instance of Adapters library required when instantiating /req REST Controller.'
)
}
this.useCases = localConfig.useCases
if (!this.useCases) {
throw new Error(
'Instance of Use Cases library required when instantiating /req REST Controller.'
)
}
// Bind 'this' object to all subfunctions
this.queryEvents = this.queryEvents.bind(this)
this.createSubscription = this.createSubscription.bind(this)
this.closeSubscription = this.closeSubscription.bind(this)
this.handleError = this.handleError.bind(this)
}
/**
* @api {get} /req/:subId Query events (stateless)
* @apiPermission public
* @apiName QueryEvents
* @apiGroup Request
*
* @apiDescription Query events from the relay with filters. Returns events immediately. Maps to: ["REQ", <sub_id>, <filters>]
*
* @apiParam {String} subId Subscription ID (unique identifier)
* @apiParam {String} filters JSON-encoded filters object (query parameter)
*
* @apiExample {curl} Example usage:
* curl -X GET "http://localhost:3000/req/sub1?filters=[{\"kinds\":[1],\"limit\":10}]"
*
* @apiSuccess {Array} events Array of Nostr events
*
* @apiError {String} error Error message
*/
async queryEvents (req, res) {
try {
const { subId } = req.params
let filters = req.query.filters
if (!subId) {
return res.status(400).json({
error: 'Subscription ID is required'
})
}
// Parse filters from query string
if (typeof filters === 'string') {
try {
filters = JSON.parse(filters)
} catch (err) {
return res.status(400).json({
error: 'Invalid filters JSON'
})
}
} else if (!filters) {
// If no filters provided, accept filters from query params
filters = {}
if (req.query.kinds) {
filters.kinds = JSON.parse(req.query.kinds)
}
if (req.query.authors) {
filters.authors = JSON.parse(req.query.authors)
}
if (req.query.ids) {
filters.ids = JSON.parse(req.query.ids)
}
if (req.query.limit) {
filters.limit = parseInt(req.query.limit)
}
if (req.query.since) {
filters.since = parseInt(req.query.since)
}
if (req.query.until) {
filters.until = parseInt(req.query.until)
}
}
// Ensure filters is an array (Nostr protocol expects array of filters)
const filtersArray = Array.isArray(filters) ? filters : [filters]
const events = await this.useCases.queryEvents.execute(filtersArray, subId)
return res.status(200).json(events)
} catch (err) {
return this.handleError(err, req, res)
}
}
/**
* @api {post} /req/:subId Create subscription (SSE)
* @apiPermission public
* @apiName CreateSubscription
* @apiGroup Request
*
* @apiDescription Create a subscription for Server-Sent Events. Maps to: ["REQ", <sub_id>, <filters>]
*
* @apiParam {String} subId Subscription ID (unique identifier)
* @apiParam {Object} filters Filters object in request body
*
* @apiExample {json} Example usage:
* {
* "kinds": [1],
* "authors": ["2c7e76c0f8dc1dca9d0197c7d19be580a8d074ccada6a2f6ebe056ae41092e9"]
* }
*
* @apiSuccess {String} message Success message
*
* @apiError {String} error Error message
*/
async createSubscription (req, res) {
try {
const { subId } = req.params
const filters = req.body
if (!subId) {
return res.status(400).json({
error: 'Subscription ID is required'
})
}
if (!filters || (typeof filters === 'object' && Object.keys(filters).length === 0)) {
return res.status(400).json({
error: 'Filters are required'
})
}
// Ensure filters is an array
const filtersArray = Array.isArray(filters) ? filters : [filters]
// Set up Server-Sent Events
res.setHeader('Content-Type', 'text/event-stream')
res.setHeader('Cache-Control', 'no-cache')
res.setHeader('Connection', 'keep-alive')
res.setHeader('X-Accel-Buffering', 'no') // Disable buffering in nginx
// Track if response is still writable
let isResponseWritable = true
// Helper function to safely write to SSE stream
const safeWrite = (data) => {
if (!isResponseWritable) {
return false
}
try {
if (!res.writable || res.destroyed || res.closed) {
isResponseWritable = false
return false
}
return res.write(data)
} catch (err) {
wlogger.warn(`Error writing to SSE stream for subscription ${subId}:`, err.message)
isResponseWritable = false
return false
}
}
// Handle response stream errors
res.on('error', (err) => {
wlogger.warn(`Response stream error for subscription ${subId}:`, err.message)
isResponseWritable = false
// Clean up subscription on stream error
this.useCases.manageSubscription.closeSubscription(subId).catch(closeErr => {
wlogger.error('Error closing subscription on stream error:', closeErr)
})
})
// Send initial connection message
if (!safeWrite(`data: ${JSON.stringify({ type: 'connected', subscriptionId: subId })}\n\n`)) {
wlogger.warn(`Failed to send initial connection message for subscription ${subId}`)
return res.status(500).json({ error: 'Failed to establish SSE connection' })
}
// Handle events
const onEvent = (event) => {
if (!safeWrite(`data: ${JSON.stringify({ type: 'event', data: event })}\n\n`)) {
wlogger.debug(`Cannot write event to SSE stream for subscription ${subId} - connection may be closed`)
}
}
// Handle EOSE
const onEose = () => {
if (!safeWrite(`data: ${JSON.stringify({ type: 'eose' })}\n\n`)) {
wlogger.debug(`Cannot write EOSE to SSE stream for subscription ${subId} - connection may be closed`)
}
}
// Handle CLOSED
const onClosed = (message) => {
if (safeWrite(`data: ${JSON.stringify({ type: 'closed', message })}\n\n`)) {
try {
if (!res.destroyed && !res.closed) {
res.end()
}
} catch (err) {
wlogger.warn(`Error ending SSE stream for subscription ${subId}:`, err.message)
}
}
isResponseWritable = false
}
// Create subscription
await this.useCases.manageSubscription.createSubscription(
subId,
filtersArray,
onEvent,
onEose,
onClosed
)
// Handle client disconnect
req.on('close', () => {
wlogger.info(`Client disconnected from subscription ${subId}`)
isResponseWritable = false
this.useCases.manageSubscription.closeSubscription(subId).catch(err => {
wlogger.error('Error closing subscription on disconnect:', err)
})
})
// Handle response finish
res.on('finish', () => {
isResponseWritable = false
})
} catch (err) {
return this.handleError(err, req, res)
}
}
/**
* @api {put} /req/:subId Create subscription (SSE) - alternative method
* @apiPermission public
* @apiName CreateSubscriptionPut
* @apiGroup Request
*
* @apiDescription Same as POST /req/:subId - create a subscription for Server-Sent Events
*/
async createSubscriptionPut (req, res) {
return this.createSubscription(req, res)
}
/**
* @api {delete} /req/:subId Close subscription
* @apiPermission public
* @apiName CloseSubscription
* @apiGroup Request
*
* @apiDescription Close an existing subscription. Maps to: ["CLOSE", <sub_id>]
*
* @apiParam {String} subId Subscription ID to close
*
* @apiSuccess {String} message Success message
*
* @apiError {String} error Error message
*/
async closeSubscription (req, res) {
try {
const { subId } = req.params
if (!subId) {
return res.status(400).json({
error: 'Subscription ID is required'
})
}
await this.useCases.manageSubscription.closeSubscription(subId)
return res.status(200).json({
message: `Subscription ${subId} closed successfully`
})
} catch (err) {
return this.handleError(err, req, res)
}
}
handleError (err, req, res) {
wlogger.error('Error in ReqRESTController:', err)
return res.status(500).json({
error: err.message || 'Internal server error'
})
}
}
export default ReqRESTControllerLib
-75
View File
@@ -1,75 +0,0 @@
/*
REST API library for the /req route.
*/
// Public npm libraries.
import express from 'express'
// Local libraries.
import ReqRESTControllerLib from './controller.js'
class ReqRouter {
constructor (localConfig = {}) {
// Dependency Injection.
this.adapters = localConfig.adapters
if (!this.adapters) {
throw new Error(
'Instance of Adapters library required when instantiating Req REST Controller.'
)
}
this.useCases = localConfig.useCases
if (!this.useCases) {
throw new Error(
'Instance of Use Cases library required when instantiating Req REST Controller.'
)
}
const dependencies = {
adapters: this.adapters,
useCases: this.useCases
}
// Encapsulate dependencies.
this.reqRESTController = new ReqRESTControllerLib(dependencies)
// Instantiate the router and set the base route.
this.router = express.Router()
}
attach (app) {
if (!app) {
throw new Error(
'Must pass app object when attaching REST API controllers.'
)
}
// Define the routes and attach the controller.
// Handle empty subId case first
this.router.get('/', (req, res) => {
res.status(400).json({
error: 'Subscription ID is required'
})
})
this.router.post('/', (req, res) => {
res.status(400).json({
error: 'Subscription ID is required'
})
})
this.router.delete('/', (req, res) => {
res.status(400).json({
error: 'Subscription ID is required'
})
})
// Routes with subId parameter
this.router.get('/:subId', this.reqRESTController.queryEvents)
this.router.post('/:subId', this.reqRESTController.createSubscription)
this.router.put('/:subId', this.reqRESTController.createSubscriptionPut)
this.router.delete('/:subId', this.reqRESTController.closeSubscription)
// Attach the Controller routes to the Express app.
app.use('/req', this.router)
}
}
export default ReqRouter
@@ -0,0 +1,134 @@
/*
Use cases for interacting with the BCH full node blockchain RPC interface.
*/
import wlogger from '../adapters/wlogger.js'
class BlockchainUseCases {
constructor (localConfig = {}) {
this.adapters = localConfig.adapters
if (!this.adapters) {
throw new Error('Adapters instance required when instantiating Blockchain use cases.')
}
this.fullNode = this.adapters.fullNode
if (!this.fullNode) {
throw new Error('Full node adapter required when instantiating Blockchain use cases.')
}
}
async getBestBlockHash () {
return this.fullNode.call('getbestblockhash')
}
async getBlockchainInfo () {
return this.fullNode.call('getblockchaininfo')
}
async getBlockCount () {
return this.fullNode.call('getblockcount')
}
async getBlockHeader ({ hash, verbose = false }) {
return this.fullNode.call('getblockheader', [hash, verbose])
}
async getBlockHeaders ({ hashes, verbose = false }) {
try {
const promises = hashes.map(hash =>
this.fullNode.call('getblockheader', [hash, verbose], `getblockheader-${hash}`)
)
return await Promise.all(promises)
} catch (err) {
wlogger.error('Error in BlockchainUseCases.getBlockHeaders()', err)
throw err
}
}
async getChainTips () {
return this.fullNode.call('getchaintips')
}
async getDifficulty () {
return this.fullNode.call('getdifficulty')
}
async getMempoolEntry ({ txid }) {
return this.fullNode.call('getmempoolentry', [txid])
}
async getMempoolEntries ({ txids }) {
try {
const promises = txids.map(txid =>
this.fullNode.call('getmempoolentry', [txid], `getmempoolentry-${txid}`)
)
return await Promise.all(promises)
} catch (err) {
wlogger.error('Error in BlockchainUseCases.getMempoolEntries()', err)
throw err
}
}
async getMempoolAncestors ({ txid, verbose = false }) {
return this.fullNode.call('getmempoolancestors', [txid, verbose])
}
async getMempoolInfo () {
return this.fullNode.call('getmempoolinfo')
}
async getRawMempool ({ verbose = false }) {
return this.fullNode.call('getrawmempool', [verbose])
}
async getTxOut ({ txid, n, includeMempool }) {
return this.fullNode.call('gettxout', [txid, n, includeMempool])
}
async getTxOutProof ({ txid }) {
return this.fullNode.call('gettxoutproof', [[txid]])
}
async getTxOutProofs ({ txids }) {
try {
const promises = txids.map(txid =>
this.fullNode.call('gettxoutproof', [[txid]], `gettxoutproof-${txid}`)
)
return await Promise.all(promises)
} catch (err) {
wlogger.error('Error in BlockchainUseCases.getTxOutProofs()', err)
throw err
}
}
async verifyTxOutProof ({ proof }) {
return this.fullNode.call('verifytxoutproof', [proof])
}
async verifyTxOutProofs ({ proofs }) {
try {
const promises = proofs.map(proof =>
this.fullNode.call('verifytxoutproof', [proof], `verifytxoutproof-${proof.slice(0, 16)}`)
)
return await Promise.all(promises)
} catch (err) {
wlogger.error('Error in BlockchainUseCases.verifyTxOutProofs()', err)
throw err
}
}
async getBlock ({ blockhash, verbosity }) {
return this.fullNode.call('getblock', [blockhash, verbosity])
}
async getBlockHash ({ height }) {
return this.fullNode.call('getblockhash', [height])
}
}
export default BlockchainUseCases
@@ -0,0 +1,24 @@
/*
Use cases for interacting with the BCH full node control RPC interface.
*/
class ControlUseCases {
constructor (localConfig = {}) {
this.adapters = localConfig.adapters
if (!this.adapters) {
throw new Error('Adapters instance required when instantiating Control use cases.')
}
this.fullNode = this.adapters.fullNode
if (!this.fullNode) {
throw new Error('Full node adapter required when instantiating Control use cases.')
}
}
async getNetworkInfo () {
return this.fullNode.call('getnetworkinfo')
}
}
export default ControlUseCases
@@ -0,0 +1,24 @@
/*
Use cases for interacting with the BCH full node double-spend proof RPC interface.
*/
class DSProofUseCases {
constructor (localConfig = {}) {
this.adapters = localConfig.adapters
if (!this.adapters) {
throw new Error('Adapters instance required when instantiating DSProof use cases.')
}
this.fullNode = this.adapters.fullNode
if (!this.fullNode) {
throw new Error('Full node adapter required when instantiating DSProof use cases.')
}
}
async getDSProof ({ txid, verbose }) {
return this.fullNode.call('getdsproof', [txid, verbose])
}
}
export default DSProofUseCases
+6 -6
View File
@@ -5,9 +5,9 @@
*/
// Local libraries
// import PublishEventUseCase from './publish-event.js'
// import QueryEventsUseCase from './query-events.js'
// import ManageSubscriptionUseCase from './manage-subscription.js'
import BlockchainUseCases from './full-node-blockchain-use-cases.js'
import ControlUseCases from './full-node-control-use-cases.js'
import DSProofUseCases from './full-node-dsproof-use-cases.js'
class UseCases {
constructor (localConfig = {}) {
@@ -18,9 +18,9 @@ class UseCases {
)
}
// this.publishEvent = new PublishEventUseCase({ adapters: this.adapters })
// this.queryEvents = new QueryEventsUseCase({ adapters: this.adapters })
// this.manageSubscription = new ManageSubscriptionUseCase({ adapters: this.adapters })
this.blockchain = new BlockchainUseCases({ adapters: this.adapters })
this.control = new ControlUseCases({ adapters: this.adapters })
this.dsproof = new DSProofUseCases({ adapters: this.adapters })
}
// Run any startup Use Cases at the start of the app.
-216
View File
@@ -1,216 +0,0 @@
/*
Use case: Manage subscriptions for Server-Sent Events (SSE).
This encapsulates the business logic for creating and managing subscriptions.
*/
import wlogger from '../adapters/wlogger.js'
class ManageSubscriptionUseCase {
constructor (localConfig = {}) {
this.adapters = localConfig.adapters
if (!this.adapters) {
throw new Error('Adapters instance required')
}
if (!this.adapters.nostrRelays || !Array.isArray(this.adapters.nostrRelays) || this.adapters.nostrRelays.length === 0) {
throw new Error('NostrRelay adapters array required')
}
// Map subscriptionId to { relaySubscriptions: Map<relayIndex, subscriptionId>, handlers, seenEventIds }
this.activeSubscriptions = new Map()
}
/**
* Create a subscription for SSE streaming across all relays
* @param {string} subscriptionId - Unique subscription ID
* @param {Array} filters - Array of filter objects
* @param {Function} onEvent - Callback for events
* @param {Function} onEose - Callback for EOSE
* @param {Function} onClosed - Callback for CLOSED
* @returns {Promise<void>}
*/
async createSubscription (subscriptionId, filters, onEvent, onEose, onClosed) {
try {
if (this.activeSubscriptions.has(subscriptionId)) {
throw new Error(`Subscription ${subscriptionId} already exists`)
}
wlogger.info(`Creating subscription ${subscriptionId} across ${this.adapters.nostrRelays.length} relay(s)`)
// Track seen event IDs to de-duplicate across relays
const seenEventIds = new Set()
// Track EOSE and CLOSED status per relay
const relayStatuses = this.adapters.nostrRelays.map(() => ({
eoseReceived: false,
closedReceived: false
}))
// Create unified handlers that merge events from all relays
const handlers = {
onEvent: (event) => {
// De-duplicate events by ID across all relays
if (event && event.id && !seenEventIds.has(event.id)) {
seenEventIds.add(event.id)
if (onEvent) {
onEvent(event)
}
}
},
onEose: () => {
// Call onEose only once when all relays have sent EOSE
// This is called from the per-relay handler only when all relays have EOSE
if (onEose) {
onEose()
}
},
onClosed: (message) => {
if (onClosed) {
onClosed(message)
}
// Clean up subscription if any relay closes it
const subscriptionInfo = this.activeSubscriptions.get(subscriptionId)
if (subscriptionInfo && subscriptionInfo.eoseTimeoutId) {
clearTimeout(subscriptionInfo.eoseTimeoutId)
}
this.activeSubscriptions.delete(subscriptionId)
}
}
// Create subscription per relay with unique subscription IDs
const relaySubscriptions = new Map()
const subscriptionPromises = this.adapters.nostrRelays.map(async (relay, index) => {
const relaySubscriptionId = `${subscriptionId}-relay-${index}`
relaySubscriptions.set(index, relaySubscriptionId)
// Create per-relay handlers that update shared state
const relayHandlers = {
onEvent: (event) => {
handlers.onEvent(event)
},
onEose: () => {
relayStatuses[index].eoseReceived = true
// Check if all relays have sent EOSE
if (relayStatuses.every(s => s.eoseReceived)) {
// Clear the timeout since we got EOSE from all relays
const subscriptionInfo = this.activeSubscriptions.get(subscriptionId)
if (subscriptionInfo && subscriptionInfo.eoseTimeoutId) {
clearTimeout(subscriptionInfo.eoseTimeoutId)
subscriptionInfo.eoseTimeoutId = null
}
handlers.onEose()
}
},
onClosed: (message) => {
relayStatuses[index].closedReceived = true
handlers.onClosed(message)
}
}
await relay.sendReq(relaySubscriptionId, filters, relayHandlers)
})
// Store subscription info
this.activeSubscriptions.set(subscriptionId, {
relaySubscriptions,
handlers,
seenEventIds,
relayStatuses,
eoseTimeoutId: null
})
// Subscribe to all relays concurrently
const results = await Promise.allSettled(subscriptionPromises)
// Check if any relay subscription failed and clean up if so
const hasFailures = results.some(result => result.status === 'rejected')
if (hasFailures) {
const subscriptionInfo = this.activeSubscriptions.get(subscriptionId)
if (subscriptionInfo && subscriptionInfo.eoseTimeoutId) {
clearTimeout(subscriptionInfo.eoseTimeoutId)
}
this.activeSubscriptions.delete(subscriptionId)
const errors = results
.filter(result => result.status === 'rejected')
.map(result => result.reason)
throw new Error(`Failed to create subscription on some relays: ${errors.map(e => e.message).join(', ')}`)
}
// Set up EOSE timeout fallback - if not all relays send EOSE within 10 seconds, call onEose anyway
const subscriptionInfo = this.activeSubscriptions.get(subscriptionId)
const EOSE_TIMEOUT_MS = 10000 // 10 seconds
subscriptionInfo.eoseTimeoutId = setTimeout(() => {
// Check if subscription still exists and if all relays have sent EOSE
if (this.activeSubscriptions.has(subscriptionId)) {
const currentInfo = this.activeSubscriptions.get(subscriptionId)
const allEoseReceived = currentInfo.relayStatuses.every(s => s.eoseReceived)
if (!allEoseReceived) {
wlogger.warn(`EOSE timeout reached for subscription ${subscriptionId} - calling onEose callback anyway`)
if (handlers.onEose) {
handlers.onEose()
}
}
}
}, EOSE_TIMEOUT_MS)
} catch (err) {
wlogger.error('Error creating subscription:', err)
const subscriptionInfo = this.activeSubscriptions.get(subscriptionId)
if (subscriptionInfo && subscriptionInfo.eoseTimeoutId) {
clearTimeout(subscriptionInfo.eoseTimeoutId)
}
this.activeSubscriptions.delete(subscriptionId)
throw err
}
}
/**
* Close a subscription across all relays
* @param {string} subscriptionId - Subscription ID to close
* @returns {Promise<void>}
*/
async closeSubscription (subscriptionId) {
try {
if (!this.activeSubscriptions.has(subscriptionId)) {
// Subscription doesn't exist - already closed, treat as success (idempotent)
wlogger.info(`Subscription ${subscriptionId} already closed or does not exist`)
return
}
wlogger.info(`Closing subscription ${subscriptionId} across all relays`)
const subscriptionInfo = this.activeSubscriptions.get(subscriptionId)
const { relaySubscriptions } = subscriptionInfo
// Clear EOSE timeout if it exists
if (subscriptionInfo.eoseTimeoutId) {
clearTimeout(subscriptionInfo.eoseTimeoutId)
}
// Close subscriptions on all relays concurrently
const closePromises = Array.from(relaySubscriptions.entries()).map(async ([relayIndex, relaySubscriptionId]) => {
try {
await this.adapters.nostrRelays[relayIndex].sendClose(relaySubscriptionId)
} catch (err) {
wlogger.warn(`Error closing subscription on relay ${relayIndex}:`, err.message)
}
})
await Promise.allSettled(closePromises)
this.activeSubscriptions.delete(subscriptionId)
} catch (err) {
wlogger.error('Error closing subscription:', err)
// Clean up even if there's an error
this.activeSubscriptions.delete(subscriptionId)
throw err
}
}
/**
* Check if a subscription exists
* @param {string} subscriptionId - Subscription ID
* @returns {boolean}
*/
hasSubscription (subscriptionId) {
return this.activeSubscriptions.has(subscriptionId)
}
}
export default ManageSubscriptionUseCase
-87
View File
@@ -1,87 +0,0 @@
/*
Use case: Publish a Nostr event to the relay.
This encapsulates the business logic for publishing events.
*/
import Event from '../entities/event.js'
import wlogger from '../adapters/wlogger.js'
class PublishEventUseCase {
constructor (localConfig = {}) {
this.adapters = localConfig.adapters
if (!this.adapters) {
throw new Error('Adapters instance required')
}
if (!this.adapters.nostrRelays || !Array.isArray(this.adapters.nostrRelays) || this.adapters.nostrRelays.length === 0) {
throw new Error('NostrRelay adapters array required')
}
}
/**
* Publish an event to all Nostr relays (broadcast)
* @param {Object} eventData - Event data (must be signed)
* @returns {Promise<Object>} Result with accepted status, message, and relay results
*/
async execute (eventData) {
try {
// Create event entity
const event = new Event(eventData)
// Validate event
if (!event.isValid()) {
throw new Error('Invalid event structure')
}
wlogger.info(`Publishing event ${event.id} (kind ${event.kind}) to ${this.adapters.nostrRelays.length} relay(s)`)
// Broadcast event to all relays
const results = await this.adapters.broadcastEvent(event.toJSON())
// Aggregate results
const acceptedRelays = results.filter(r => r.accepted)
const rejectedRelays = results.filter(r => !r.accepted)
const failedRelays = results.filter(r => !r.success)
const atLeastOneAccepted = acceptedRelays.length > 0
const allAccepted = acceptedRelays.length === results.length && failedRelays.length === 0
// Build aggregated message
let message = ''
if (allAccepted) {
message = `Accepted by all ${acceptedRelays.length} relay(s)`
} else if (atLeastOneAccepted) {
message = `Accepted by ${acceptedRelays.length}/${results.length} relay(s)`
if (rejectedRelays.length > 0) {
message += `, rejected by ${rejectedRelays.length} relay(s)`
}
if (failedRelays.length > 0) {
message += `, failed to reach ${failedRelays.length} relay(s)`
}
} else {
message = `Rejected or failed by all ${results.length} relay(s)`
if (rejectedRelays.length > 0) {
const rejectionMessages = rejectedRelays.map(r => r.message).filter(m => m).join('; ')
if (rejectionMessages) {
message += `: ${rejectionMessages}`
}
}
}
wlogger.info(`Event ${event.id} ${atLeastOneAccepted ? 'accepted' : 'rejected/failed'}: ${message}`)
return {
accepted: atLeastOneAccepted,
message,
eventId: event.id,
relayResults: results,
acceptedCount: acceptedRelays.length,
totalRelays: results.length
}
} catch (err) {
wlogger.error('Error in PublishEventUseCase:', err)
throw err
}
}
}
export default PublishEventUseCase
-41
View File
@@ -1,41 +0,0 @@
/*
Use case: Query events from the relay (stateless).
This encapsulates the business logic for querying events.
*/
import wlogger from '../adapters/wlogger.js'
class QueryEventsUseCase {
constructor (localConfig = {}) {
this.adapters = localConfig.adapters
if (!this.adapters) {
throw new Error('Adapters instance required')
}
if (!this.adapters.nostrRelays || !Array.isArray(this.adapters.nostrRelays) || this.adapters.nostrRelays.length === 0) {
throw new Error('NostrRelay adapters array required')
}
}
/**
* Query events with filters from all relays (stateless - returns immediately)
* @param {Array} filters - Array of filter objects
* @param {string} subscriptionId - Unique subscription ID
* @returns {Promise<Array>} Array of events (merged and de-duplicated from all relays)
*/
async execute (filters, subscriptionId) {
try {
wlogger.info(`Querying events with subscription ${subscriptionId} from ${this.adapters.nostrRelays.length} relay(s)`)
// Query all relays concurrently and merge results
const events = await this.adapters.queryAllRelays(filters, subscriptionId)
wlogger.info(`Query returned ${events.length} events from ${this.adapters.nostrRelays.length} relay(s)`)
return events
} catch (err) {
wlogger.error('Error in QueryEventsUseCase:', err)
throw err
}
}
}
export default QueryEventsUseCase
+122
View File
@@ -0,0 +1,122 @@
/*
Unit tests for FullNodeRPCAdapter.
*/
import { assert } from 'chai'
import sinon from 'sinon'
import axios from 'axios'
import FullNodeRPCAdapter from '../../../src/adapters/full-node-rpc.js'
describe('#full-node-rpc.js', () => {
let sandbox
let axiosCreateStub
let mockAxiosInstance
const baseConfig = {
fullNode: {
rpcBaseUrl: 'http://127.0.0.1:8332',
rpcUsername: 'user',
rpcPassword: 'pass',
rpcTimeoutMs: 1000,
rpcRequestIdPrefix: 'test'
}
}
beforeEach(() => {
sandbox = sinon.createSandbox()
mockAxiosInstance = {
post: sandbox.stub()
}
axiosCreateStub = sandbox.stub(axios, 'create').returns(mockAxiosInstance)
})
afterEach(() => {
sandbox.restore()
})
describe('#constructor()', () => {
it('should throw if full node config is missing', () => {
assert.throws(() => {
// eslint-disable-next-line no-new
new FullNodeRPCAdapter({ config: {} })
}, /Full node RPC configuration is required/)
})
it('should create axios client with provided configuration', () => {
// eslint-disable-next-line no-new
new FullNodeRPCAdapter({ config: baseConfig })
assert.isTrue(axiosCreateStub.calledOnce)
const options = axiosCreateStub.getCall(0).args[0]
assert.equal(options.baseURL, baseConfig.fullNode.rpcBaseUrl)
assert.equal(options.timeout, baseConfig.fullNode.rpcTimeoutMs)
assert.deepEqual(options.auth, {
username: baseConfig.fullNode.rpcUsername,
password: baseConfig.fullNode.rpcPassword
})
})
})
describe('#call()', () => {
it('should call RPC method and return result', async () => {
mockAxiosInstance.post.resolves({ data: { result: 'hash' } })
const uut = new FullNodeRPCAdapter({ config: baseConfig })
const result = await uut.call('getbestblockhash', [])
assert.equal(result, 'hash')
assert.isTrue(mockAxiosInstance.post.calledOnce)
const [, payload] = mockAxiosInstance.post.getCall(0).args
assert.deepEqual(payload, {
jsonrpc: '1.0',
id: 'test-getbestblockhash',
method: 'getbestblockhash',
params: []
})
})
it('should use custom request id when provided', async () => {
mockAxiosInstance.post.resolves({ data: { result: 123 } })
const uut = new FullNodeRPCAdapter({ config: baseConfig })
await uut.call('getblockcount', [], 'custom-id')
const [, payload] = mockAxiosInstance.post.getCall(0).args
assert.equal(payload.id, 'custom-id')
})
it('should throw formatted error when RPC returns error', async () => {
mockAxiosInstance.post.resolves({
data: {
error: { message: 'RPC error' }
}
})
const uut = new FullNodeRPCAdapter({ config: baseConfig })
try {
await uut.call('failing', [])
assert.fail('Unexpected success')
} catch (err) {
assert.equal(err.message, 'RPC error')
assert.equal(err.status, 400)
}
})
it('should translate network errors into 503 status', async () => {
mockAxiosInstance.post.rejects(new Error('ENOTFOUND fullnode'))
const uut = new FullNodeRPCAdapter({ config: baseConfig })
try {
await uut.call('getblockcount', [])
assert.fail('Unexpected success')
} catch (err) {
assert.equal(
err.message,
'Network error: Could not communicate with full node or other external service.'
)
assert.equal(err.status, 503)
}
})
})
})
-304
View File
@@ -1,304 +0,0 @@
/*
Unit tests for NostrRelayAdapter.
*/
/*
// npm libraries
import { assert } from 'chai'
import sinon from 'sinon'
// Mocking data libraries
import {
mockKind1Event,
validEventId
} from '../mocks/event-mocks.js'
import {
mockOkAccepted,
mockEventMessage,
mockEoseMessage,
mockClosedMessage
} from '../mocks/nostr-relay-mocks.js'
// Unit under test
// Note: WebSocket mocking for ES modules is complex. These tests focus on
// testing the adapter's logic that can be tested without full WebSocket mocking.
import NostrRelayAdapter from '../../../src/adapters/nostr-relay.js'
describe('#nostr-relay.js', () => {
let sandbox
let uut
beforeEach(() => {
sandbox = sinon.createSandbox()
uut = new NostrRelayAdapter({
relayUrl: 'wss://test-relay.example.com'
})
})
afterEach(() => {
sandbox.restore()
})
describe('#connect()', () => {
it('should return immediately if already connected', async () => {
// Manually set connection state
uut.isConnected = true
uut.ws = { close: sandbox.stub() }
await uut.connect()
// Should not create new connection
assert.isTrue(uut.isConnected)
})
// Note: Full WebSocket connection testing requires integration tests
// due to ES module import limitations
})
describe('#sendEvent()', () => {
it('should queue message when disconnected', async () => {
uut.isConnected = false
uut.ws = null
// Mock connect to resolve immediately
uut.connect = sandbox.stub().resolves()
// Start sending (will queue)
uut.sendEvent(mockKind1Event).catch(() => {
// Expected to fail or timeout without real WebSocket
})
// Should queue message and attempt connection
// Wait a bit for async operations
await new Promise(resolve => setTimeout(resolve, 10))
assert.isTrue(uut.pendingMessages.length > 0 || uut.connect.called)
})
it('should set up event resolver', async () => {
uut.isConnected = true
uut.ws = { send: sandbox.stub() }
// Mock sendMessage to resolve immediately
uut.sendMessage = sandbox.stub().resolves()
// Start sending
const sendPromise = uut.sendEvent(mockKind1Event).catch(() => {
// Expected without real WebSocket response
})
// Wait a tick for Promise constructor to run
await new Promise(resolve => setImmediate(resolve))
// Verify resolver was set up
assert.isTrue(uut.eventResolvers.has(mockKind1Event.id))
// Clean up
uut.eventResolvers.delete(mockKind1Event.id)
// Prevent timeout error
sendPromise.catch(() => {})
})
// Note: Full sendEvent testing with WebSocket responses requires integration tests
})
describe('#sendReq()', () => {
it('should store handlers for subscription', async () => {
uut.isConnected = true
uut.ws = { send: sandbox.stub() }
uut.connect = sandbox.stub().resolves()
const subscriptionId = 'test-sub-123'
const filters = [{ kinds: [1] }]
const handlers = {
onEvent: sandbox.stub(),
onEose: sandbox.stub(),
onClosed: sandbox.stub()
}
await uut.sendReq(subscriptionId, filters, handlers)
// Assert handlers were stored
assert.isTrue(uut.subscriptionHandlers.has(subscriptionId))
assert.deepEqual(uut.subscriptionHandlers.get(subscriptionId), handlers)
})
it('should connect before sending if disconnected', async () => {
uut.isConnected = false
uut.connect = sandbox.stub().resolves()
const subscriptionId = 'test-sub-123'
const filters = [{ kinds: [1] }]
const handlers = {}
await uut.sendReq(subscriptionId, filters, handlers)
assert.isTrue(uut.connect.called)
})
})
describe('#sendClose()', () => {
it('should clean up handlers for subscription', async () => {
uut.isConnected = true
uut.ws = { send: sandbox.stub() }
const subscriptionId = 'test-sub-123'
uut.subscriptionHandlers.set(subscriptionId, {})
uut.messageHandlers.set(subscriptionId, {})
await uut.sendClose(subscriptionId)
// Assert handlers were cleaned up
assert.isFalse(uut.subscriptionHandlers.has(subscriptionId))
assert.isFalse(uut.messageHandlers.has(subscriptionId))
})
})
describe('#handleMessage()', () => {
it('should handle EVENT message', () => {
// Use the subscription ID from the mock message
const subscriptionId = 'subscription-id-123'
const onEventHandler = sandbox.stub()
uut.subscriptionHandlers.set(subscriptionId, {
onEvent: onEventHandler
})
const message = mockEventMessage
uut.handleMessage(message)
assert.isTrue(onEventHandler.calledOnce)
assert.deepEqual(onEventHandler.getCall(0).args[0], mockKind1Event)
})
it('should handle EOSE message', () => {
// Use the subscription ID from the mock message
const subscriptionId = 'subscription-id-123'
const onEoseHandler = sandbox.stub()
uut.subscriptionHandlers.set(subscriptionId, {
onEose: onEoseHandler
})
const message = mockEoseMessage
uut.handleMessage(message)
assert.isTrue(onEoseHandler.calledOnce)
})
it('should handle CLOSED message', () => {
// Use the subscription ID from the mock message
const subscriptionId = 'subscription-id-123'
const onClosedHandler = sandbox.stub()
uut.subscriptionHandlers.set(subscriptionId, {
onClosed: onClosedHandler
})
const message = mockClosedMessage
uut.handleMessage(message)
assert.isTrue(onClosedHandler.calledOnce)
assert.equal(onClosedHandler.getCall(0).args[0], 'subscription closed')
})
it('should handle OK message', () => {
const eventId = validEventId
let resolver = null
uut.eventResolvers.set(eventId, (result) => {
resolver = result
})
const message = mockOkAccepted
uut.handleMessage(message)
assert.isNotNull(resolver)
assert.isTrue(resolver.accepted)
assert.isFalse(uut.eventResolvers.has(eventId))
})
it('should handle NOTICE message', () => {
const message = ['NOTICE', 'rate limited']
// Should not throw
uut.handleMessage(message)
})
it('should ignore invalid message format', () => {
const message = 'invalid'
// Should not throw
uut.handleMessage(message)
})
it('should ignore empty messages', () => {
const message = []
// Should not throw
uut.handleMessage(message)
})
})
describe('#disconnect()', () => {
it('should disconnect from relay', async () => {
const mockWs = { close: sandbox.stub() }
uut.isConnected = true
uut.ws = mockWs
await uut.disconnect()
assert.isTrue(mockWs.close.called)
assert.isFalse(uut.isConnected)
assert.isNull(uut.ws)
})
it('should handle disconnect when already disconnected', async () => {
uut.isConnected = false
uut.ws = null
await uut.disconnect()
assert.isFalse(uut.isConnected)
})
})
describe('#handleError()', () => {
it('should handle WebSocket errors', () => {
uut.isConnected = true
const error = new Error('WebSocket error')
uut.handleError(error)
assert.isFalse(uut.isConnected)
})
})
describe('#handleClose()', () => {
it('should attempt reconnection on close', async () => {
uut.isConnected = true
uut.reconnectAttempts = 0
uut.maxReconnectAttempts = 5
// Mock connect to avoid actual connection
uut.connect = sandbox.stub().resolves()
uut.handleClose()
// Wait for reconnection attempt
await new Promise(resolve => setTimeout(resolve, 110))
// Should attempt reconnection
assert.equal(uut.reconnectAttempts, 1)
})
it('should stop reconnecting after max attempts', async () => {
uut.isConnected = true
uut.reconnectAttempts = 5
uut.maxReconnectAttempts = 5
uut.connect = sandbox.stub().resolves()
uut.handleClose()
await new Promise(resolve => setTimeout(resolve, 110))
// Should not increment beyond max
assert.equal(uut.reconnectAttempts, 5)
})
})
})
*/
@@ -0,0 +1,215 @@
/*
Unit tests for BlockchainRESTController.
*/
import { assert } from 'chai'
import sinon from 'sinon'
import BlockchainRESTController from '../../../src/controllers/rest-api/full-node/blockchain/controller.js'
import {
createMockRequest,
createMockResponse
} from '../mocks/controller-mocks.js'
describe('#blockchain-controller.js', () => {
let sandbox
let mockUseCases
let mockAdapters
let uut
const createBlockchainUseCaseStubs = () => ({
getBestBlockHash: sandbox.stub().resolves('hash'),
getBlockchainInfo: sandbox.stub().resolves({}),
getBlockCount: sandbox.stub().resolves(123),
getBlockHeader: sandbox.stub().resolves({ header: true }),
getBlockHeaders: sandbox.stub().resolves(['header']),
getChainTips: sandbox.stub().resolves(['tip']),
getDifficulty: sandbox.stub().resolves(1),
getMempoolEntry: sandbox.stub().resolves({}),
getMempoolEntries: sandbox.stub().resolves([]),
getMempoolAncestors: sandbox.stub().resolves([]),
getMempoolInfo: sandbox.stub().resolves({ size: 1 }),
getRawMempool: sandbox.stub().resolves(['tx']),
getTxOut: sandbox.stub().resolves({ value: 1 }),
getTxOutProof: sandbox.stub().resolves('proof'),
getTxOutProofs: sandbox.stub().resolves(['proof']),
verifyTxOutProof: sandbox.stub().resolves(['txid']),
verifyTxOutProofs: sandbox.stub().resolves([['txid']]),
getBlock: sandbox.stub().resolves({ hash: 'abc' }),
getBlockHash: sandbox.stub().resolves('blockhash')
})
beforeEach(() => {
sandbox = sinon.createSandbox()
mockAdapters = {
fullNode: {
validateArraySize: sandbox.stub().returns(true)
}
}
mockUseCases = {
blockchain: createBlockchainUseCaseStubs()
}
uut = new BlockchainRESTController({
adapters: mockAdapters,
useCases: mockUseCases
})
})
afterEach(() => {
sandbox.restore()
})
describe('#constructor()', () => {
it('should require adapters', () => {
assert.throws(() => {
// eslint-disable-next-line no-new
new BlockchainRESTController({ useCases: mockUseCases })
}, /Adapters library required/)
})
it('should require blockchain use cases', () => {
assert.throws(() => {
// eslint-disable-next-line no-new
new BlockchainRESTController({ adapters: mockAdapters, useCases: {} })
}, /Blockchain use cases required/)
})
})
describe('#root()', () => {
it('should return service status', async () => {
const req = createMockRequest()
const res = createMockResponse()
await uut.root(req, res)
assert.equal(res.statusValue, 200)
assert.deepEqual(res.jsonData, { status: 'blockchain' })
})
})
describe('#getBestBlockHash()', () => {
it('should return hash on success', async () => {
const req = createMockRequest()
const res = createMockResponse()
await uut.getBestBlockHash(req, res)
assert.equal(res.statusValue, 200)
assert.equal(res.jsonData, 'hash')
assert.isTrue(mockUseCases.blockchain.getBestBlockHash.calledOnce)
})
it('should handle errors via handleError()', async () => {
const error = new Error('failure')
error.status = 422
mockUseCases.blockchain.getBestBlockHash.rejects(error)
const req = createMockRequest()
const res = createMockResponse()
await uut.getBestBlockHash(req, res)
assert.equal(res.statusValue, 422)
assert.deepEqual(res.jsonData, { error: 'failure' })
})
})
describe('#getBlockHeaderSingle()', () => {
it('should return 400 if hash is missing', async () => {
const req = createMockRequest()
const res = createMockResponse()
await uut.getBlockHeaderSingle(req, res)
assert.equal(res.statusValue, 400)
assert.property(res.jsonData, 'error')
})
it('should call use case with verbose flag', async () => {
const hash = 'a'.repeat(64)
const req = createMockRequest({
params: { hash },
query: { verbose: 'true' }
})
const res = createMockResponse()
await uut.getBlockHeaderSingle(req, res)
assert.equal(res.statusValue, 200)
assert.isTrue(
mockUseCases.blockchain.getBlockHeader.calledOnceWithExactly({
hash,
verbose: true
})
)
})
})
describe('#getBlockHeaderBulk()', () => {
it('should return error if hashes is not array', async () => {
const req = createMockRequest({
body: { hashes: 'not-an-array' },
locals: {}
})
const res = createMockResponse()
await uut.getBlockHeaderBulk(req, res)
assert.equal(res.statusValue, 400)
assert.include(res.jsonData.error, 'hashes needs to be an array')
})
it('should validate array size and call use case', async () => {
const hash = 'a'.repeat(64)
const req = createMockRequest({
body: { hashes: [hash], verbose: true },
locals: { proLimit: false }
})
const res = createMockResponse()
mockUseCases.blockchain.getBlockHeaders.resolves(['result'])
await uut.getBlockHeaderBulk(req, res)
assert.equal(res.statusValue, 200)
assert.deepEqual(res.jsonData, ['result'])
assert.isTrue(
mockAdapters.fullNode.validateArraySize.calledOnceWithExactly(1, { isProUser: false })
)
assert.isTrue(
mockUseCases.blockchain.getBlockHeaders.calledOnceWithExactly({
hashes: [hash],
verbose: true
})
)
})
it('should return error if array size invalid', async () => {
mockAdapters.fullNode.validateArraySize.returns(false)
const req = createMockRequest({
body: { hashes: ['a'.repeat(64)] },
locals: {}
})
const res = createMockResponse()
await uut.getBlockHeaderBulk(req, res)
assert.equal(res.statusValue, 400)
assert.equal(res.jsonData.error, 'Array too large.')
})
})
describe('#verifyTxOutProofBulk()', () => {
it('should flatten proof responses', async () => {
mockUseCases.blockchain.verifyTxOutProofs.resolves([['txid-a'], ['txid-b']])
const req = createMockRequest({
body: { proofs: ['proof-a', 'proof-b'] },
locals: {}
})
const res = createMockResponse()
await uut.verifyTxOutProofBulk(req, res)
assert.equal(res.statusValue, 200)
assert.deepEqual(res.jsonData, ['txid-a', 'txid-b'])
})
})
})
@@ -0,0 +1,88 @@
/*
Unit tests for ControlRESTController.
*/
import { assert } from 'chai'
import sinon from 'sinon'
import ControlRESTController from '../../../src/controllers/rest-api/full-node/control/controller.js'
import { createMockRequest, createMockResponse } from '../mocks/controller-mocks.js'
describe('#control-controller.js', () => {
let sandbox
let mockAdapters
let mockUseCases
let uut
beforeEach(() => {
sandbox = sinon.createSandbox()
mockAdapters = {}
mockUseCases = {
control: {
getNetworkInfo: sandbox.stub().resolves({ version: 1 })
}
}
uut = new ControlRESTController({
adapters: mockAdapters,
useCases: mockUseCases
})
})
afterEach(() => {
sandbox.restore()
})
describe('#constructor()', () => {
it('should require adapters', () => {
assert.throws(() => {
// eslint-disable-next-line no-new
new ControlRESTController({ useCases: mockUseCases })
}, /Adapters library required/)
})
it('should require control use cases', () => {
assert.throws(() => {
// eslint-disable-next-line no-new
new ControlRESTController({ adapters: mockAdapters, useCases: {} })
}, /Control use cases required/)
})
})
describe('#root()', () => {
it('should return control status', async () => {
const req = createMockRequest()
const res = createMockResponse()
await uut.root(req, res)
assert.equal(res.statusValue, 200)
assert.deepEqual(res.jsonData, { status: 'control' })
})
})
describe('#getNetworkInfo()', () => {
it('should return network info on success', async () => {
const req = createMockRequest()
const res = createMockResponse()
await uut.getNetworkInfo(req, res)
assert.equal(res.statusValue, 200)
assert.deepEqual(res.jsonData, { version: 1 })
})
it('should handle errors via handleError', async () => {
const error = new Error('failure')
error.status = 503
mockUseCases.control.getNetworkInfo.rejects(error)
const req = createMockRequest()
const res = createMockResponse()
await uut.getNetworkInfo(req, res)
assert.equal(res.statusValue, 503)
assert.deepEqual(res.jsonData, { error: 'failure' })
})
})
})
@@ -0,0 +1,117 @@
/*
Unit tests for DSProofRESTController.
*/
import { assert } from 'chai'
import sinon from 'sinon'
import DSProofRESTController from '../../../src/controllers/rest-api/full-node/dsproof/controller.js'
import { createMockRequest, createMockResponse } from '../mocks/controller-mocks.js'
describe('#dsproof-controller.js', () => {
let sandbox
let mockAdapters
let mockUseCases
let uut
beforeEach(() => {
sandbox = sinon.createSandbox()
mockAdapters = {}
mockUseCases = {
dsproof: {
getDSProof: sandbox.stub().resolves({ proof: true })
}
}
uut = new DSProofRESTController({
adapters: mockAdapters,
useCases: mockUseCases
})
})
afterEach(() => {
sandbox.restore()
})
describe('#constructor()', () => {
it('should require adapters', () => {
assert.throws(() => {
// eslint-disable-next-line no-new
new DSProofRESTController({ useCases: mockUseCases })
}, /Adapters library required/)
})
it('should require dsproof use cases', () => {
assert.throws(() => {
// eslint-disable-next-line no-new
new DSProofRESTController({ adapters: mockAdapters, useCases: {} })
}, /DSProof use cases required/)
})
})
describe('#root()', () => {
it('should return dsproof status', async () => {
const req = createMockRequest()
const res = createMockResponse()
await uut.root(req, res)
assert.equal(res.statusValue, 200)
assert.deepEqual(res.jsonData, { status: 'dsproof' })
})
})
describe('#getDSProof()', () => {
it('should validate txid presence', async () => {
const req = createMockRequest()
const res = createMockResponse()
await uut.getDSProof(req, res)
assert.equal(res.statusValue, 400)
assert.include(res.jsonData.error, 'txid can not be empty')
})
it('should validate txid length', async () => {
const req = createMockRequest({ params: { txid: 'abc' } })
const res = createMockResponse()
await uut.getDSProof(req, res)
assert.equal(res.statusValue, 400)
assert.include(res.jsonData.error, 'txid must be of length 64')
})
it('should call use case with derived verbose when valid', async () => {
const txid = 'a'.repeat(64)
const req = createMockRequest({
params: { txid },
query: { verbose: 'true' }
})
const res = createMockResponse()
await uut.getDSProof(req, res)
assert.equal(res.statusValue, 200)
assert.isTrue(mockUseCases.dsproof.getDSProof.calledOnceWithExactly({
txid,
verbose: 3
}))
assert.deepEqual(res.jsonData, { proof: true })
})
it('should handle errors via handleError', async () => {
const txid = 'a'.repeat(64)
const error = new Error('failure')
error.status = 422
mockUseCases.dsproof.getDSProof.rejects(error)
const req = createMockRequest({ params: { txid } })
const res = createMockResponse()
await uut.getDSProof(req, res)
assert.equal(res.statusValue, 422)
assert.deepEqual(res.jsonData, { error: 'failure' })
})
})
})
@@ -1,187 +0,0 @@
/*
Unit tests for EventRESTControllerLib.
*/
// npm libraries
import { assert } from 'chai'
import sinon from 'sinon'
// Mocking data libraries
import {
mockKind1Event,
mockKind0Event
} from '../mocks/event-mocks.js'
import {
createMockRequestWithBody,
createMockResponse
} from '../mocks/controller-mocks.js'
// Unit under test
import EventRESTControllerLib from '../../../src/controllers/rest-api/event/controller.js'
describe('#event-controller.js', () => {
let sandbox
let mockUseCases
let uut
beforeEach(() => {
sandbox = sinon.createSandbox()
// Create mock use cases
mockUseCases = {
publishEvent: {
execute: sandbox.stub()
}
}
uut = new EventRESTControllerLib({
adapters: {},
useCases: mockUseCases
})
})
afterEach(() => {
sandbox.restore()
})
describe('#publishEvent()', () => {
it('should successfully publish an event', async () => {
const req = createMockRequestWithBody(mockKind1Event)
const res = createMockResponse()
mockUseCases.publishEvent.execute.resolves({
accepted: true,
message: 'event saved',
eventId: mockKind1Event.id
})
await uut.publishEvent(req, res)
// Assert use case was called
assert.isTrue(mockUseCases.publishEvent.execute.calledOnce)
assert.deepEqual(mockUseCases.publishEvent.execute.getCall(0).args[0], mockKind1Event)
// Assert response
assert.equal(res.statusValue, 200)
assert.property(res.jsonData, 'accepted')
assert.isTrue(res.jsonData.accepted)
assert.equal(res.jsonData.eventId, mockKind1Event.id)
})
it('should return 400 when event is rejected', async () => {
const req = createMockRequestWithBody(mockKind1Event)
const res = createMockResponse()
mockUseCases.publishEvent.execute.resolves({
accepted: false,
message: 'duplicate: event already exists',
eventId: mockKind1Event.id
})
await uut.publishEvent(req, res)
// Assert response status is 400
assert.equal(res.statusValue, 400)
assert.property(res.jsonData, 'accepted')
assert.isFalse(res.jsonData.accepted)
})
it('should return 400 when event data is missing', async () => {
const req = createMockRequestWithBody(null)
const res = createMockResponse()
await uut.publishEvent(req, res)
// Assert use case was not called
assert.isFalse(mockUseCases.publishEvent.execute.called)
// Assert error response
assert.equal(res.statusValue, 400)
assert.property(res.jsonData, 'error')
assert.include(res.jsonData.error, 'Event data is required')
})
it('should handle use case errors', async () => {
const req = createMockRequestWithBody(mockKind1Event)
const res = createMockResponse()
mockUseCases.publishEvent.execute.rejects(new Error('Network error'))
await uut.publishEvent(req, res)
// Assert error response
assert.equal(res.statusValue, 500)
assert.property(res.jsonData, 'error')
assert.include(res.jsonData.error, 'Network error')
})
it('should return 400 for validation errors', async () => {
const req = createMockRequestWithBody(mockKind1Event)
const res = createMockResponse()
mockUseCases.publishEvent.execute.rejects(new Error('Invalid event structure'))
await uut.publishEvent(req, res)
// Assert validation error returns 400
assert.equal(res.statusValue, 400)
assert.property(res.jsonData, 'error')
assert.include(res.jsonData.error, 'Invalid event structure')
})
it('should handle errors with missing message', async () => {
const req = createMockRequestWithBody(mockKind1Event)
const res = createMockResponse()
const error = new Error()
error.message = undefined
mockUseCases.publishEvent.execute.rejects(error)
await uut.publishEvent(req, res)
// Assert error response with default message
assert.equal(res.statusValue, 500)
assert.property(res.jsonData, 'error')
assert.equal(res.jsonData.error, 'Internal server error')
})
it('should publish different event kinds', async () => {
const req = createMockRequestWithBody(mockKind0Event)
const res = createMockResponse()
mockUseCases.publishEvent.execute.resolves({
accepted: true,
message: 'event saved',
eventId: mockKind0Event.id
})
await uut.publishEvent(req, res)
assert.isTrue(mockUseCases.publishEvent.execute.calledOnce)
assert.equal(res.statusValue, 200)
assert.isTrue(res.jsonData.accepted)
})
})
describe('#constructor()', () => {
it('should require adapters instance', () => {
try {
// eslint-disable-next-line no-new
new EventRESTControllerLib({ useCases: mockUseCases })
assert.equal(true, false, 'unexpected result')
} catch (err) {
assert.include(err.message, 'Adapters library required')
}
})
it('should require useCases instance', () => {
try {
// eslint-disable-next-line no-new
new EventRESTControllerLib({ adapters: {} })
assert.equal(true, false, 'unexpected result')
} catch (err) {
assert.include(err.message, 'Use Cases library required')
}
})
})
})
@@ -1,344 +0,0 @@
/*
Unit tests for ReqRESTControllerLib.
*/
// npm libraries
import { assert } from 'chai'
import sinon from 'sinon'
// Mocking data libraries
import { mockEventsArray } from '../mocks/nostr-relay-mocks.js'
import {
createMockRequestWithParams,
createMockResponse
} from '../mocks/controller-mocks.js'
// Unit under test
import ReqRESTControllerLib from '../../../src/controllers/rest-api/req/controller.js'
describe('#req-controller.js', () => {
let sandbox
let mockUseCases
let uut
beforeEach(() => {
sandbox = sinon.createSandbox()
// Create mock use cases
mockUseCases = {
queryEvents: {
execute: sandbox.stub()
},
manageSubscription: {
createSubscription: sandbox.stub(),
closeSubscription: sandbox.stub()
}
}
uut = new ReqRESTControllerLib({
adapters: {},
useCases: mockUseCases
})
})
afterEach(() => {
sandbox.restore()
})
describe('#queryEvents()', () => {
it('should successfully query events with filters as JSON string', async () => {
const filters = [{ kinds: [1], limit: 10 }]
const filtersJson = JSON.stringify(filters)
const req = createMockRequestWithParams({ subId: 'test-sub-123' })
req.query = { filters: filtersJson }
const res = createMockResponse()
mockUseCases.queryEvents.execute.resolves(mockEventsArray)
await uut.queryEvents(req, res)
// Assert use case was called with parsed filters
assert.isTrue(mockUseCases.queryEvents.execute.calledOnce)
const executeArgs = mockUseCases.queryEvents.execute.getCall(0).args
assert.deepEqual(executeArgs[0], filters)
assert.equal(executeArgs[1], 'test-sub-123')
// Assert response
assert.equal(res.statusValue, 200)
assert.isArray(res.jsonData)
assert.equal(res.jsonData.length, mockEventsArray.length)
})
it('should successfully query events with individual query params', async () => {
const req = createMockRequestWithParams({ subId: 'test-sub-123' })
req.query = {
kinds: JSON.stringify([1]),
authors: JSON.stringify(['abc123']),
limit: '10'
}
const res = createMockResponse()
mockUseCases.queryEvents.execute.resolves(mockEventsArray)
await uut.queryEvents(req, res)
// Assert use case was called
assert.isTrue(mockUseCases.queryEvents.execute.calledOnce)
const executeArgs = mockUseCases.queryEvents.execute.getCall(0).args
assert.isArray(executeArgs[0])
assert.equal(executeArgs[0][0].kinds[0], 1)
assert.equal(executeArgs[0][0].authors[0], 'abc123')
assert.equal(executeArgs[0][0].limit, 10)
})
it('should handle empty filters', async () => {
const req = createMockRequestWithParams({ subId: 'test-sub-123' })
req.query = {}
const res = createMockResponse()
mockUseCases.queryEvents.execute.resolves([])
await uut.queryEvents(req, res)
// Assert use case was called with empty filters array
assert.isTrue(mockUseCases.queryEvents.execute.calledOnce)
const executeArgs = mockUseCases.queryEvents.execute.getCall(0).args
assert.deepEqual(executeArgs[0], [{}])
})
it('should return 400 when subscription ID is missing', async () => {
const req = createMockRequestWithParams({})
const res = createMockResponse()
await uut.queryEvents(req, res)
// Assert use case was not called
assert.isFalse(mockUseCases.queryEvents.execute.called)
// Assert error response
assert.equal(res.statusValue, 400)
assert.property(res.jsonData, 'error')
assert.include(res.jsonData.error, 'Subscription ID is required')
})
it('should return 400 when filters JSON is invalid', async () => {
const req = createMockRequestWithParams({ subId: 'test-sub-123' })
req.query = { filters: 'invalid-json{' }
const res = createMockResponse()
await uut.queryEvents(req, res)
// Assert error response
assert.equal(res.statusValue, 400)
assert.property(res.jsonData, 'error')
assert.include(res.jsonData.error, 'Invalid filters JSON')
})
it('should handle use case errors', async () => {
const req = createMockRequestWithParams({ subId: 'test-sub-123' })
req.query = { filters: JSON.stringify([{ kinds: [1] }]) }
const res = createMockResponse()
mockUseCases.queryEvents.execute.rejects(new Error('Query failed'))
await uut.queryEvents(req, res)
// Assert error response
assert.equal(res.statusValue, 500)
assert.property(res.jsonData, 'error')
assert.include(res.jsonData.error, 'Query failed')
})
})
describe('#createSubscription()', () => {
it('should successfully create SSE subscription', async () => {
const req = createMockRequestWithParams({ subId: 'test-sub-123' })
req.body = { kinds: [1] }
const res = createMockResponse()
mockUseCases.manageSubscription.createSubscription.resolves()
await uut.createSubscription(req, res)
// Assert use case was called
assert.isTrue(mockUseCases.manageSubscription.createSubscription.calledOnce)
const createArgs = mockUseCases.manageSubscription.createSubscription.getCall(0).args
assert.equal(createArgs[0], 'test-sub-123')
assert.isArray(createArgs[1])
assert.equal(createArgs[1][0].kinds[0], 1)
assert.isFunction(createArgs[2]) // onEvent
assert.isFunction(createArgs[3]) // onEose
assert.isFunction(createArgs[4]) // onClosed
// Assert SSE headers
assert.equal(res.headers['Content-Type'], 'text/event-stream')
assert.equal(res.headers['Cache-Control'], 'no-cache')
assert.equal(res.headers.Connection, 'keep-alive')
// Assert initial connection message was written
assert.isTrue(res.writeData.length > 0)
})
it('should return 400 when subscription ID is missing', async () => {
const req = createMockRequestWithParams({})
req.body = { kinds: [1] }
const res = createMockResponse()
await uut.createSubscription(req, res)
// Assert error response
assert.equal(res.statusValue, 400)
assert.property(res.jsonData, 'error')
assert.include(res.jsonData.error, 'Subscription ID is required')
})
it('should return 400 when filters are missing', async () => {
const req = createMockRequestWithParams({ subId: 'test-sub-123' })
req.body = {}
const res = createMockResponse()
await uut.createSubscription(req, res)
// Assert error response
assert.equal(res.statusValue, 400)
assert.property(res.jsonData, 'error')
assert.include(res.jsonData.error, 'Filters are required')
})
it('should handle filters as array', async () => {
const req = createMockRequestWithParams({ subId: 'test-sub-123' })
req.body = [{ kinds: [1] }, { kinds: [3] }]
const res = createMockResponse()
mockUseCases.manageSubscription.createSubscription.resolves()
await uut.createSubscription(req, res)
// Assert filters array was passed correctly
const createArgs = mockUseCases.manageSubscription.createSubscription.getCall(0).args
assert.isArray(createArgs[1])
assert.equal(createArgs[1].length, 2)
})
it('should handle client disconnect', async () => {
const req = createMockRequestWithParams({ subId: 'test-sub-123' })
req.body = { kinds: [1] }
req.on = sinon.stub()
const res = createMockResponse()
mockUseCases.manageSubscription.createSubscription.resolves()
mockUseCases.manageSubscription.closeSubscription.resolves()
await uut.createSubscription(req, res)
// Assert close handler was set up
assert.isTrue(req.on.calledWith('close'))
// Simulate client disconnect
const closeCallback = req.on.getCall(0).args[1]
await closeCallback()
// Assert closeSubscription was called
assert.isTrue(mockUseCases.manageSubscription.closeSubscription.calledOnce)
assert.equal(mockUseCases.manageSubscription.closeSubscription.getCall(0).args[0], 'test-sub-123')
})
})
describe('#closeSubscription()', () => {
it('should successfully close a subscription', async () => {
const req = createMockRequestWithParams({ subId: 'test-sub-123' })
const res = createMockResponse()
mockUseCases.manageSubscription.closeSubscription.resolves()
await uut.closeSubscription(req, res)
// Assert use case was called
assert.isTrue(mockUseCases.manageSubscription.closeSubscription.calledOnce)
assert.equal(mockUseCases.manageSubscription.closeSubscription.getCall(0).args[0], 'test-sub-123')
// Assert response
assert.equal(res.statusValue, 200)
assert.property(res.jsonData, 'message')
assert.include(res.jsonData.message, 'closed successfully')
})
it('should return 400 when subscription ID is missing', async () => {
const req = createMockRequestWithParams({})
const res = createMockResponse()
await uut.closeSubscription(req, res)
// Assert error response
assert.equal(res.statusValue, 400)
assert.property(res.jsonData, 'error')
assert.include(res.jsonData.error, 'Subscription ID is required')
})
it('should handle use case errors', async () => {
const req = createMockRequestWithParams({ subId: 'test-sub-123' })
const res = createMockResponse()
mockUseCases.manageSubscription.closeSubscription.rejects(new Error('Relay connection error'))
await uut.closeSubscription(req, res)
// Assert error response
assert.equal(res.statusValue, 500)
assert.property(res.jsonData, 'error')
assert.include(res.jsonData.error, 'Relay connection error')
})
it('should handle idempotent close (subscription already closed)', async () => {
const req = createMockRequestWithParams({ subId: 'test-sub-123' })
const res = createMockResponse()
// closeSubscription resolves successfully even if subscription doesn't exist
mockUseCases.manageSubscription.closeSubscription.resolves()
await uut.closeSubscription(req, res)
// Assert success response even for already-closed subscription
assert.equal(res.statusValue, 200)
assert.property(res.jsonData, 'message')
assert.include(res.jsonData.message, 'closed successfully')
})
})
describe('#createSubscriptionPut()', () => {
it('should call createSubscription', async () => {
const req = createMockRequestWithParams({ subId: 'test-sub-123' })
req.body = { kinds: [1] }
const res = createMockResponse()
mockUseCases.manageSubscription.createSubscription.resolves()
await uut.createSubscriptionPut(req, res)
// Assert createSubscription was called
assert.isTrue(mockUseCases.manageSubscription.createSubscription.calledOnce)
})
})
describe('#constructor()', () => {
it('should require adapters instance', () => {
try {
// eslint-disable-next-line no-new
new ReqRESTControllerLib({ useCases: mockUseCases })
assert.equal(true, false, 'unexpected result')
} catch (err) {
assert.include(err.message, 'Adapters library required')
}
})
it('should require useCases instance', () => {
try {
// eslint-disable-next-line no-new
new ReqRESTControllerLib({ adapters: {} })
assert.equal(true, false, 'unexpected result')
} catch (err) {
assert.include(err.message, 'Use Cases library required')
}
})
})
})
@@ -0,0 +1,99 @@
/*
Unit tests for RESTControllers index.
*/
import { assert } from 'chai'
import sinon from 'sinon'
import RESTControllers from '../../../src/controllers/rest-api/index.js'
import BlockchainRouter from '../../../src/controllers/rest-api/full-node/blockchain/index.js'
import ControlRouter from '../../../src/controllers/rest-api/full-node/control/index.js'
import DSProofRouter from '../../../src/controllers/rest-api/full-node/dsproof/index.js'
describe('#controllers/rest-api/index.js', () => {
let sandbox
let mockAdapters
let mockUseCases
const createBlockchainUseCaseStubs = () => ({
getBestBlockHash: () => {},
getBlockchainInfo: () => {},
getBlockCount: () => {},
getBlockHeader: () => {},
getBlockHeaders: () => {},
getChainTips: () => {},
getDifficulty: () => {},
getMempoolEntry: () => {},
getMempoolEntries: () => {},
getMempoolAncestors: () => {},
getMempoolInfo: () => {},
getRawMempool: () => {},
getTxOut: () => {},
getTxOutProof: () => {},
getTxOutProofs: () => {},
verifyTxOutProof: () => {},
verifyTxOutProofs: () => {},
getBlock: () => {},
getBlockHash: () => {}
})
beforeEach(() => {
sandbox = sinon.createSandbox()
mockAdapters = {
fullNode: {
validateArraySize: sandbox.stub().returns(true)
}
}
mockUseCases = {
blockchain: createBlockchainUseCaseStubs(),
control: {
getNetworkInfo: () => {}
},
dsproof: {
getDSProof: () => {}
}
}
})
afterEach(() => {
sandbox.restore()
})
describe('#constructor()', () => {
it('should require adapters instance', () => {
assert.throws(() => {
// eslint-disable-next-line no-new
new RESTControllers({ useCases: mockUseCases })
}, /Adapters library required/)
})
it('should require useCases instance', () => {
assert.throws(() => {
// eslint-disable-next-line no-new
new RESTControllers({ adapters: mockAdapters })
}, /Use Cases library required/)
})
})
describe('#attachRESTControllers()', () => {
it('should instantiate routers and attach to app', () => {
const blockchainAttachStub = sandbox.stub(BlockchainRouter.prototype, 'attach')
const controlAttachStub = sandbox.stub(ControlRouter.prototype, 'attach')
const dsproofAttachStub = sandbox.stub(DSProofRouter.prototype, 'attach')
const restControllers = new RESTControllers({
adapters: mockAdapters,
useCases: mockUseCases
})
const app = {}
restControllers.attachRESTControllers(app)
assert.isTrue(blockchainAttachStub.calledOnce)
assert.equal(blockchainAttachStub.getCall(0).args[0], app)
assert.isTrue(controlAttachStub.calledOnce)
assert.equal(controlAttachStub.getCall(0).args[0], app)
assert.isTrue(dsproofAttachStub.calledOnce)
assert.equal(dsproofAttachStub.getCall(0).args[0], app)
})
})
})
-58
View File
@@ -1,58 +0,0 @@
/*
Mock responses from Nostr relay for unit tests.
Contains mock messages that would come from a Nostr relay WebSocket.
*/
import { mockKind1Event, validEventId } from './event-mocks.js'
// Mock OK response (event accepted)
const mockOkAccepted = ['OK', validEventId, true, 'event saved']
// Mock OK response (event rejected)
const mockOkRejected = ['OK', validEventId, false, 'duplicate: event already exists']
// Mock EVENT message (from relay)
const mockEventMessage = ['EVENT', 'subscription-id-123', mockKind1Event]
// Mock EOSE message (end of stored events)
const mockEoseMessage = ['EOSE', 'subscription-id-123']
// Mock CLOSED message
const mockClosedMessage = ['CLOSED', 'subscription-id-123', 'subscription closed']
// Mock NOTICE message
const mockNoticeMessage = ['NOTICE', 'rate limited: slow down']
// Mock successful sendEvent response
const mockSendEventSuccess = {
accepted: true,
message: 'event saved'
}
// Mock failed sendEvent response
const mockSendEventFailure = {
accepted: false,
message: 'duplicate: event already exists'
}
// Mock events array for query tests
const mockEventsArray = [
mockKind1Event,
{
...mockKind1Event,
id: 'b'.repeat(64),
content: 'Another test message'
}
]
export {
mockOkAccepted,
mockOkRejected,
mockEventMessage,
mockEoseMessage,
mockClosedMessage,
mockNoticeMessage,
mockSendEventSuccess,
mockSendEventFailure,
mockEventsArray
}
@@ -0,0 +1,137 @@
/*
Unit tests for BlockchainUseCases.
*/
import { assert } from 'chai'
import sinon from 'sinon'
import BlockchainUseCases from '../../../src/use-cases/full-node-blockchain-use-cases.js'
describe('#full-node-blockchain-use-cases.js', () => {
let sandbox
let mockAdapters
let uut
const createAdapters = () => {
return {
fullNode: {
call: sandbox.stub()
}
}
}
beforeEach(() => {
sandbox = sinon.createSandbox()
mockAdapters = createAdapters()
uut = new BlockchainUseCases({ adapters: mockAdapters })
})
afterEach(() => {
sandbox.restore()
})
describe('#constructor()', () => {
it('should require adapters', () => {
assert.throws(() => {
// eslint-disable-next-line no-new
new BlockchainUseCases()
}, /Adapters instance required/)
})
it('should require full node adapter', () => {
assert.throws(() => {
// eslint-disable-next-line no-new
new BlockchainUseCases({ adapters: {} })
}, /Full node adapter required/)
})
})
describe('#getBestBlockHash()', () => {
it('should call full node adapter without parameters', async () => {
mockAdapters.fullNode.call.resolves('hash')
const result = await uut.getBestBlockHash()
assert.equal(result, 'hash')
assert.isTrue(mockAdapters.fullNode.call.calledOnceWithExactly('getbestblockhash'))
})
})
describe('#getBlockHeaders()', () => {
it('should call adapter for each hash and return aggregated result', async () => {
const hashes = ['a'.repeat(64), 'b'.repeat(64)]
mockAdapters.fullNode.call
.onFirstCall().resolves('header-1')
.onSecondCall().resolves('header-2')
const result = await uut.getBlockHeaders({ hashes, verbose: true })
assert.deepEqual(result, ['header-1', 'header-2'])
assert.isTrue(
mockAdapters.fullNode.call.calledWithExactly(
'getblockheader',
[hashes[0], true],
`getblockheader-${hashes[0]}`
)
)
assert.isTrue(
mockAdapters.fullNode.call.calledWithExactly(
'getblockheader',
[hashes[1], true],
`getblockheader-${hashes[1]}`
)
)
})
it('should rethrow errors from adapter', async () => {
const hashes = ['a'.repeat(64)]
mockAdapters.fullNode.call.rejects(new Error('failure'))
try {
await uut.getBlockHeaders({ hashes })
assert.fail('Unexpected success')
} catch (err) {
assert.equal(err.message, 'failure')
}
})
})
describe('#getTxOut()', () => {
it('should pass parameters to full node call', async () => {
mockAdapters.fullNode.call.resolves({ value: 1 })
const result = await uut.getTxOut({
txid: 'txid',
n: 0,
includeMempool: true
})
assert.deepEqual(result, { value: 1 })
assert.isTrue(
mockAdapters.fullNode.call.calledOnceWithExactly(
'gettxout',
['txid', 0, true]
)
)
})
})
describe('#verifyTxOutProofs()', () => {
it('should call adapter for each proof and return aggregated results', async () => {
const proofs = ['proof-1', 'proof-2']
mockAdapters.fullNode.call.onFirstCall().resolves(['txid-1'])
mockAdapters.fullNode.call.onSecondCall().resolves(['txid-2'])
const result = await uut.verifyTxOutProofs({ proofs })
assert.deepEqual(result, [['txid-1'], ['txid-2']])
assert.isTrue(
mockAdapters.fullNode.call.calledWithExactly(
'verifytxoutproof',
['proof-1'],
`verifytxoutproof-${proofs[0].slice(0, 16)}`
)
)
})
})
})
@@ -0,0 +1,53 @@
/*
Unit tests for ControlUseCases.
*/
import { assert } from 'chai'
import ControlUseCases from '../../../src/use-cases/full-node-control-use-cases.js'
describe('#full-node-control-use-cases.js', () => {
let mockAdapters
let uut
beforeEach(() => {
mockAdapters = {
fullNode: {
call: async () => ({})
}
}
uut = new ControlUseCases({ adapters: mockAdapters })
})
describe('#constructor()', () => {
it('should require adapters', () => {
assert.throws(() => {
// eslint-disable-next-line no-new
new ControlUseCases()
}, /Adapters instance required/)
})
it('should require full node adapter', () => {
assert.throws(() => {
// eslint-disable-next-line no-new
new ControlUseCases({ adapters: {} })
}, /Full node adapter required/)
})
})
describe('#getNetworkInfo()', () => {
it('should call full node adapter with correct method', async () => {
let capturedMethod = ''
mockAdapters.fullNode.call = async method => {
capturedMethod = method
return { version: 1 }
}
const result = await uut.getNetworkInfo()
assert.equal(capturedMethod, 'getnetworkinfo')
assert.deepEqual(result, { version: 1 })
})
})
})
@@ -0,0 +1,54 @@
/*
Unit tests for DSProofUseCases.
*/
import { assert } from 'chai'
import DSProofUseCases from '../../../src/use-cases/full-node-dsproof-use-cases.js'
describe('#full-node-dsproof-use-cases.js', () => {
let mockAdapters
let uut
beforeEach(() => {
mockAdapters = {
fullNode: {
call: async () => ({})
}
}
uut = new DSProofUseCases({ adapters: mockAdapters })
})
describe('#constructor()', () => {
it('should require adapters', () => {
assert.throws(() => {
// eslint-disable-next-line no-new
new DSProofUseCases()
}, /Adapters instance required/)
})
it('should require full node adapter', () => {
assert.throws(() => {
// eslint-disable-next-line no-new
new DSProofUseCases({ adapters: {} })
}, /Full node adapter required/)
})
})
describe('#getDSProof()', () => {
it('should pass txid and verbose parameters to adapter', async () => {
let capturedArgs = null
mockAdapters.fullNode.call = async (method, params) => {
capturedArgs = { method, params }
return { success: true }
}
const result = await uut.getDSProof({ txid: 'a'.repeat(64), verbose: 2 })
assert.equal(capturedArgs.method, 'getdsproof')
assert.deepEqual(capturedArgs.params, ['a'.repeat(64), 2])
assert.deepEqual(result, { success: true })
})
})
})
@@ -1,242 +0,0 @@
/*
Unit tests for ManageSubscriptionUseCase.
*/
// npm libraries
import { assert } from 'chai'
import sinon from 'sinon'
// Mocking data libraries
import { mockKind1Event } from '../mocks/event-mocks.js'
// Unit under test
import ManageSubscriptionUseCase from '../../../src/use-cases/manage-subscription.js'
describe('#manage-subscription.js', () => {
let sandbox
let mockAdapters
let uut
beforeEach(() => {
sandbox = sinon.createSandbox()
// Create mock adapters with multiple relays support
const mockRelay1 = {
relayUrl: 'wss://relay1.example.com',
sendReq: sandbox.stub(),
sendClose: sandbox.stub()
}
const mockRelay2 = {
relayUrl: 'wss://relay2.example.com',
sendReq: sandbox.stub(),
sendClose: sandbox.stub()
}
mockAdapters = {
nostrRelays: [mockRelay1, mockRelay2]
}
uut = new ManageSubscriptionUseCase({ adapters: mockAdapters })
})
afterEach(() => {
sandbox.restore()
})
describe('#createSubscription()', () => {
it('should successfully create a subscription across all relays', async () => {
const subscriptionId = 'test-sub-123'
const filters = [{ kinds: [1] }]
let onEventCalled = false
let onEoseCalled = false
let onClosedCalled = false
const onEvent = (event) => {
onEventCalled = true
}
const onEose = () => {
onEoseCalled = true
}
const onClosed = (message) => {
onClosedCalled = true
}
// Mock adapters to resolve
mockAdapters.nostrRelays[0].sendReq.resolves()
mockAdapters.nostrRelays[1].sendReq.resolves()
await uut.createSubscription(subscriptionId, filters, onEvent, onEose, onClosed)
// Assert adapters were called for both relays
assert.isTrue(mockAdapters.nostrRelays[0].sendReq.calledOnce)
assert.isTrue(mockAdapters.nostrRelays[1].sendReq.calledOnce)
// Assert subscription is tracked
assert.isTrue(uut.hasSubscription(subscriptionId))
// Test handlers - get them from the subscription info
const subscriptionInfo = uut.activeSubscriptions.get(subscriptionId)
const handlers = subscriptionInfo.handlers
// Test event handler (should de-duplicate)
handlers.onEvent(mockKind1Event)
assert.isTrue(onEventCalled)
// Simulate EOSE from both relays
const relayStatuses = subscriptionInfo.relayStatuses
relayStatuses[0].eoseReceived = true
relayStatuses[1].eoseReceived = true
handlers.onEose()
assert.isTrue(onEoseCalled)
handlers.onClosed('test message')
assert.isTrue(onClosedCalled)
assert.isFalse(uut.hasSubscription(subscriptionId))
})
it('should prevent duplicate subscriptions', async () => {
const subscriptionId = 'test-sub-123'
const filters = [{ kinds: [1] }]
mockAdapters.nostrRelays[0].sendReq.resolves()
mockAdapters.nostrRelays[1].sendReq.resolves()
await uut.createSubscription(subscriptionId, filters)
try {
await uut.createSubscription(subscriptionId, filters)
assert.equal(true, false, 'unexpected result')
} catch (err) {
assert.include(err.message, 'already exists')
}
})
it('should clean up subscription on error', async () => {
const subscriptionId = 'test-sub-123'
const filters = [{ kinds: [1] }]
mockAdapters.nostrRelays[0].sendReq.rejects(new Error('Connection error'))
mockAdapters.nostrRelays[1].sendReq.resolves()
try {
await uut.createSubscription(subscriptionId, filters)
assert.equal(true, false, 'unexpected result')
} catch (err) {
// Should clean up even if some relays fail
assert.isFalse(uut.hasSubscription(subscriptionId))
}
})
it('should handle missing callbacks gracefully', async () => {
const subscriptionId = 'test-sub-123'
const filters = [{ kinds: [1] }]
mockAdapters.nostrRelays[0].sendReq.resolves()
mockAdapters.nostrRelays[1].sendReq.resolves()
await uut.createSubscription(subscriptionId, filters, null, null, null)
// Should not throw when handlers are null
const subscriptionInfo = uut.activeSubscriptions.get(subscriptionId)
const handlers = subscriptionInfo.handlers
handlers.onEvent(mockKind1Event)
handlers.onEose()
handlers.onClosed('test')
})
})
describe('#closeSubscription()', () => {
it('should successfully close a subscription across all relays', async () => {
const subscriptionId = 'test-sub-123'
const filters = [{ kinds: [1] }]
mockAdapters.nostrRelays[0].sendReq.resolves()
mockAdapters.nostrRelays[1].sendReq.resolves()
mockAdapters.nostrRelays[0].sendClose.resolves()
mockAdapters.nostrRelays[1].sendClose.resolves()
// Create subscription first
await uut.createSubscription(subscriptionId, filters)
assert.isTrue(uut.hasSubscription(subscriptionId))
// Close subscription
await uut.closeSubscription(subscriptionId)
// Assert adapters were called for both relays
assert.isTrue(mockAdapters.nostrRelays[0].sendClose.calledOnce)
assert.isTrue(mockAdapters.nostrRelays[1].sendClose.calledOnce)
// Assert subscription is removed
assert.isFalse(uut.hasSubscription(subscriptionId))
})
it('should return successfully when closing non-existent subscription (idempotent)', async () => {
const subscriptionId = 'non-existent-sub'
// Should not throw - idempotent operation
await uut.closeSubscription(subscriptionId)
// Should return successfully without error
assert.isTrue(true, 'closeSubscription should succeed for non-existent subscription')
})
it('should clean up subscription even on error', async () => {
const subscriptionId = 'test-sub-123'
const filters = [{ kinds: [1] }]
mockAdapters.nostrRelays[0].sendReq.resolves()
mockAdapters.nostrRelays[1].sendReq.resolves()
mockAdapters.nostrRelays[0].sendClose.rejects(new Error('Close error'))
mockAdapters.nostrRelays[1].sendClose.resolves()
// Create subscription first
await uut.createSubscription(subscriptionId, filters)
// Close should succeed even if one relay fails
await uut.closeSubscription(subscriptionId)
// Should still clean up
assert.isFalse(uut.hasSubscription(subscriptionId))
})
})
describe('#hasSubscription()', () => {
it('should return false for non-existent subscription', () => {
assert.isFalse(uut.hasSubscription('non-existent'))
})
it('should return true for existing subscription', async () => {
const subscriptionId = 'test-sub-123'
const filters = [{ kinds: [1] }]
mockAdapters.nostrRelays[0].sendReq.resolves()
mockAdapters.nostrRelays[1].sendReq.resolves()
assert.isFalse(uut.hasSubscription(subscriptionId))
await uut.createSubscription(subscriptionId, filters)
assert.isTrue(uut.hasSubscription(subscriptionId))
})
})
describe('#constructor()', () => {
it('should require adapters instance', () => {
try {
// eslint-disable-next-line no-new
new ManageSubscriptionUseCase()
assert.equal(true, false, 'unexpected result')
} catch (err) {
assert.include(err.message, 'Adapters instance required')
}
})
it('should require NostrRelay adapters array', () => {
try {
// eslint-disable-next-line no-new
new ManageSubscriptionUseCase({ adapters: {} })
assert.equal(true, false, 'unexpected result')
} catch (err) {
assert.include(err.message, 'NostrRelay adapters array required')
}
})
})
})
-146
View File
@@ -1,146 +0,0 @@
/*
Unit tests for PublishEventUseCase.
*/
// npm libraries
import { assert } from 'chai'
import sinon from 'sinon'
// Mocking data libraries
import {
mockKind1Event,
mockInvalidEventMissingId
} from '../mocks/event-mocks.js'
// Unit under test
import PublishEventUseCase from '../../../src/use-cases/publish-event.js'
describe('#publish-event.js', () => {
let sandbox
let mockAdapters
let uut
beforeEach(() => {
sandbox = sinon.createSandbox()
// Create mock adapters with multiple relays support
mockAdapters = {
nostrRelays: [
{ relayUrl: 'wss://relay1.example.com' },
{ relayUrl: 'wss://relay2.example.com' }
],
broadcastEvent: sandbox.stub()
}
uut = new PublishEventUseCase({ adapters: mockAdapters })
})
afterEach(() => {
sandbox.restore()
})
describe('#execute()', () => {
it('should successfully publish a valid event to all relays', async () => {
// Mock broadcast response - at least one relay accepts
mockAdapters.broadcastEvent.resolves([
{ accepted: true, message: 'event saved', relayUrl: 'wss://relay1.example.com', success: true },
{ accepted: true, message: 'event saved', relayUrl: 'wss://relay2.example.com', success: true }
])
const result = await uut.execute(mockKind1Event)
// Assert adapter was called correctly
assert.isTrue(mockAdapters.broadcastEvent.calledOnce)
const callArgs = mockAdapters.broadcastEvent.getCall(0).args[0]
assert.equal(callArgs.id, mockKind1Event.id)
assert.equal(callArgs.kind, mockKind1Event.kind)
// Assert result
assert.property(result, 'accepted')
assert.property(result, 'message')
assert.property(result, 'eventId')
assert.property(result, 'relayResults')
assert.property(result, 'acceptedCount')
assert.property(result, 'totalRelays')
assert.isTrue(result.accepted)
assert.equal(result.eventId, mockKind1Event.id)
assert.equal(result.acceptedCount, 2)
assert.equal(result.totalRelays, 2)
})
it('should handle event rejection from all relays', async () => {
// Mock broadcast response - all relays reject
mockAdapters.broadcastEvent.resolves([
{ accepted: false, message: 'duplicate', relayUrl: 'wss://relay1.example.com', success: true },
{ accepted: false, message: 'duplicate', relayUrl: 'wss://relay2.example.com', success: true }
])
const result = await uut.execute(mockKind1Event)
// Assert result shows rejection
assert.isFalse(result.accepted)
assert.property(result, 'message')
assert.equal(result.eventId, mockKind1Event.id)
assert.equal(result.acceptedCount, 0)
})
it('should succeed if at least one relay accepts', async () => {
// Mock broadcast response - one accepts, one rejects
mockAdapters.broadcastEvent.resolves([
{ accepted: true, message: 'event saved', relayUrl: 'wss://relay1.example.com', success: true },
{ accepted: false, message: 'duplicate', relayUrl: 'wss://relay2.example.com', success: true }
])
const result = await uut.execute(mockKind1Event)
// Should succeed if at least one accepts
assert.isTrue(result.accepted)
assert.equal(result.acceptedCount, 1)
assert.equal(result.totalRelays, 2)
})
it('should throw error for invalid event structure', async () => {
try {
await uut.execute(mockInvalidEventMissingId)
assert.equal(true, false, 'unexpected result')
} catch (err) {
assert.include(err.message, 'Invalid event structure')
assert.isFalse(mockAdapters.broadcastEvent.called)
}
})
it('should handle adapter errors', async () => {
// Mock adapter error
const adapterError = new Error('Network error')
mockAdapters.broadcastEvent.rejects(adapterError)
try {
await uut.execute(mockKind1Event)
assert.equal(true, false, 'unexpected result')
} catch (err) {
assert.equal(err.message, 'Network error')
assert.isTrue(mockAdapters.broadcastEvent.calledOnce)
}
})
it('should require adapters instance', () => {
try {
// eslint-disable-next-line no-new
new PublishEventUseCase()
assert.equal(true, false, 'unexpected result')
} catch (err) {
assert.include(err.message, 'Adapters instance required')
}
})
it('should require NostrRelay adapters array', () => {
try {
// eslint-disable-next-line no-new
new PublishEventUseCase({ adapters: {} })
assert.equal(true, false, 'unexpected result')
} catch (err) {
assert.include(err.message, 'NostrRelay adapters array required')
}
})
})
})
-106
View File
@@ -1,106 +0,0 @@
/*
Unit tests for QueryEventsUseCase.
*/
// npm libraries
import { assert } from 'chai'
import sinon from 'sinon'
// Mocking data libraries
import { mockEventsArray } from '../mocks/nostr-relay-mocks.js'
// Unit under test
import QueryEventsUseCase from '../../../src/use-cases/query-events.js'
describe('#query-events.js', () => {
let sandbox
let mockAdapters
let uut
beforeEach(() => {
sandbox = sinon.createSandbox()
// Create mock adapters with multiple relays support
mockAdapters = {
nostrRelays: [
{ relayUrl: 'wss://relay1.example.com' },
{ relayUrl: 'wss://relay2.example.com' }
],
queryAllRelays: sandbox.stub()
}
uut = new QueryEventsUseCase({ adapters: mockAdapters })
})
afterEach(() => {
sandbox.restore()
})
describe('#execute()', () => {
it('should successfully query events from all relays and return merged results', async () => {
const filters = [{ kinds: [1], limit: 10 }]
const subscriptionId = 'test-sub-123'
// Mock queryAllRelays to return events
mockAdapters.queryAllRelays.resolves(mockEventsArray)
const result = await uut.execute(filters, subscriptionId)
// Assert adapter was called correctly
assert.isTrue(mockAdapters.queryAllRelays.calledOnce)
const callArgs = mockAdapters.queryAllRelays.getCall(0).args
assert.deepEqual(callArgs[0], filters)
assert.equal(callArgs[1], subscriptionId)
// Assert result contains events
assert.isArray(result)
assert.equal(result.length, mockEventsArray.length)
})
it('should handle errors from queryAllRelays', async () => {
const filters = [{ kinds: [1] }]
const subscriptionId = 'test-sub-123'
mockAdapters.queryAllRelays.rejects(new Error('Query failed'))
try {
await uut.execute(filters, subscriptionId)
assert.equal(true, false, 'unexpected result')
} catch (err) {
assert.include(err.message, 'Query failed')
}
})
it('should return empty array when no events found', async () => {
const filters = [{ kinds: [1] }]
const subscriptionId = 'test-sub-123'
mockAdapters.queryAllRelays.resolves([])
const result = await uut.execute(filters, subscriptionId)
assert.isArray(result)
assert.equal(result.length, 0)
})
it('should require adapters instance', () => {
try {
// eslint-disable-next-line no-new
new QueryEventsUseCase()
assert.equal(true, false, 'unexpected result')
} catch (err) {
assert.include(err.message, 'Adapters instance required')
}
})
it('should require NostrRelay adapters array', () => {
try {
// eslint-disable-next-line no-new
new QueryEventsUseCase({ adapters: {} })
assert.equal(true, false, 'unexpected result')
} catch (err) {
assert.include(err.message, 'NostrRelay adapters array required')
}
})
})
})