psf-bch-api 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,3 +6,25 @@ This is a REST API for communicating with Bitcoin Cash infrastructure. It replac
6
6
 
7
7
  [MIT](./LICENSE.md)
8
8
 
9
+ ## x402-bch Payments
10
+
11
+ 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.
12
+
13
+ ### Configuration
14
+
15
+ Environment variables control the payment flow:
16
+
17
+ - `X402_ENABLED` — set to `false` (case-insensitive) to disable the middleware. Defaults to enabled.
18
+ - `SERVER_BCH_ADDRESS` — BCH cash address that receives funding transactions. Defaults to `bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d`.
19
+ - `FACILITATOR_URL` — Root URL of the facilitator service (e.g., `http://localhost:4345/facilitator`).
20
+ - `X402_PRICE_SAT` — Optional; override the satoshi price per call (defaults to `2000`).
21
+
22
+ When `X402_ENABLED=false`, the server continues to operate without payment headers for local development or trusted deployments.
23
+
24
+ ### Manual Verification
25
+
26
+ 1. Start or point to an `x402-bch` facilitator service (the example facilitator listens at `http://localhost:4345/facilitator`).
27
+ 2. Run the API server with the default configuration: `npm start`.
28
+ 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.
29
+ 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).
30
+
package/bin/server.js CHANGED
@@ -7,6 +7,7 @@
7
7
  import express from 'express'
8
8
  import cors from 'cors'
9
9
  import dotenv from 'dotenv'
10
+ import { paymentMiddleware as x402PaymentMiddleware } from 'x402-bch-express'
10
11
  import { fileURLToPath } from 'url'
11
12
  import { dirname, join } from 'path'
12
13
 
@@ -14,6 +15,7 @@ import { dirname, join } from 'path'
14
15
  import config from '../src/config/index.js'
15
16
  import Controllers from '../src/controllers/index.js'
16
17
  import wlogger from '../src/adapters/wlogger.js'
18
+ import { buildX402Routes, getX402Settings } from '../src/config/x402.js'
17
19
 
18
20
  // Load environment variables
19
21
  dotenv.config()
@@ -57,6 +59,8 @@ class Server {
57
59
  // Create an Express instance.
58
60
  const app = express()
59
61
 
62
+ const x402Settings = getX402Settings()
63
+
60
64
  // MIDDLEWARE START
61
65
  app.use(express.json())
62
66
  app.use(express.urlencoded({ extended: true }))
@@ -68,6 +72,24 @@ class Server {
68
72
  allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With']
69
73
  }))
70
74
 
75
+ if (x402Settings.enabled) {
76
+ const routes = buildX402Routes(this.config.apiPrefix)
77
+ const facilitatorOptions = x402Settings.facilitatorUrl
78
+ ? { url: x402Settings.facilitatorUrl }
79
+ : undefined
80
+
81
+ wlogger.info(`x402 middleware enabled; enforcing ${x402Settings.priceSat} satoshis per request`)
82
+ app.use(
83
+ x402PaymentMiddleware(
84
+ x402Settings.serverAddress,
85
+ routes,
86
+ facilitatorOptions
87
+ )
88
+ )
89
+ } else {
90
+ wlogger.info('x402 middleware disabled via configuration')
91
+ }
92
+
71
93
  // Endpoint logging middleware
72
94
  app.use((req, res, next) => {
73
95
  console.log(`Endpoint called: ${req.method} ${req.path}`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "psf-bch-api",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "main": "index.js",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -20,7 +20,8 @@
20
20
  "dotenv": "16.3.1",
21
21
  "express": "5.1.0",
22
22
  "winston": "3.11.0",
23
- "winston-daily-rotate-file": "4.7.1"
23
+ "winston-daily-rotate-file": "4.7.1",
24
+ "x402-bch-express": "1.1.1"
24
25
  },
25
26
  "devDependencies": {
26
27
  "apidoc": "1.2.0",
@@ -16,6 +16,25 @@ const pkgInfo = JSON.parse(readFileSync(`${__dirname.toString()}/../../../packag
16
16
 
17
17
  const version = pkgInfo.version
18
18
 
19
+ const normalizeBoolean = (value, defaultValue) => {
20
+ if (value === undefined || value === null || value === '') return defaultValue
21
+
22
+ const normalized = String(value).trim().toLowerCase()
23
+ if (['false', '0', 'no', 'off'].includes(normalized)) return false
24
+ if (['true', '1', 'yes', 'on'].includes(normalized)) return true
25
+ return defaultValue
26
+ }
27
+
28
+ const parsedPriceSat = Number(process.env.X402_PRICE_SAT)
29
+ const priceSat = Number.isFinite(parsedPriceSat) && parsedPriceSat > 0 ? parsedPriceSat : 2000
30
+
31
+ const x402Defaults = {
32
+ enabled: normalizeBoolean(process.env.X402_ENABLED, true),
33
+ facilitatorUrl: process.env.FACILITATOR_URL || 'http://localhost:4345/facilitator',
34
+ serverAddress: process.env.SERVER_BCH_ADDRESS || 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d',
35
+ priceSat
36
+ }
37
+
19
38
  export default {
20
39
  // Server port
21
40
  port: process.env.PORT || 5942,
@@ -23,6 +42,9 @@ export default {
23
42
  // Environment
24
43
  env: process.env.NODE_ENV || 'development',
25
44
 
45
+ // API prefix for REST controllers
46
+ apiPrefix: process.env.API_PREFIX || '/v6',
47
+
26
48
  // Logging level
27
49
  logLevel: process.env.LOG_LEVEL || 'info',
28
50
 
@@ -59,6 +81,8 @@ export default {
59
81
  rpcRequestIdPrefix: process.env.RPC_REQUEST_ID_PREFIX || 'psf-bch-api'
60
82
  },
61
83
 
84
+ x402: x402Defaults,
85
+
62
86
  // Version
63
87
  version
64
88
  }
@@ -0,0 +1,43 @@
1
+ import config from './index.js'
2
+
3
+ const DEFAULT_DESCRIPTION = 'Access to protected psf-bch-api resources'
4
+ const DEFAULT_TIMEOUT_SECONDS = 60
5
+ const NETWORK = 'bch'
6
+
7
+ /**
8
+ * Builds a route configuration map for x402-bch middleware.
9
+ *
10
+ * @param {string} apiPrefix Express API prefix (e.g., "/v6")
11
+ * @returns {Object} Routes configuration compatible with x402-bch-express
12
+ */
13
+ export function buildX402Routes (apiPrefix = '/v6') {
14
+ const normalizedPrefix = apiPrefix.endsWith('/')
15
+ ? apiPrefix.slice(0, -1)
16
+ : apiPrefix
17
+ const prefixWithSlash = normalizedPrefix.startsWith('/')
18
+ ? normalizedPrefix
19
+ : `/${normalizedPrefix}`
20
+
21
+ const routeKey = `${prefixWithSlash}/*`
22
+
23
+ return {
24
+ network: NETWORK,
25
+ [routeKey]: {
26
+ price: config.x402.priceSat,
27
+ network: NETWORK,
28
+ config: {
29
+ description: `${DEFAULT_DESCRIPTION} (2000 satoshis)`,
30
+ maxTimeoutSeconds: DEFAULT_TIMEOUT_SECONDS
31
+ }
32
+ }
33
+ }
34
+ }
35
+
36
+ export function getX402Settings () {
37
+ return {
38
+ enabled: Boolean(config.x402?.enabled),
39
+ facilitatorUrl: config.x402?.facilitatorUrl,
40
+ serverAddress: config.x402?.serverAddress,
41
+ priceSat: config.x402?.priceSat
42
+ }
43
+ }
@@ -18,6 +18,7 @@ class Controllers {
18
18
  this.useCases = new UseCases({ adapters: this.adapters })
19
19
  this.config = config
20
20
  this.timerController = new TimerController({ adapters: this.adapters, useCases: this.useCases })
21
+ this.apiPrefix = this.config.apiPrefix || '/v6'
21
22
 
22
23
  // Bind 'this' object to all subfunctions
23
24
  this.initAdapters = this.initAdapters.bind(this)
@@ -45,7 +46,8 @@ class Controllers {
45
46
  attachRESTControllers (app) {
46
47
  const restControllers = new RESTControllers({
47
48
  adapters: this.adapters,
48
- useCases: this.useCases
49
+ useCases: this.useCases,
50
+ apiPrefix: this.apiPrefix
49
51
  })
50
52
 
51
53
  // Attach the REST API Controllers to the Express app.
@@ -48,7 +48,7 @@ class BlockchainRESTController {
48
48
  }
49
49
 
50
50
  /**
51
- * @api {get} /full-node/blockchain/ Service status
51
+ * @api {get} /v6/full-node/blockchain/ Service status
52
52
  * @apiName BlockchainRoot
53
53
  * @apiGroup Blockchain
54
54
  *
@@ -61,13 +61,13 @@ class BlockchainRESTController {
61
61
  }
62
62
 
63
63
  /**
64
- * @api {get} /full-node/blockchain/getBestBlockHash Get best block hash
64
+ * @api {get} /v6/full-node/blockchain/getBestBlockHash Get best block hash
65
65
  * @apiName GetBestBlockHash
66
66
  * @apiGroup Blockchain
67
67
  * @apiDescription Returns the hash of the best (tip) block in the longest block chain.
68
68
  *
69
69
  * @apiExample Example usage:
70
- * curl -X GET "https://api.fullstack.cash/v5/blockchain/getBestBlockHash" -H "accept: application/json"
70
+ * curl -X GET "https://api.fullstack.cash/v6/full-node/blockchain/getBestBlockHash" -H "accept: application/json"
71
71
  *
72
72
  * @apiSuccess {String} bestBlockHash Hash of the best block
73
73
  */
@@ -81,7 +81,7 @@ class BlockchainRESTController {
81
81
  }
82
82
 
83
83
  /**
84
- * @api {get} /full-node/blockchain/getBlockchainInfo Get blockchain info
84
+ * @api {get} /v6/full-node/blockchain/getBlockchainInfo Get blockchain info
85
85
  * @apiName GetBlockchainInfo
86
86
  * @apiGroup Blockchain
87
87
  * @apiDescription Returns various state info regarding blockchain processing.
@@ -96,7 +96,7 @@ class BlockchainRESTController {
96
96
  }
97
97
 
98
98
  /**
99
- * @api {get} /full-node/blockchain/getBlockCount Get block count
99
+ * @api {get} /v6/full-node/blockchain/getBlockCount Get block count
100
100
  * @apiName GetBlockCount
101
101
  * @apiGroup Blockchain
102
102
  * @apiDescription Returns the number of blocks in the longest blockchain.
@@ -111,7 +111,7 @@ class BlockchainRESTController {
111
111
  }
112
112
 
113
113
  /**
114
- * @api {get} /full-node/blockchain/getBlockHeader/:hash Get single block header
114
+ * @api {get} /v6/full-node/blockchain/getBlockHeader/:hash Get single block header
115
115
  * @apiName GetSingleBlockHeader
116
116
  * @apiGroup Blockchain
117
117
  * @apiDescription Returns serialized block header data.
@@ -136,7 +136,7 @@ class BlockchainRESTController {
136
136
  }
137
137
 
138
138
  /**
139
- * @api {post} /full-node/blockchain/getBlockHeader Get multiple block headers
139
+ * @api {post} /v6/full-node/blockchain/getBlockHeader Get multiple block headers
140
140
  * @apiName GetBulkBlockHeader
141
141
  * @apiGroup Blockchain
142
142
  * @apiDescription Returns serialized block header data for multiple hashes.
@@ -173,7 +173,7 @@ class BlockchainRESTController {
173
173
  }
174
174
 
175
175
  /**
176
- * @api {get} /full-node/blockchain/getChainTips Get chain tips
176
+ * @api {get} /v6/full-node/blockchain/getChainTips Get chain tips
177
177
  * @apiName GetChainTips
178
178
  * @apiGroup Blockchain
179
179
  * @apiDescription Returns information about known tips in the block tree.
@@ -188,7 +188,7 @@ class BlockchainRESTController {
188
188
  }
189
189
 
190
190
  /**
191
- * @api {get} /full-node/blockchain/getDifficulty Get difficulty
191
+ * @api {get} /v6/full-node/blockchain/getDifficulty Get difficulty
192
192
  * @apiName GetDifficulty
193
193
  * @apiGroup Blockchain
194
194
  * @apiDescription Returns the current difficulty value.
@@ -203,7 +203,7 @@ class BlockchainRESTController {
203
203
  }
204
204
 
205
205
  /**
206
- * @api {get} /full-node/blockchain/getMempoolEntry/:txid Get single mempool entry
206
+ * @api {get} /v6/full-node/blockchain/getMempoolEntry/:txid Get single mempool entry
207
207
  * @apiName GetMempoolEntry
208
208
  * @apiGroup Blockchain
209
209
  * @apiDescription Returns mempool data for a transaction.
@@ -223,7 +223,7 @@ class BlockchainRESTController {
223
223
  }
224
224
 
225
225
  /**
226
- * @api {post} /full-node/blockchain/getMempoolEntry Get bulk mempool entry
226
+ * @api {post} /v6/full-node/blockchain/getMempoolEntry Get bulk mempool entry
227
227
  * @apiName GetMempoolEntryBulk
228
228
  * @apiGroup Blockchain
229
229
  * @apiDescription Returns mempool data for multiple transactions.
@@ -256,7 +256,7 @@ class BlockchainRESTController {
256
256
  }
257
257
 
258
258
  /**
259
- * @api {get} /full-node/blockchain/getMempoolAncestors/:txid Get mempool ancestors
259
+ * @api {get} /v6/full-node/blockchain/getMempoolAncestors/:txid Get mempool ancestors
260
260
  * @apiName GetMempoolAncestors
261
261
  * @apiGroup Blockchain
262
262
  * @apiDescription Returns mempool ancestor data for a transaction.
@@ -281,7 +281,7 @@ class BlockchainRESTController {
281
281
  }
282
282
 
283
283
  /**
284
- * @api {get} /full-node/blockchain/getMempoolInfo Get mempool info
284
+ * @api {get} /v6/full-node/blockchain/getMempoolInfo Get mempool info
285
285
  * @apiName GetMempoolInfo
286
286
  * @apiGroup Blockchain
287
287
  * @apiDescription Returns details on the state of the mempool.
@@ -296,7 +296,7 @@ class BlockchainRESTController {
296
296
  }
297
297
 
298
298
  /**
299
- * @api {get} /full-node/blockchain/getRawMempool Get raw mempool
299
+ * @api {get} /v6/full-node/blockchain/getRawMempool Get raw mempool
300
300
  * @apiName GetRawMempool
301
301
  * @apiGroup Blockchain
302
302
  * @apiDescription Returns all transaction ids in the mempool.
@@ -314,7 +314,7 @@ class BlockchainRESTController {
314
314
  }
315
315
 
316
316
  /**
317
- * @api {get} /full-node/blockchain/getTxOut/:txid/:n Get transaction output
317
+ * @api {get} /v6/full-node/blockchain/getTxOut/:txid/:n Get transaction output
318
318
  * @apiName GetTxOut
319
319
  * @apiGroup Blockchain
320
320
  * @apiDescription Returns details about an unspent transaction output.
@@ -347,7 +347,7 @@ class BlockchainRESTController {
347
347
  }
348
348
 
349
349
  /**
350
- * @api {post} /full-node/blockchain/getTxOut Validate a UTXO
350
+ * @api {post} /v6/full-node/blockchain/getTxOut Validate a UTXO
351
351
  * @apiName GetTxOutPost
352
352
  * @apiGroup Blockchain
353
353
  * @apiDescription Returns details about an unspent transaction output.
@@ -380,7 +380,7 @@ class BlockchainRESTController {
380
380
  }
381
381
 
382
382
  /**
383
- * @api {get} /full-node/blockchain/getTxOutProof/:txid Get TxOut proof
383
+ * @api {get} /v6/full-node/blockchain/getTxOutProof/:txid Get TxOut proof
384
384
  * @apiName GetTxOutProofSingle
385
385
  * @apiGroup Blockchain
386
386
  * @apiDescription Returns a hex-encoded proof that the transaction was included in a block.
@@ -400,7 +400,7 @@ class BlockchainRESTController {
400
400
  }
401
401
 
402
402
  /**
403
- * @api {post} /full-node/blockchain/getTxOutProof Get TxOut proofs
403
+ * @api {post} /v6/full-node/blockchain/getTxOutProof Get TxOut proofs
404
404
  * @apiName GetTxOutProofBulk
405
405
  * @apiGroup Blockchain
406
406
  * @apiDescription Returns hex-encoded proofs for transactions.
@@ -435,7 +435,7 @@ class BlockchainRESTController {
435
435
  }
436
436
 
437
437
  /**
438
- * @api {get} /full-node/blockchain/verifyTxOutProof/:proof Verify TxOut proof
438
+ * @api {get} /v6/full-node/blockchain/verifyTxOutProof/:proof Verify TxOut proof
439
439
  * @apiName VerifyTxOutProofSingle
440
440
  * @apiGroup Blockchain
441
441
  * @apiDescription Verifies a hex-encoded proof was included in a block.
@@ -455,7 +455,7 @@ class BlockchainRESTController {
455
455
  }
456
456
 
457
457
  /**
458
- * @api {post} /full-node/blockchain/verifyTxOutProof Verify TxOut proofs
458
+ * @api {post} /v6/full-node/blockchain/verifyTxOutProof Verify TxOut proofs
459
459
  * @apiName VerifyTxOutProofBulk
460
460
  * @apiGroup Blockchain
461
461
  * @apiDescription Verifies hex-encoded proofs were included in blocks.
@@ -490,7 +490,7 @@ class BlockchainRESTController {
490
490
  }
491
491
 
492
492
  /**
493
- * @api {post} /full-node/blockchain/getBlock Get block details
493
+ * @api {post} /v6/full-node/blockchain/getBlock Get block details
494
494
  * @apiName GetBlock
495
495
  * @apiGroup Blockchain
496
496
  * @apiDescription Returns block details for a hash.
@@ -519,7 +519,7 @@ class BlockchainRESTController {
519
519
  }
520
520
 
521
521
  /**
522
- * @api {get} /full-node/blockchain/getBlockHash/:height Get block hash
522
+ * @api {get} /v6/full-node/blockchain/getBlockHash/:height Get block hash
523
523
  * @apiName GetBlockHash
524
524
  * @apiGroup Blockchain
525
525
  * @apiDescription Returns the hash of a block by height.
@@ -28,7 +28,11 @@ class BlockchainRouter {
28
28
 
29
29
  this.blockchainController = new BlockchainRESTController(dependencies)
30
30
 
31
- this.baseUrl = '/full-node/blockchain'
31
+ this.apiPrefix = (localConfig.apiPrefix || '').replace(/\/$/, '')
32
+ this.baseUrl = `${this.apiPrefix}/full-node/blockchain`
33
+ if (!this.baseUrl.startsWith('/')) {
34
+ this.baseUrl = `/${this.baseUrl}`
35
+ }
32
36
  this.router = express.Router()
33
37
  }
34
38
 
@@ -0,0 +1,68 @@
1
+ /*
2
+ REST API Controller for the /full-node/control routes.
3
+ */
4
+
5
+ import wlogger from '../../../../adapters/wlogger.js'
6
+
7
+ class ControlRESTController {
8
+ constructor (localConfig = {}) {
9
+ this.adapters = localConfig.adapters
10
+ if (!this.adapters) {
11
+ throw new Error(
12
+ 'Instance of Adapters library required when instantiating Control REST Controller.'
13
+ )
14
+ }
15
+
16
+ this.useCases = localConfig.useCases
17
+ if (!this.useCases || !this.useCases.control) {
18
+ throw new Error(
19
+ 'Instance of Control use cases required when instantiating Control REST Controller.'
20
+ )
21
+ }
22
+
23
+ this.controlUseCases = this.useCases.control
24
+
25
+ this.root = this.root.bind(this)
26
+ this.getNetworkInfo = this.getNetworkInfo.bind(this)
27
+ this.handleError = this.handleError.bind(this)
28
+ }
29
+
30
+ /**
31
+ * @api {get} /v6/full-node/control/ Service status
32
+ * @apiName ControlRoot
33
+ * @apiGroup Control
34
+ *
35
+ * @apiDescription Returns the status of the control service.
36
+ *
37
+ * @apiSuccess {String} status Service identifier
38
+ */
39
+ async root (req, res) {
40
+ return res.status(200).json({ status: 'control' })
41
+ }
42
+
43
+ /**
44
+ * @api {get} /v6/full-node/control/getNetworkInfo Get Network Info
45
+ * @apiName GetNetworkInfo
46
+ * @apiGroup Control
47
+ * @apiDescription RPC call that gets basic full node information.
48
+ */
49
+ async getNetworkInfo (req, res) {
50
+ try {
51
+ const result = await this.controlUseCases.getNetworkInfo()
52
+ return res.status(200).json(result)
53
+ } catch (err) {
54
+ return this.handleError(err, res)
55
+ }
56
+ }
57
+
58
+ handleError (err, res) {
59
+ wlogger.error('Error in ControlRESTController:', err)
60
+
61
+ const status = err.status || 500
62
+ const message = err.message || 'Internal server error'
63
+
64
+ return res.status(status).json({ error: message })
65
+ }
66
+ }
67
+
68
+ export default ControlRESTController
@@ -0,0 +1,51 @@
1
+ /*
2
+ REST API router for /full-node/control routes.
3
+ */
4
+
5
+ import express from 'express'
6
+ import ControlRESTController from './controller.js'
7
+
8
+ class ControlRouter {
9
+ constructor (localConfig = {}) {
10
+ this.adapters = localConfig.adapters
11
+ if (!this.adapters) {
12
+ throw new Error(
13
+ 'Instance of Adapters library required when instantiating Control REST Router.'
14
+ )
15
+ }
16
+
17
+ this.useCases = localConfig.useCases
18
+ if (!this.useCases) {
19
+ throw new Error(
20
+ 'Instance of Use Cases library required when instantiating Control REST Router.'
21
+ )
22
+ }
23
+
24
+ const dependencies = {
25
+ adapters: this.adapters,
26
+ useCases: this.useCases
27
+ }
28
+
29
+ this.controlController = new ControlRESTController(dependencies)
30
+
31
+ this.apiPrefix = (localConfig.apiPrefix || '').replace(/\/$/, '')
32
+ this.baseUrl = `${this.apiPrefix}/full-node/control`
33
+ if (!this.baseUrl.startsWith('/')) {
34
+ this.baseUrl = `/${this.baseUrl}`
35
+ }
36
+ this.router = express.Router()
37
+ }
38
+
39
+ attach (app) {
40
+ if (!app) {
41
+ throw new Error('Must pass app object when attaching REST API controllers.')
42
+ }
43
+
44
+ this.router.get('/', this.controlController.root)
45
+ this.router.get('/getNetworkInfo', this.controlController.getNetworkInfo)
46
+
47
+ app.use(this.baseUrl, this.router)
48
+ }
49
+ }
50
+
51
+ export default ControlRouter
@@ -0,0 +1,90 @@
1
+ /*
2
+ REST API Controller for the /full-node/dsproof routes.
3
+ */
4
+
5
+ import wlogger from '../../../../adapters/wlogger.js'
6
+
7
+ class DSProofRESTController {
8
+ constructor (localConfig = {}) {
9
+ this.adapters = localConfig.adapters
10
+ if (!this.adapters) {
11
+ throw new Error(
12
+ 'Instance of Adapters library required when instantiating DSProof REST Controller.'
13
+ )
14
+ }
15
+
16
+ this.useCases = localConfig.useCases
17
+ if (!this.useCases || !this.useCases.dsproof) {
18
+ throw new Error(
19
+ 'Instance of DSProof use cases required when instantiating DSProof REST Controller.'
20
+ )
21
+ }
22
+
23
+ this.dsproofUseCases = this.useCases.dsproof
24
+
25
+ this.root = this.root.bind(this)
26
+ this.getDSProof = this.getDSProof.bind(this)
27
+ this.handleError = this.handleError.bind(this)
28
+ }
29
+
30
+ /**
31
+ * @api {get} /v6/full-node/dsproof/ Service status
32
+ * @apiName DSProofRoot
33
+ * @apiGroup DSProof
34
+ *
35
+ * @apiDescription Returns the status of the dsproof service.
36
+ *
37
+ * @apiSuccess {String} status Service identifier
38
+ */
39
+ async root (req, res) {
40
+ return res.status(200).json({ status: 'dsproof' })
41
+ }
42
+
43
+ /**
44
+ * @api {get} /v6/full-node/dsproof/getDSProof/:txid Get Double-Spend Proof
45
+ * @apiName GetDSProof
46
+ * @apiGroup DSProof
47
+ * @apiDescription Get information for a double-spend proof.
48
+ *
49
+ * @apiParam {String} txid Transaction ID
50
+ * @apiParam {String} verbose Verbose level (`false`, `true`) for compatibility with legacy API
51
+ */
52
+ async getDSProof (req, res) {
53
+ try {
54
+ const txid = req.params.txid
55
+
56
+ if (!txid) {
57
+ return res.status(400).json({
58
+ success: false,
59
+ error: 'txid can not be empty'
60
+ })
61
+ }
62
+
63
+ if (txid.length !== 64) {
64
+ return res.status(400).json({
65
+ success: false,
66
+ error: `txid must be of length 64 (not ${txid.length})`
67
+ })
68
+ }
69
+
70
+ let verbose = 2
71
+ if (req.query.verbose === 'true') verbose = 3
72
+
73
+ const result = await this.dsproofUseCases.getDSProof({ txid, verbose })
74
+ return res.status(200).json(result)
75
+ } catch (err) {
76
+ return this.handleError(err, res)
77
+ }
78
+ }
79
+
80
+ handleError (err, res) {
81
+ wlogger.error('Error in DSProofRESTController:', err)
82
+
83
+ const status = err.status || 500
84
+ const message = err.message || 'Internal server error'
85
+
86
+ return res.status(status).json({ error: message })
87
+ }
88
+ }
89
+
90
+ export default DSProofRESTController
@@ -0,0 +1,51 @@
1
+ /*
2
+ REST API router for /full-node/dsproof routes.
3
+ */
4
+
5
+ import express from 'express'
6
+ import DSProofRESTController from './controller.js'
7
+
8
+ class DSProofRouter {
9
+ constructor (localConfig = {}) {
10
+ this.adapters = localConfig.adapters
11
+ if (!this.adapters) {
12
+ throw new Error(
13
+ 'Instance of Adapters library required when instantiating DSProof REST Router.'
14
+ )
15
+ }
16
+
17
+ this.useCases = localConfig.useCases
18
+ if (!this.useCases) {
19
+ throw new Error(
20
+ 'Instance of Use Cases library required when instantiating DSProof REST Router.'
21
+ )
22
+ }
23
+
24
+ const dependencies = {
25
+ adapters: this.adapters,
26
+ useCases: this.useCases
27
+ }
28
+
29
+ this.dsproofController = new DSProofRESTController(dependencies)
30
+
31
+ this.apiPrefix = (localConfig.apiPrefix || '').replace(/\/$/, '')
32
+ this.baseUrl = `${this.apiPrefix}/full-node/dsproof`
33
+ if (!this.baseUrl.startsWith('/')) {
34
+ this.baseUrl = `/${this.baseUrl}`
35
+ }
36
+ this.router = express.Router()
37
+ }
38
+
39
+ attach (app) {
40
+ if (!app) {
41
+ throw new Error('Must pass app object when attaching REST API controllers.')
42
+ }
43
+
44
+ this.router.get('/', this.dsproofController.root)
45
+ this.router.get('/getDSProof/:txid', this.dsproofController.getDSProof)
46
+
47
+ app.use(this.baseUrl, this.router)
48
+ }
49
+ }
50
+
51
+ export default DSProofRouter
@@ -8,6 +8,8 @@
8
8
  // import EventRouter from './event/index.js'
9
9
  // import ReqRouter from './req/index.js'
10
10
  import BlockchainRouter from './full-node/blockchain/index.js'
11
+ import ControlRouter from './full-node/control/index.js'
12
+ import DSProofRouter from './full-node/dsproof/index.js'
11
13
  import config from '../../config/index.js'
12
14
 
13
15
  class RESTControllers {
@@ -26,6 +28,12 @@ class RESTControllers {
26
28
  )
27
29
  }
28
30
 
31
+ // Allow overriding the API prefix for testing, default to v6.
32
+ this.apiPrefix = localConfig.apiPrefix || '/v6'
33
+ if (this.apiPrefix.length > 1 && this.apiPrefix.endsWith('/')) {
34
+ this.apiPrefix = this.apiPrefix.slice(0, -1)
35
+ }
36
+
29
37
  // Bind 'this' object to all subfunctions.
30
38
  this.attachRESTControllers = this.attachRESTControllers.bind(this)
31
39
 
@@ -36,7 +44,8 @@ class RESTControllers {
36
44
  attachRESTControllers (app) {
37
45
  const dependencies = {
38
46
  adapters: this.adapters,
39
- useCases: this.useCases
47
+ useCases: this.useCases,
48
+ apiPrefix: this.apiPrefix
40
49
  }
41
50
 
42
51
  // Attach the REST API Controllers associated with the /event route
@@ -49,6 +58,12 @@ class RESTControllers {
49
58
 
50
59
  const blockchainRouter = new BlockchainRouter(dependencies)
51
60
  blockchainRouter.attach(app)
61
+
62
+ const controlRouter = new ControlRouter(dependencies)
63
+ controlRouter.attach(app)
64
+
65
+ const dsproofRouter = new DSProofRouter(dependencies)
66
+ dsproofRouter.attach(app)
52
67
  }
53
68
  }
54
69
 
@@ -0,0 +1,24 @@
1
+ /*
2
+ Use cases for interacting with the BCH full node control RPC interface.
3
+ */
4
+
5
+ class ControlUseCases {
6
+ constructor (localConfig = {}) {
7
+ this.adapters = localConfig.adapters
8
+
9
+ if (!this.adapters) {
10
+ throw new Error('Adapters instance required when instantiating Control use cases.')
11
+ }
12
+
13
+ this.fullNode = this.adapters.fullNode
14
+ if (!this.fullNode) {
15
+ throw new Error('Full node adapter required when instantiating Control use cases.')
16
+ }
17
+ }
18
+
19
+ async getNetworkInfo () {
20
+ return this.fullNode.call('getnetworkinfo')
21
+ }
22
+ }
23
+
24
+ export default ControlUseCases
@@ -0,0 +1,24 @@
1
+ /*
2
+ Use cases for interacting with the BCH full node double-spend proof RPC interface.
3
+ */
4
+
5
+ class DSProofUseCases {
6
+ constructor (localConfig = {}) {
7
+ this.adapters = localConfig.adapters
8
+
9
+ if (!this.adapters) {
10
+ throw new Error('Adapters instance required when instantiating DSProof use cases.')
11
+ }
12
+
13
+ this.fullNode = this.adapters.fullNode
14
+ if (!this.fullNode) {
15
+ throw new Error('Full node adapter required when instantiating DSProof use cases.')
16
+ }
17
+ }
18
+
19
+ async getDSProof ({ txid, verbose }) {
20
+ return this.fullNode.call('getdsproof', [txid, verbose])
21
+ }
22
+ }
23
+
24
+ export default DSProofUseCases
@@ -6,6 +6,8 @@
6
6
 
7
7
  // Local libraries
8
8
  import BlockchainUseCases from './full-node-blockchain-use-cases.js'
9
+ import ControlUseCases from './full-node-control-use-cases.js'
10
+ import DSProofUseCases from './full-node-dsproof-use-cases.js'
9
11
 
10
12
  class UseCases {
11
13
  constructor (localConfig = {}) {
@@ -17,6 +19,8 @@ class UseCases {
17
19
  }
18
20
 
19
21
  this.blockchain = new BlockchainUseCases({ adapters: this.adapters })
22
+ this.control = new ControlUseCases({ adapters: this.adapters })
23
+ this.dsproof = new DSProofUseCases({ adapters: this.adapters })
20
24
  }
21
25
 
22
26
  // Run any startup Use Cases at the start of the app.
@@ -0,0 +1,88 @@
1
+ /*
2
+ Unit tests for ControlRESTController.
3
+ */
4
+
5
+ import { assert } from 'chai'
6
+ import sinon from 'sinon'
7
+
8
+ import ControlRESTController from '../../../src/controllers/rest-api/full-node/control/controller.js'
9
+ import { createMockRequest, createMockResponse } from '../mocks/controller-mocks.js'
10
+
11
+ describe('#control-controller.js', () => {
12
+ let sandbox
13
+ let mockAdapters
14
+ let mockUseCases
15
+ let uut
16
+
17
+ beforeEach(() => {
18
+ sandbox = sinon.createSandbox()
19
+ mockAdapters = {}
20
+ mockUseCases = {
21
+ control: {
22
+ getNetworkInfo: sandbox.stub().resolves({ version: 1 })
23
+ }
24
+ }
25
+
26
+ uut = new ControlRESTController({
27
+ adapters: mockAdapters,
28
+ useCases: mockUseCases
29
+ })
30
+ })
31
+
32
+ afterEach(() => {
33
+ sandbox.restore()
34
+ })
35
+
36
+ describe('#constructor()', () => {
37
+ it('should require adapters', () => {
38
+ assert.throws(() => {
39
+ // eslint-disable-next-line no-new
40
+ new ControlRESTController({ useCases: mockUseCases })
41
+ }, /Adapters library required/)
42
+ })
43
+
44
+ it('should require control use cases', () => {
45
+ assert.throws(() => {
46
+ // eslint-disable-next-line no-new
47
+ new ControlRESTController({ adapters: mockAdapters, useCases: {} })
48
+ }, /Control use cases required/)
49
+ })
50
+ })
51
+
52
+ describe('#root()', () => {
53
+ it('should return control status', async () => {
54
+ const req = createMockRequest()
55
+ const res = createMockResponse()
56
+
57
+ await uut.root(req, res)
58
+
59
+ assert.equal(res.statusValue, 200)
60
+ assert.deepEqual(res.jsonData, { status: 'control' })
61
+ })
62
+ })
63
+
64
+ describe('#getNetworkInfo()', () => {
65
+ it('should return network info on success', async () => {
66
+ const req = createMockRequest()
67
+ const res = createMockResponse()
68
+
69
+ await uut.getNetworkInfo(req, res)
70
+
71
+ assert.equal(res.statusValue, 200)
72
+ assert.deepEqual(res.jsonData, { version: 1 })
73
+ })
74
+
75
+ it('should handle errors via handleError', async () => {
76
+ const error = new Error('failure')
77
+ error.status = 503
78
+ mockUseCases.control.getNetworkInfo.rejects(error)
79
+ const req = createMockRequest()
80
+ const res = createMockResponse()
81
+
82
+ await uut.getNetworkInfo(req, res)
83
+
84
+ assert.equal(res.statusValue, 503)
85
+ assert.deepEqual(res.jsonData, { error: 'failure' })
86
+ })
87
+ })
88
+ })
@@ -0,0 +1,117 @@
1
+ /*
2
+ Unit tests for DSProofRESTController.
3
+ */
4
+
5
+ import { assert } from 'chai'
6
+ import sinon from 'sinon'
7
+
8
+ import DSProofRESTController from '../../../src/controllers/rest-api/full-node/dsproof/controller.js'
9
+ import { createMockRequest, createMockResponse } from '../mocks/controller-mocks.js'
10
+
11
+ describe('#dsproof-controller.js', () => {
12
+ let sandbox
13
+ let mockAdapters
14
+ let mockUseCases
15
+ let uut
16
+
17
+ beforeEach(() => {
18
+ sandbox = sinon.createSandbox()
19
+ mockAdapters = {}
20
+ mockUseCases = {
21
+ dsproof: {
22
+ getDSProof: sandbox.stub().resolves({ proof: true })
23
+ }
24
+ }
25
+
26
+ uut = new DSProofRESTController({
27
+ adapters: mockAdapters,
28
+ useCases: mockUseCases
29
+ })
30
+ })
31
+
32
+ afterEach(() => {
33
+ sandbox.restore()
34
+ })
35
+
36
+ describe('#constructor()', () => {
37
+ it('should require adapters', () => {
38
+ assert.throws(() => {
39
+ // eslint-disable-next-line no-new
40
+ new DSProofRESTController({ useCases: mockUseCases })
41
+ }, /Adapters library required/)
42
+ })
43
+
44
+ it('should require dsproof use cases', () => {
45
+ assert.throws(() => {
46
+ // eslint-disable-next-line no-new
47
+ new DSProofRESTController({ adapters: mockAdapters, useCases: {} })
48
+ }, /DSProof use cases required/)
49
+ })
50
+ })
51
+
52
+ describe('#root()', () => {
53
+ it('should return dsproof status', async () => {
54
+ const req = createMockRequest()
55
+ const res = createMockResponse()
56
+
57
+ await uut.root(req, res)
58
+
59
+ assert.equal(res.statusValue, 200)
60
+ assert.deepEqual(res.jsonData, { status: 'dsproof' })
61
+ })
62
+ })
63
+
64
+ describe('#getDSProof()', () => {
65
+ it('should validate txid presence', async () => {
66
+ const req = createMockRequest()
67
+ const res = createMockResponse()
68
+
69
+ await uut.getDSProof(req, res)
70
+
71
+ assert.equal(res.statusValue, 400)
72
+ assert.include(res.jsonData.error, 'txid can not be empty')
73
+ })
74
+
75
+ it('should validate txid length', async () => {
76
+ const req = createMockRequest({ params: { txid: 'abc' } })
77
+ const res = createMockResponse()
78
+
79
+ await uut.getDSProof(req, res)
80
+
81
+ assert.equal(res.statusValue, 400)
82
+ assert.include(res.jsonData.error, 'txid must be of length 64')
83
+ })
84
+
85
+ it('should call use case with derived verbose when valid', async () => {
86
+ const txid = 'a'.repeat(64)
87
+ const req = createMockRequest({
88
+ params: { txid },
89
+ query: { verbose: 'true' }
90
+ })
91
+ const res = createMockResponse()
92
+
93
+ await uut.getDSProof(req, res)
94
+
95
+ assert.equal(res.statusValue, 200)
96
+ assert.isTrue(mockUseCases.dsproof.getDSProof.calledOnceWithExactly({
97
+ txid,
98
+ verbose: 3
99
+ }))
100
+ assert.deepEqual(res.jsonData, { proof: true })
101
+ })
102
+
103
+ it('should handle errors via handleError', async () => {
104
+ const txid = 'a'.repeat(64)
105
+ const error = new Error('failure')
106
+ error.status = 422
107
+ mockUseCases.dsproof.getDSProof.rejects(error)
108
+ const req = createMockRequest({ params: { txid } })
109
+ const res = createMockResponse()
110
+
111
+ await uut.getDSProof(req, res)
112
+
113
+ assert.equal(res.statusValue, 422)
114
+ assert.deepEqual(res.jsonData, { error: 'failure' })
115
+ })
116
+ })
117
+ })
@@ -7,6 +7,8 @@ import sinon from 'sinon'
7
7
 
8
8
  import RESTControllers from '../../../src/controllers/rest-api/index.js'
9
9
  import BlockchainRouter from '../../../src/controllers/rest-api/full-node/blockchain/index.js'
10
+ import ControlRouter from '../../../src/controllers/rest-api/full-node/control/index.js'
11
+ import DSProofRouter from '../../../src/controllers/rest-api/full-node/dsproof/index.js'
10
12
 
11
13
  describe('#controllers/rest-api/index.js', () => {
12
14
  let sandbox
@@ -43,7 +45,13 @@ describe('#controllers/rest-api/index.js', () => {
43
45
  }
44
46
  }
45
47
  mockUseCases = {
46
- blockchain: createBlockchainUseCaseStubs()
48
+ blockchain: createBlockchainUseCaseStubs(),
49
+ control: {
50
+ getNetworkInfo: () => {}
51
+ },
52
+ dsproof: {
53
+ getDSProof: () => {}
54
+ }
47
55
  }
48
56
  })
49
57
 
@@ -68,8 +76,10 @@ describe('#controllers/rest-api/index.js', () => {
68
76
  })
69
77
 
70
78
  describe('#attachRESTControllers()', () => {
71
- it('should instantiate blockchain router and attach to app', () => {
72
- const attachStub = sandbox.stub(BlockchainRouter.prototype, 'attach')
79
+ it('should instantiate routers and attach to app', () => {
80
+ const blockchainAttachStub = sandbox.stub(BlockchainRouter.prototype, 'attach')
81
+ const controlAttachStub = sandbox.stub(ControlRouter.prototype, 'attach')
82
+ const dsproofAttachStub = sandbox.stub(DSProofRouter.prototype, 'attach')
73
83
  const restControllers = new RESTControllers({
74
84
  adapters: mockAdapters,
75
85
  useCases: mockUseCases
@@ -78,8 +88,12 @@ describe('#controllers/rest-api/index.js', () => {
78
88
 
79
89
  restControllers.attachRESTControllers(app)
80
90
 
81
- assert.isTrue(attachStub.calledOnce)
82
- assert.equal(attachStub.getCall(0).args[0], app)
91
+ assert.isTrue(blockchainAttachStub.calledOnce)
92
+ assert.equal(blockchainAttachStub.getCall(0).args[0], app)
93
+ assert.isTrue(controlAttachStub.calledOnce)
94
+ assert.equal(controlAttachStub.getCall(0).args[0], app)
95
+ assert.isTrue(dsproofAttachStub.calledOnce)
96
+ assert.equal(dsproofAttachStub.getCall(0).args[0], app)
83
97
  })
84
98
  })
85
99
  })
@@ -0,0 +1,53 @@
1
+ /*
2
+ Unit tests for ControlUseCases.
3
+ */
4
+
5
+ import { assert } from 'chai'
6
+
7
+ import ControlUseCases from '../../../src/use-cases/full-node-control-use-cases.js'
8
+
9
+ describe('#full-node-control-use-cases.js', () => {
10
+ let mockAdapters
11
+ let uut
12
+
13
+ beforeEach(() => {
14
+ mockAdapters = {
15
+ fullNode: {
16
+ call: async () => ({})
17
+ }
18
+ }
19
+
20
+ uut = new ControlUseCases({ adapters: mockAdapters })
21
+ })
22
+
23
+ describe('#constructor()', () => {
24
+ it('should require adapters', () => {
25
+ assert.throws(() => {
26
+ // eslint-disable-next-line no-new
27
+ new ControlUseCases()
28
+ }, /Adapters instance required/)
29
+ })
30
+
31
+ it('should require full node adapter', () => {
32
+ assert.throws(() => {
33
+ // eslint-disable-next-line no-new
34
+ new ControlUseCases({ adapters: {} })
35
+ }, /Full node adapter required/)
36
+ })
37
+ })
38
+
39
+ describe('#getNetworkInfo()', () => {
40
+ it('should call full node adapter with correct method', async () => {
41
+ let capturedMethod = ''
42
+ mockAdapters.fullNode.call = async method => {
43
+ capturedMethod = method
44
+ return { version: 1 }
45
+ }
46
+
47
+ const result = await uut.getNetworkInfo()
48
+
49
+ assert.equal(capturedMethod, 'getnetworkinfo')
50
+ assert.deepEqual(result, { version: 1 })
51
+ })
52
+ })
53
+ })
@@ -0,0 +1,54 @@
1
+ /*
2
+ Unit tests for DSProofUseCases.
3
+ */
4
+
5
+ import { assert } from 'chai'
6
+
7
+ import DSProofUseCases from '../../../src/use-cases/full-node-dsproof-use-cases.js'
8
+
9
+ describe('#full-node-dsproof-use-cases.js', () => {
10
+ let mockAdapters
11
+ let uut
12
+
13
+ beforeEach(() => {
14
+ mockAdapters = {
15
+ fullNode: {
16
+ call: async () => ({})
17
+ }
18
+ }
19
+
20
+ uut = new DSProofUseCases({ adapters: mockAdapters })
21
+ })
22
+
23
+ describe('#constructor()', () => {
24
+ it('should require adapters', () => {
25
+ assert.throws(() => {
26
+ // eslint-disable-next-line no-new
27
+ new DSProofUseCases()
28
+ }, /Adapters instance required/)
29
+ })
30
+
31
+ it('should require full node adapter', () => {
32
+ assert.throws(() => {
33
+ // eslint-disable-next-line no-new
34
+ new DSProofUseCases({ adapters: {} })
35
+ }, /Full node adapter required/)
36
+ })
37
+ })
38
+
39
+ describe('#getDSProof()', () => {
40
+ it('should pass txid and verbose parameters to adapter', async () => {
41
+ let capturedArgs = null
42
+ mockAdapters.fullNode.call = async (method, params) => {
43
+ capturedArgs = { method, params }
44
+ return { success: true }
45
+ }
46
+
47
+ const result = await uut.getDSProof({ txid: 'a'.repeat(64), verbose: 2 })
48
+
49
+ assert.equal(capturedArgs.method, 'getdsproof')
50
+ assert.deepEqual(capturedArgs.params, ['a'.repeat(64), 2])
51
+ assert.deepEqual(result, { success: true })
52
+ })
53
+ })
54
+ })