fix(control): Added control and DS Proof endpoints

This commit is contained in:
Chris Troutner
2025-11-09 19:48:13 -08:00
parent 230853ed11
commit 865993c1a9
13 changed files with 643 additions and 5 deletions
@@ -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} /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} /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,47 @@
/*
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.baseUrl = '/full-node/control'
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} /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} /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,47 @@
/*
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.baseUrl = '/full-node/dsproof'
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
View File
@@ -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 {
@@ -49,6 +51,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
+4
View File
@@ -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' })
})
})
})
+19 -5
View File
@@ -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 })
})
})
})