mirror of
https://github.com/Permissionless-Software-Foundation/psf-bch-api-base.git
synced 2026-09-21 16:52:00 -07:00
Merge pull request #2 from Permissionless-Software-Foundation/ct-unstable
Added x402 payments for endpoint access
This commit is contained in:
@@ -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).
|
||||
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
Generated
+1043
-83
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -20,7 +20,8 @@
|
||||
"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",
|
||||
|
||||
Vendored
+24
@@ -16,6 +16,25 @@ const pkgInfo = JSON.parse(readFileSync(`${__dirname.toString()}/../../../packag
|
||||
|
||||
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,
|
||||
@@ -23,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',
|
||||
|
||||
@@ -59,6 +81,8 @@ export default {
|
||||
rpcRequestIdPrefix: process.env.RPC_REQUEST_ID_PREFIX || 'psf-bch-api'
|
||||
},
|
||||
|
||||
x402: x402Defaults,
|
||||
|
||||
// Version
|
||||
version
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -48,7 +48,7 @@ class BlockchainRESTController {
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} /full-node/blockchain/ Service status
|
||||
* @api {get} /v6/full-node/blockchain/ Service status
|
||||
* @apiName BlockchainRoot
|
||||
* @apiGroup Blockchain
|
||||
*
|
||||
@@ -61,13 +61,13 @@ class BlockchainRESTController {
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} /full-node/blockchain/getBestBlockHash Get best block hash
|
||||
* @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/v5/blockchain/getBestBlockHash" -H "accept: application/json"
|
||||
* curl -X GET "https://api.fullstack.cash/v6/full-node/blockchain/getBestBlockHash" -H "accept: application/json"
|
||||
*
|
||||
* @apiSuccess {String} bestBlockHash Hash of the best block
|
||||
*/
|
||||
@@ -81,7 +81,7 @@ class BlockchainRESTController {
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} /full-node/blockchain/getBlockchainInfo Get blockchain info
|
||||
* @api {get} /v6/full-node/blockchain/getBlockchainInfo Get blockchain info
|
||||
* @apiName GetBlockchainInfo
|
||||
* @apiGroup Blockchain
|
||||
* @apiDescription Returns various state info regarding blockchain processing.
|
||||
@@ -96,7 +96,7 @@ class BlockchainRESTController {
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} /full-node/blockchain/getBlockCount Get block count
|
||||
* @api {get} /v6/full-node/blockchain/getBlockCount Get block count
|
||||
* @apiName GetBlockCount
|
||||
* @apiGroup Blockchain
|
||||
* @apiDescription Returns the number of blocks in the longest blockchain.
|
||||
@@ -111,7 +111,7 @@ class BlockchainRESTController {
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} /full-node/blockchain/getBlockHeader/:hash Get single block header
|
||||
* @api {get} /v6/full-node/blockchain/getBlockHeader/:hash Get single block header
|
||||
* @apiName GetSingleBlockHeader
|
||||
* @apiGroup Blockchain
|
||||
* @apiDescription Returns serialized block header data.
|
||||
@@ -136,7 +136,7 @@ class BlockchainRESTController {
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} /full-node/blockchain/getBlockHeader Get multiple block headers
|
||||
* @api {post} /v6/full-node/blockchain/getBlockHeader Get multiple block headers
|
||||
* @apiName GetBulkBlockHeader
|
||||
* @apiGroup Blockchain
|
||||
* @apiDescription Returns serialized block header data for multiple hashes.
|
||||
@@ -173,7 +173,7 @@ class BlockchainRESTController {
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} /full-node/blockchain/getChainTips Get chain tips
|
||||
* @api {get} /v6/full-node/blockchain/getChainTips Get chain tips
|
||||
* @apiName GetChainTips
|
||||
* @apiGroup Blockchain
|
||||
* @apiDescription Returns information about known tips in the block tree.
|
||||
@@ -188,7 +188,7 @@ class BlockchainRESTController {
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} /full-node/blockchain/getDifficulty Get difficulty
|
||||
* @api {get} /v6/full-node/blockchain/getDifficulty Get difficulty
|
||||
* @apiName GetDifficulty
|
||||
* @apiGroup Blockchain
|
||||
* @apiDescription Returns the current difficulty value.
|
||||
@@ -203,7 +203,7 @@ class BlockchainRESTController {
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} /full-node/blockchain/getMempoolEntry/:txid Get single mempool entry
|
||||
* @api {get} /v6/full-node/blockchain/getMempoolEntry/:txid Get single mempool entry
|
||||
* @apiName GetMempoolEntry
|
||||
* @apiGroup Blockchain
|
||||
* @apiDescription Returns mempool data for a transaction.
|
||||
@@ -223,7 +223,7 @@ class BlockchainRESTController {
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} /full-node/blockchain/getMempoolEntry Get bulk mempool entry
|
||||
* @api {post} /v6/full-node/blockchain/getMempoolEntry Get bulk mempool entry
|
||||
* @apiName GetMempoolEntryBulk
|
||||
* @apiGroup Blockchain
|
||||
* @apiDescription Returns mempool data for multiple transactions.
|
||||
@@ -256,7 +256,7 @@ class BlockchainRESTController {
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} /full-node/blockchain/getMempoolAncestors/:txid Get mempool ancestors
|
||||
* @api {get} /v6/full-node/blockchain/getMempoolAncestors/:txid Get mempool ancestors
|
||||
* @apiName GetMempoolAncestors
|
||||
* @apiGroup Blockchain
|
||||
* @apiDescription Returns mempool ancestor data for a transaction.
|
||||
@@ -281,7 +281,7 @@ class BlockchainRESTController {
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} /full-node/blockchain/getMempoolInfo Get mempool info
|
||||
* @api {get} /v6/full-node/blockchain/getMempoolInfo Get mempool info
|
||||
* @apiName GetMempoolInfo
|
||||
* @apiGroup Blockchain
|
||||
* @apiDescription Returns details on the state of the mempool.
|
||||
@@ -296,7 +296,7 @@ class BlockchainRESTController {
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} /full-node/blockchain/getRawMempool Get raw mempool
|
||||
* @api {get} /v6/full-node/blockchain/getRawMempool Get raw mempool
|
||||
* @apiName GetRawMempool
|
||||
* @apiGroup Blockchain
|
||||
* @apiDescription Returns all transaction ids in the mempool.
|
||||
@@ -314,7 +314,7 @@ class BlockchainRESTController {
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} /full-node/blockchain/getTxOut/:txid/:n Get transaction output
|
||||
* @api {get} /v6/full-node/blockchain/getTxOut/:txid/:n Get transaction output
|
||||
* @apiName GetTxOut
|
||||
* @apiGroup Blockchain
|
||||
* @apiDescription Returns details about an unspent transaction output.
|
||||
@@ -347,7 +347,7 @@ class BlockchainRESTController {
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} /full-node/blockchain/getTxOut Validate a UTXO
|
||||
* @api {post} /v6/full-node/blockchain/getTxOut Validate a UTXO
|
||||
* @apiName GetTxOutPost
|
||||
* @apiGroup Blockchain
|
||||
* @apiDescription Returns details about an unspent transaction output.
|
||||
@@ -380,7 +380,7 @@ class BlockchainRESTController {
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} /full-node/blockchain/getTxOutProof/:txid Get TxOut proof
|
||||
* @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.
|
||||
@@ -400,7 +400,7 @@ class BlockchainRESTController {
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} /full-node/blockchain/getTxOutProof Get TxOut proofs
|
||||
* @api {post} /v6/full-node/blockchain/getTxOutProof Get TxOut proofs
|
||||
* @apiName GetTxOutProofBulk
|
||||
* @apiGroup Blockchain
|
||||
* @apiDescription Returns hex-encoded proofs for transactions.
|
||||
@@ -435,7 +435,7 @@ class BlockchainRESTController {
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} /full-node/blockchain/verifyTxOutProof/:proof Verify TxOut proof
|
||||
* @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.
|
||||
@@ -455,7 +455,7 @@ class BlockchainRESTController {
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} /full-node/blockchain/verifyTxOutProof Verify TxOut proofs
|
||||
* @api {post} /v6/full-node/blockchain/verifyTxOutProof Verify TxOut proofs
|
||||
* @apiName VerifyTxOutProofBulk
|
||||
* @apiGroup Blockchain
|
||||
* @apiDescription Verifies hex-encoded proofs were included in blocks.
|
||||
@@ -490,7 +490,7 @@ class BlockchainRESTController {
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} /full-node/blockchain/getBlock Get block details
|
||||
* @api {post} /v6/full-node/blockchain/getBlock Get block details
|
||||
* @apiName GetBlock
|
||||
* @apiGroup Blockchain
|
||||
* @apiDescription Returns block details for a hash.
|
||||
@@ -519,7 +519,7 @@ class BlockchainRESTController {
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} /full-node/blockchain/getBlockHash/:height Get block hash
|
||||
* @api {get} /v6/full-node/blockchain/getBlockHash/:height Get block hash
|
||||
* @apiName GetBlockHash
|
||||
* @apiGroup Blockchain
|
||||
* @apiDescription Returns the hash of a block by height.
|
||||
|
||||
@@ -28,7 +28,11 @@ class BlockchainRouter {
|
||||
|
||||
this.blockchainController = new BlockchainRESTController(dependencies)
|
||||
|
||||
this.baseUrl = '/full-node/blockchain'
|
||||
this.apiPrefix = (localConfig.apiPrefix || '').replace(/\/$/, '')
|
||||
this.baseUrl = `${this.apiPrefix}/full-node/blockchain`
|
||||
if (!this.baseUrl.startsWith('/')) {
|
||||
this.baseUrl = `/${this.baseUrl}`
|
||||
}
|
||||
this.router = express.Router()
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -8,6 +8,8 @@
|
||||
// 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 {
|
||||
@@ -26,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)
|
||||
|
||||
@@ -36,7 +44,8 @@ class RESTControllers {
|
||||
attachRESTControllers (app) {
|
||||
const dependencies = {
|
||||
adapters: this.adapters,
|
||||
useCases: this.useCases
|
||||
useCases: this.useCases,
|
||||
apiPrefix: this.apiPrefix
|
||||
}
|
||||
|
||||
// Attach the REST API Controllers associated with the /event route
|
||||
@@ -49,6 +58,12 @@ class RESTControllers {
|
||||
|
||||
const blockchainRouter = new BlockchainRouter(dependencies)
|
||||
blockchainRouter.attach(app)
|
||||
|
||||
const controlRouter = new ControlRouter(dependencies)
|
||||
controlRouter.attach(app)
|
||||
|
||||
const dsproofRouter = new DSProofRouter(dependencies)
|
||||
dsproofRouter.attach(app)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 +6,8 @@
|
||||
|
||||
// Local libraries
|
||||
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 = {}) {
|
||||
@@ -17,6 +19,8 @@ class UseCases {
|
||||
}
|
||||
|
||||
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.
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -7,6 +7,8 @@ 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
|
||||
@@ -43,7 +45,13 @@ describe('#controllers/rest-api/index.js', () => {
|
||||
}
|
||||
}
|
||||
mockUseCases = {
|
||||
blockchain: createBlockchainUseCaseStubs()
|
||||
blockchain: createBlockchainUseCaseStubs(),
|
||||
control: {
|
||||
getNetworkInfo: () => {}
|
||||
},
|
||||
dsproof: {
|
||||
getDSProof: () => {}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -68,8 +76,10 @@ describe('#controllers/rest-api/index.js', () => {
|
||||
})
|
||||
|
||||
describe('#attachRESTControllers()', () => {
|
||||
it('should instantiate blockchain router and attach to app', () => {
|
||||
const attachStub = sandbox.stub(BlockchainRouter.prototype, 'attach')
|
||||
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
|
||||
@@ -78,8 +88,12 @@ describe('#controllers/rest-api/index.js', () => {
|
||||
|
||||
restControllers.attachRESTControllers(app)
|
||||
|
||||
assert.isTrue(attachStub.calledOnce)
|
||||
assert.equal(attachStub.getCall(0).args[0], 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user