diff --git a/src/controllers/rest-api/full-node/mining/controller.js b/src/controllers/rest-api/full-node/mining/controller.js new file mode 100644 index 0000000..bc98baa --- /dev/null +++ b/src/controllers/rest-api/full-node/mining/controller.js @@ -0,0 +1,99 @@ +/* + REST API Controller for the /full-node/mining routes. +*/ + +import wlogger from '../../../../adapters/wlogger.js' + +class MiningRESTController { + constructor (localConfig = {}) { + this.adapters = localConfig.adapters + if (!this.adapters) { + throw new Error( + 'Instance of Adapters library required when instantiating Mining REST Controller.' + ) + } + + this.useCases = localConfig.useCases + if (!this.useCases || !this.useCases.mining) { + throw new Error( + 'Instance of Mining use cases required when instantiating Mining REST Controller.' + ) + } + + this.miningUseCases = this.useCases.mining + + // Bind functions + this.root = this.root.bind(this) + this.getMiningInfo = this.getMiningInfo.bind(this) + this.getNetworkHashPS = this.getNetworkHashPS.bind(this) + this.handleError = this.handleError.bind(this) + } + + /** + * @api {get} /v6/full-node/mining/ Service status + * @apiName MiningRoot + * @apiGroup Mining + * + * @apiDescription Returns the status of the mining service. + * + * @apiSuccess {String} status Service identifier + */ + async root (req, res) { + return res.status(200).json({ status: 'mining' }) + } + + /** + * @api {get} /v6/full-node/mining/getMiningInfo Get Mining Info + * @apiName GetMiningInfo + * @apiGroup Mining + * @apiDescription Returns a json object containing mining-related information. + * + * @apiExample Example usage: + * curl -X GET "https://api.fullstack.cash/v6/full-node/mining/getMiningInfo" -H "accept: application/json" + */ + async getMiningInfo (req, res) { + try { + const result = await this.miningUseCases.getMiningInfo() + return res.status(200).json(result) + } catch (err) { + return this.handleError(err, res) + } + } + + /** + * @api {get} /v6/full-node/mining/getNetworkHashPS Get Estimated network hashes per second + * @apiName GetNetworkHashPS + * @apiGroup Mining + * @apiDescription Returns the estimated network hashes per second based on the last n blocks. Pass in [nblocks] to override # of blocks, -1 specifies since last difficulty change. Pass in [height] to estimate the network speed at the time when a certain block was found. + * + * @apiParam {Number} nblocks Number of blocks to use for estimation (default: 120) + * @apiParam {Number} height Block height to estimate at (default: -1) + * + * @apiExample Example usage: + * curl -X GET "https://api.fullstack.cash/v6/full-node/mining/getNetworkHashPS?nblocks=120&height=-1" -H "accept: application/json" + */ + async getNetworkHashPS (req, res) { + try { + let nblocks = 120 // Default + let height = -1 // Default + if (req.query.nblocks) nblocks = parseInt(req.query.nblocks) + if (req.query.height) height = parseInt(req.query.height) + + const result = await this.miningUseCases.getNetworkHashPS({ nblocks, height }) + return res.status(200).json(result) + } catch (err) { + return this.handleError(err, res) + } + } + + handleError (err, res) { + wlogger.error('Error in MiningRESTController:', err) + + const status = err.status || 500 + const message = err.message || 'Internal server error' + + return res.status(status).json({ error: message }) + } +} + +export default MiningRESTController diff --git a/src/controllers/rest-api/full-node/mining/index.js b/src/controllers/rest-api/full-node/mining/index.js new file mode 100644 index 0000000..a71aead --- /dev/null +++ b/src/controllers/rest-api/full-node/mining/index.js @@ -0,0 +1,52 @@ +/* + REST API router for /full-node/mining routes. +*/ + +import express from 'express' +import MiningRESTController from './controller.js' + +class MiningRouter { + constructor (localConfig = {}) { + this.adapters = localConfig.adapters + if (!this.adapters) { + throw new Error( + 'Instance of Adapters library required when instantiating Mining REST Router.' + ) + } + + this.useCases = localConfig.useCases + if (!this.useCases) { + throw new Error( + 'Instance of Use Cases library required when instantiating Mining REST Router.' + ) + } + + const dependencies = { + adapters: this.adapters, + useCases: this.useCases + } + + this.miningController = new MiningRESTController(dependencies) + + this.apiPrefix = (localConfig.apiPrefix || '').replace(/\/$/, '') + this.baseUrl = `${this.apiPrefix}/full-node/mining` + 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.miningController.root) + this.router.get('/getMiningInfo', this.miningController.getMiningInfo) + this.router.get('/getNetworkHashPS', this.miningController.getNetworkHashPS) + + app.use(this.baseUrl, this.router) + } +} + +export default MiningRouter diff --git a/src/controllers/rest-api/index.js b/src/controllers/rest-api/index.js index a6d4397..67239d7 100644 --- a/src/controllers/rest-api/index.js +++ b/src/controllers/rest-api/index.js @@ -10,6 +10,7 @@ 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 MiningRouter from './full-node/mining/index.js' import config from '../../config/index.js' class RESTControllers { @@ -64,6 +65,9 @@ class RESTControllers { const dsproofRouter = new DSProofRouter(dependencies) dsproofRouter.attach(app) + + const miningRouter = new MiningRouter(dependencies) + miningRouter.attach(app) } } diff --git a/src/use-cases/full-node-mining-use-cases.js b/src/use-cases/full-node-mining-use-cases.js new file mode 100644 index 0000000..f2deba8 --- /dev/null +++ b/src/use-cases/full-node-mining-use-cases.js @@ -0,0 +1,28 @@ +/* + Use cases for interacting with the BCH full node mining RPC interface. +*/ + +class MiningUseCases { + constructor (localConfig = {}) { + this.adapters = localConfig.adapters + + if (!this.adapters) { + throw new Error('Adapters instance required when instantiating Mining use cases.') + } + + this.fullNode = this.adapters.fullNode + if (!this.fullNode) { + throw new Error('Full node adapter required when instantiating Mining use cases.') + } + } + + async getMiningInfo () { + return this.fullNode.call('getmininginfo') + } + + async getNetworkHashPS ({ nblocks, height }) { + return this.fullNode.call('getnetworkhashps', [nblocks, height]) + } +} + +export default MiningUseCases diff --git a/src/use-cases/index.js b/src/use-cases/index.js index d3441a9..229a783 100644 --- a/src/use-cases/index.js +++ b/src/use-cases/index.js @@ -8,6 +8,7 @@ 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' +import MiningUseCases from './full-node-mining-use-cases.js' class UseCases { constructor (localConfig = {}) { @@ -21,6 +22,7 @@ class UseCases { this.blockchain = new BlockchainUseCases({ adapters: this.adapters }) this.control = new ControlUseCases({ adapters: this.adapters }) this.dsproof = new DSProofUseCases({ adapters: this.adapters }) + this.mining = new MiningUseCases({ adapters: this.adapters }) } // Run any startup Use Cases at the start of the app. diff --git a/test/unit/controllers/mining-controller-unit.js b/test/unit/controllers/mining-controller-unit.js new file mode 100644 index 0000000..bdb4df3 --- /dev/null +++ b/test/unit/controllers/mining-controller-unit.js @@ -0,0 +1,139 @@ +/* + Unit tests for MiningRESTController. +*/ + +import { assert } from 'chai' +import sinon from 'sinon' + +import MiningRESTController from '../../../src/controllers/rest-api/full-node/mining/controller.js' +import { createMockRequest, createMockResponse } from '../mocks/controller-mocks.js' + +describe('#mining-controller.js', () => { + let sandbox + let mockAdapters + let mockUseCases + let uut + + beforeEach(() => { + sandbox = sinon.createSandbox() + mockAdapters = {} + mockUseCases = { + mining: { + getMiningInfo: sandbox.stub().resolves({ blocks: 100, difficulty: 1.5 }), + getNetworkHashPS: sandbox.stub().resolves(1234567890) + } + } + + uut = new MiningRESTController({ + adapters: mockAdapters, + useCases: mockUseCases + }) + }) + + afterEach(() => { + sandbox.restore() + }) + + describe('#constructor()', () => { + it('should require adapters', () => { + assert.throws(() => { + // eslint-disable-next-line no-new + new MiningRESTController({ useCases: mockUseCases }) + }, /Adapters library required/) + }) + + it('should require mining use cases', () => { + assert.throws(() => { + // eslint-disable-next-line no-new + new MiningRESTController({ adapters: mockAdapters, useCases: {} }) + }, /Mining use cases required/) + }) + }) + + describe('#root()', () => { + it('should return mining status', async () => { + const req = createMockRequest() + const res = createMockResponse() + + await uut.root(req, res) + + assert.equal(res.statusValue, 200) + assert.deepEqual(res.jsonData, { status: 'mining' }) + }) + }) + + describe('#getMiningInfo()', () => { + it('should return mining info on success', async () => { + const req = createMockRequest() + const res = createMockResponse() + + await uut.getMiningInfo(req, res) + + assert.equal(res.statusValue, 200) + assert.deepEqual(res.jsonData, { blocks: 100, difficulty: 1.5 }) + assert.isTrue(mockUseCases.mining.getMiningInfo.calledOnce) + }) + + it('should handle errors via handleError', async () => { + const error = new Error('failure') + error.status = 503 + mockUseCases.mining.getMiningInfo.rejects(error) + const req = createMockRequest() + const res = createMockResponse() + + await uut.getMiningInfo(req, res) + + assert.equal(res.statusValue, 503) + assert.deepEqual(res.jsonData, { error: 'failure' }) + }) + }) + + describe('#getNetworkHashPS()', () => { + it('should return network hash PS with default params', async () => { + const req = createMockRequest() + const res = createMockResponse() + + await uut.getNetworkHashPS(req, res) + + assert.equal(res.statusValue, 200) + assert.equal(res.jsonData, 1234567890) + assert.isTrue(mockUseCases.mining.getNetworkHashPS.calledOnce) + assert.deepEqual(mockUseCases.mining.getNetworkHashPS.firstCall.args[0], { + nblocks: 120, + height: -1 + }) + }) + + it('should parse query params for nblocks and height', async () => { + const req = createMockRequest({ + query: { + nblocks: '240', + height: '1000' + } + }) + const res = createMockResponse() + + await uut.getNetworkHashPS(req, res) + + assert.equal(res.statusValue, 200) + assert.isTrue(mockUseCases.mining.getNetworkHashPS.calledOnce) + assert.deepEqual(mockUseCases.mining.getNetworkHashPS.firstCall.args[0], { + nblocks: 240, + height: 1000 + }) + }) + + it('should handle errors via handleError', async () => { + const error = new Error('RPC error') + error.status = 500 + mockUseCases.mining.getNetworkHashPS.rejects(error) + const req = createMockRequest() + const res = createMockResponse() + + await uut.getNetworkHashPS(req, res) + + assert.equal(res.statusValue, 500) + assert.deepEqual(res.jsonData, { error: 'RPC error' }) + }) + }) +}) diff --git a/test/unit/controllers/rest-api-index-unit.js b/test/unit/controllers/rest-api-index-unit.js index 4b4a7f6..00dc9d9 100644 --- a/test/unit/controllers/rest-api-index-unit.js +++ b/test/unit/controllers/rest-api-index-unit.js @@ -9,6 +9,7 @@ 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' +import MiningRouter from '../../../src/controllers/rest-api/full-node/mining/index.js' describe('#controllers/rest-api/index.js', () => { let sandbox @@ -51,6 +52,10 @@ describe('#controllers/rest-api/index.js', () => { }, dsproof: { getDSProof: () => {} + }, + mining: { + getMiningInfo: () => {}, + getNetworkHashPS: () => {} } } }) @@ -80,6 +85,7 @@ describe('#controllers/rest-api/index.js', () => { const blockchainAttachStub = sandbox.stub(BlockchainRouter.prototype, 'attach') const controlAttachStub = sandbox.stub(ControlRouter.prototype, 'attach') const dsproofAttachStub = sandbox.stub(DSProofRouter.prototype, 'attach') + const miningAttachStub = sandbox.stub(MiningRouter.prototype, 'attach') const restControllers = new RESTControllers({ adapters: mockAdapters, useCases: mockUseCases @@ -94,6 +100,8 @@ describe('#controllers/rest-api/index.js', () => { assert.equal(controlAttachStub.getCall(0).args[0], app) assert.isTrue(dsproofAttachStub.calledOnce) assert.equal(dsproofAttachStub.getCall(0).args[0], app) + assert.isTrue(miningAttachStub.calledOnce) + assert.equal(miningAttachStub.getCall(0).args[0], app) }) }) }) diff --git a/test/unit/use-cases/full-node-mining-use-cases-unit.js b/test/unit/use-cases/full-node-mining-use-cases-unit.js new file mode 100644 index 0000000..815d946 --- /dev/null +++ b/test/unit/use-cases/full-node-mining-use-cases-unit.js @@ -0,0 +1,84 @@ +/* + Unit tests for MiningUseCases. +*/ + +import { assert } from 'chai' + +import MiningUseCases from '../../../src/use-cases/full-node-mining-use-cases.js' + +describe('#full-node-mining-use-cases.js', () => { + let mockAdapters + let uut + + beforeEach(() => { + mockAdapters = { + fullNode: { + call: async () => ({}) + } + } + + uut = new MiningUseCases({ adapters: mockAdapters }) + }) + + describe('#constructor()', () => { + it('should require adapters', () => { + assert.throws(() => { + // eslint-disable-next-line no-new + new MiningUseCases() + }, /Adapters instance required/) + }) + + it('should require full node adapter', () => { + assert.throws(() => { + // eslint-disable-next-line no-new + new MiningUseCases({ adapters: {} }) + }, /Full node adapter required/) + }) + }) + + describe('#getMiningInfo()', () => { + it('should call full node adapter with correct method', async () => { + let capturedMethod = '' + mockAdapters.fullNode.call = async method => { + capturedMethod = method + return { blocks: 100, difficulty: 1.5 } + } + + const result = await uut.getMiningInfo() + + assert.equal(capturedMethod, 'getmininginfo') + assert.deepEqual(result, { blocks: 100, difficulty: 1.5 }) + }) + }) + + describe('#getNetworkHashPS()', () => { + it('should call full node adapter with correct method and default params', async () => { + let capturedMethod = '' + let capturedParams = [] + mockAdapters.fullNode.call = async (method, params) => { + capturedMethod = method + capturedParams = params + return 1234567890 + } + + const result = await uut.getNetworkHashPS({ nblocks: 120, height: -1 }) + + assert.equal(capturedMethod, 'getnetworkhashps') + assert.deepEqual(capturedParams, [120, -1]) + assert.equal(result, 1234567890) + }) + + it('should call full node adapter with custom params', async () => { + let capturedParams = [] + mockAdapters.fullNode.call = async (method, params) => { + capturedParams = params + return 9876543210 + } + + const result = await uut.getNetworkHashPS({ nblocks: 240, height: 1000 }) + + assert.deepEqual(capturedParams, [240, 1000]) + assert.equal(result, 9876543210) + }) + }) +})