diff --git a/src/app.js b/src/app.js index 8830854..a87dff3 100644 --- a/src/app.js +++ b/src/app.js @@ -46,6 +46,7 @@ const ControlV5 = require('./routes/v5/full-node/control') const MiningV5 = require('./routes/v5/full-node/mining') const networkV5 = require('./routes/v5/full-node/network') const RawtransactionsV5 = require('./routes/v5/full-node/rawtransactions') +const DSProofV5 = require('./routes/v5/full-node/dsproof') const UtilV5 = require('./routes/v5/util') const SlpV5 = require('./routes/v5/slp') const xpubV5 = require('./routes/v5/xpub') @@ -78,7 +79,7 @@ const electrumxv5 = new ElectrumXV5() const encryptionv5 = new EncryptionV5() const pricev5 = new PriceV5() const utilV5 = new UtilV5({ electrumx: electrumxv5 }) - +const dsproofV5 = new DSProofV5() const app = express() app.locals.env = process.env @@ -187,6 +188,7 @@ app.use(`/${v5prefix}/` + 'electrumx', electrumxv5.router) app.use(`/${v5prefix}/` + 'encryption', encryptionv5.router) app.use(`/${v5prefix}/` + 'price', pricev5.router) app.use(`/${v5prefix}/` + 'util', utilV5.router) +app.use(`/${v5prefix}/` + 'dsproof', dsproofV5.router) // const ninsight = new Ninsight() app.use(`/${v5prefix}/` + 'ninsight', ninsight.router) diff --git a/src/routes/v5/full-node/dsproof.js b/src/routes/v5/full-node/dsproof.js new file mode 100644 index 0000000..905d4d3 --- /dev/null +++ b/src/routes/v5/full-node/dsproof.js @@ -0,0 +1,95 @@ +'use strict' + +const express = require('express') +const router = express.Router() + +const axios = require('axios') +const wlogger = require('../../../util/winston-logging') + +const RouteUtils = require('../../../util/route-utils') +const routeUtils = new RouteUtils() + +// Used to convert error messages to strings, to safely pass to users. +const util = require('util') +util.inspect.defaultOptions = { depth: 1 } + +let _this +class DSProof { + constructor () { + _this = this + _this.axios = axios + _this.routeUtils = routeUtils + + _this.router = router + _this.router.get('/', this.root) + _this.router.get('/getdsproof/:txid', _this.getDSProof) + } + + // DRY error handler. + errorHandler (err, res) { + // Attempt to decode the error message. + const { msg, status } = _this.routeUtils.decodeError(err) + if (msg) { + res.status(status) + return res.json({ error: msg }) + } + + res.status(500) + return res.json({ error: util.inspect(err) }) + } + + root (req, res, next) { + return res.json({ status: 'dsproof' }) + } + + /** + * @api {get} /dsproof/getdsproof/:txid Get Double-Spend Proof. + * @apiName DS Proof. + * @apiGroup DSProof + * @apiDescription Get information for a double-spend proof. + * + * + * @apiExample Example usage: + * curl -X GET "https://api.fullstack.cash/v5/dsproof/getdsproof/a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d" -H "accept: application/json" + * + * + */ + async getDSProof (req, res, next) { + try { + const txid = req.params.txid + + let verbose = 2 // default + if (req.query.verbose === 'true') verbose = 3 + + if (!txid || txid === '') { + res.status(400) + return res.json({ + success: false, + error: 'txid can not be empty' + }) + } + + if (txid.length !== 64) { + res.status(400) + return res.json({ + success: false, + error: `txid must be of length 64 (not ${txid.length})` + }) + } + + const options = _this.routeUtils.getAxiosOptions() + options.data.id = 'getdsproof' + options.data.method = 'getdsproof' + options.data.params = [txid, verbose] + + const response = await _this.axios.request(options) + return res.json(response.data.result) + } catch (err) { + wlogger.error('Error in dsproof.js/getDSProof().', err) + + return _this.errorHandler(err, res) + } + } +} + +module.exports = DSProof diff --git a/test/v5/dsproof.js b/test/v5/dsproof.js new file mode 100644 index 0000000..e0d73dd --- /dev/null +++ b/test/v5/dsproof.js @@ -0,0 +1,190 @@ +/* + TESTS FOR THE DSPROOF.JS LIBRARY + + This test file uses the environment variable TEST to switch between unit + and integration tests. By default, TEST is set to 'unit'. Set this variable + to 'integration' to run the tests against BCH mainnet. +*/ + +'use strict' + +const chai = require('chai') +const assert = chai.assert +const DSProofRoute = require('../../src/routes/v5/full-node/dsproof') +const uut = new DSProofRoute() + +// const nock = require('nock') // HTTP mocking +const sinon = require('sinon') + +let originalEnvVars // Used during transition from integration to unit tests. + +// Mocking data. +const { mockReq, mockRes } = require('./mocks/express-mocks') +const mockData = require('./mocks/dsproof-mocks') + +// Used for debugging. +const util = require('util') +util.inspect.defaultOptions = { depth: 1 } + +describe('#DSProof', () => { + let req, res + let sandbox + before(() => { + // Save existing environment variables. + originalEnvVars = { + BITCOINCOM_BASEURL: process.env.BITCOINCOM_BASEURL, + RPC_BASEURL: process.env.RPC_BASEURL, + RPC_USERNAME: process.env.RPC_USERNAME, + RPC_PASSWORD: process.env.RPC_PASSWORD + } + + // Set default environment variables for unit tests. + if (!process.env.TEST) process.env.TEST = 'unit' + if (process.env.TEST === 'unit') { + process.env.BITCOINCOM_BASEURL = 'http://fakeurl/api/' + process.env.RPC_BASEURL = 'http://fakeurl/api' + process.env.RPC_USERNAME = 'fakeusername' + process.env.RPC_PASSWORD = 'fakepassword' + } + }) + + // Setup the mocks before each test. + beforeEach(() => { + // Mock the req and res objects used by Express routes. + req = mockReq + res = mockRes + + // Explicitly reset the parmas and body. + req.params = {} + req.body = {} + req.query = {} + + sandbox = sinon.createSandbox() + }) + + afterEach(() => { + // Restore Sandbox + sandbox.restore() + }) + + after(() => { + // Restore any pre-existing environment variables. + process.env.BITCOINCOM_BASEURL = originalEnvVars.BITCOINCOM_BASEURL + process.env.RPC_BASEURL = originalEnvVars.RPC_BASEURL + process.env.RPC_USERNAME = originalEnvVars.RPC_USERNAME + process.env.RPC_PASSWORD = originalEnvVars.RPC_PASSWORD + }) + + describe('#root', async () => { + // root route handler. + it('should respond to GET for base route', async () => { + const result = uut.root(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.equal(result.status, 'dsproof', 'Returns static string') + }) + }) + + describe('#getDSProof', async () => { + it('should throw 400 error if txid is missing', async () => { + const result = await uut.getDSProof(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ['error', 'success']) + assert.isFalse(result.success) + assert.include(result.error, 'txid can not be empty') + }) + it('should throw 400 error if txid is invalid', async () => { + req.params.txid = 'abc123' + + const result = await uut.getDSProof(req, res) + + assert.hasAllKeys(result, ['error', 'success']) + assert.isFalse(result.success) + + assert.equal(res.statusCode, 400, 'HTTP status code 400 expected.') + assert.include(result.error, 'txid must be of length 64') + }) + it('should throw 503 when network issues', async () => { + req.params.txid = + 'ee0df780b58f6f24467605b2589c44c3a50fc849fb8f91b89669a4ae0d86bc7e' + + // Save the existing RPC URL. + const savedUrl2 = process.env.RPC_BASEURL + + // Manipulate the URL to cause a 500 network error. + process.env.RPC_BASEURL = 'http://fakeurl/api/' + + await uut.getDSProof(req, res) + // console.log(`result: ${util.inspect(result)}`) + + // Restore the saved URL. + process.env.RPC_BASEURL = savedUrl2 + + assert.isAbove( + res.statusCode, + 499, + 'HTTP status code 500 or greater expected.' + ) + // assert.include(result.error,"Network error: Could not communicate with full node","Error message expected") + }) + it('returns proper error when downstream service stalls', async () => { + req.params.txid = + 'ee0df780b58f6f24467605b2589c44c3a50fc849fb8f91b89669a4ae0d86bc7e' + // Mock the timeout error. + sandbox.stub(uut.axios, 'request').throws({ code: 'ECONNABORTED' }) + + const result = await uut.getDSProof(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + + it('returns proper error when downstream service is down', async () => { + req.params.txid = + 'ee0df780b58f6f24467605b2589c44c3a50fc849fb8f91b89669a4ae0d86bc7e' + // Mock the timeout error. + sandbox.stub(uut.axios, 'request').throws({ code: 'ECONNREFUSED' }) + + const result = await uut.getDSProof(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + + it('should GET double-spend proof', async function () { + req.params.txid = + 'ee0df780b58f6f24467605b2589c44c3a50fc849fb8f91b89669a4ae0d86bc7e' + // Mock the RPC call for unit tests. + if (process.env.TEST === 'unit') { + sandbox + .stub(uut.axios, 'request') + .resolves({ data: { result: mockData.mockGetDsProof } }) + } else { + return this.skip() + } + + const result = await uut.getDSProof(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.property(result, 'dspid') + assert.property(result, 'txid') + assert.property(result, 'outpoint') + assert.property(result, 'path') + assert.property(result, 'descendants') + + assert.property(result.outpoint, 'txid') + assert.property(result.outpoint, 'vout') + }) + }) +}) diff --git a/test/v5/mocks/dsproof-mocks.js b/test/v5/mocks/dsproof-mocks.js new file mode 100644 index 0000000..6412cd6 --- /dev/null +++ b/test/v5/mocks/dsproof-mocks.js @@ -0,0 +1,22 @@ +/* + This library contains mocking data for running unit tests on the dsproof route. +*/ + +'use strict' + +const mockGetDsProof = { + dspid: '6234fff2a9dbcaa50e2c50d74e1d725a2bed1757d7a05a2db7a82df659554397', + txid: 'ee0df780b58f6f24467605b2589c44c3a50fc849fb8f91b89669a4ae0d86bc7e', + outpoint: { + txid: '17ebef34f7e9a0692317dd6fbb43ffccff45b7d688a2a754dbccfc5e6d93ce3e', + vout: 1 + }, + path: ['ee0df780b58f6f24467605b2589c44c3a50fc849fb8f91b89669a4ae0d86bc7e'], + descendants: [ + 'ee0df780b58f6f24467605b2589c44c3a50fc849fb8f91b89669a4ae0d86bc7e' + ] +} + +module.exports = { + mockGetDsProof +}