feat(rawtransactions): Adding full node raw transaction endpoints

This commit is contained in:
Chris Troutner
2025-11-14 06:28:15 -08:00
parent 4f89335768
commit e5c8fa9321
11 changed files with 1196 additions and 13 deletions
+2 -6
View File
@@ -113,12 +113,8 @@ class FullNodeRPCAdapter {
}
}
validateArraySize (length, options = {}) {
const { isProUser = false } = options
const freemiumLimit = Number(this.config.fullNode?.freemiumArrayLimit || 20)
const proLimit = Number(this.config.fullNode?.proArrayLimit || freemiumLimit)
const limit = isProUser ? proLimit : freemiumLimit
validateArraySize (length) {
const limit = 20
return length <= limit
}
@@ -155,7 +155,7 @@ class BlockchainRESTController {
})
}
if (!this.adapters.fullNode.validateArraySize(hashes.length, { isProUser: Boolean(req.locals?.proLimit) })) {
if (!this.adapters.fullNode.validateArraySize(hashes.length)) {
return res.status(400).json({ error: 'Array too large.' })
}
@@ -238,7 +238,7 @@ class BlockchainRESTController {
})
}
if (!this.adapters.fullNode.validateArraySize(txids.length, { isProUser: Boolean(req.locals?.proLimit) })) {
if (!this.adapters.fullNode.validateArraySize(txids.length)) {
return res.status(400).json({ error: 'Array too large.' })
}
@@ -415,7 +415,7 @@ class BlockchainRESTController {
})
}
if (!this.adapters.fullNode.validateArraySize(txids.length, { isProUser: Boolean(req.locals?.proLimit) })) {
if (!this.adapters.fullNode.validateArraySize(txids.length)) {
return res.status(400).json({ error: 'Array too large.' })
}
@@ -470,7 +470,7 @@ class BlockchainRESTController {
})
}
if (!this.adapters.fullNode.validateArraySize(proofs.length, { isProUser: Boolean(req.locals?.proLimit) })) {
if (!this.adapters.fullNode.validateArraySize(proofs.length)) {
return res.status(400).json({ error: 'Array too large.' })
}
@@ -0,0 +1,333 @@
/*
REST API Controller for the /full-node/rawtransactions routes.
*/
import wlogger from '../../../../adapters/wlogger.js'
class RawTransactionsRESTController {
constructor (localConfig = {}) {
this.adapters = localConfig.adapters
if (!this.adapters) {
throw new Error(
'Instance of Adapters library required when instantiating RawTransactions REST Controller.'
)
}
this.useCases = localConfig.useCases
if (!this.useCases || !this.useCases.rawtransactions) {
throw new Error(
'Instance of RawTransactions use cases required when instantiating RawTransactions REST Controller.'
)
}
this.rawtransactionsUseCases = this.useCases.rawtransactions
// Bind functions
this.root = this.root.bind(this)
this.decodeRawTransactionSingle = this.decodeRawTransactionSingle.bind(this)
this.decodeRawTransactionBulk = this.decodeRawTransactionBulk.bind(this)
this.decodeScriptSingle = this.decodeScriptSingle.bind(this)
this.decodeScriptBulk = this.decodeScriptBulk.bind(this)
this.getRawTransactionSingle = this.getRawTransactionSingle.bind(this)
this.getRawTransactionBulk = this.getRawTransactionBulk.bind(this)
this.sendRawTransactionSingle = this.sendRawTransactionSingle.bind(this)
this.sendRawTransactionBulk = this.sendRawTransactionBulk.bind(this)
this.handleError = this.handleError.bind(this)
}
/**
* @api {get} /v6/full-node/rawtransactions/ Service status
* @apiName RawTransactionsRoot
* @apiGroup RawTransactions
*
* @apiDescription Returns the status of the rawtransactions service.
*
* @apiSuccess {String} status Service identifier
*/
async root (req, res) {
return res.status(200).json({ status: 'rawtransactions' })
}
/**
* @api {get} /v6/full-node/rawtransactions/decodeRawTransaction/:hex Decode Single Raw Transaction
* @apiName DecodeSingleRawTransaction
* @apiGroup RawTransactions
* @apiDescription Return a JSON object representing the serialized, hex-encoded transaction.
*
* @apiParam {String} hex Hex-encoded transaction
*
* @apiExample Example usage:
* curl -X GET "https://api.fullstack.cash/v6/full-node/rawtransactions/decodeRawTransaction/02000000010e991f7ccec410f27d333f737f149b5d3be6728687da81072e638aed0063a176010000006b483045022100cd20443b0af090053450bc4ab00d563d4ac5955bb36e0135b00b8a96a19f233302205047f2c70a08c6ef4b76f2d198b33a31d17edfaa7e1e9e865894da0d396009354121024d4e7f522f67105b7bf5f9dbe557e7b2244613fdfcd6fe09304f93877328f6beffffffff02a0860100000000001976a9140ee020c07f39526ac5505c54fa1ab98490979b8388acb5f0f70b000000001976a9143a9b2b0c12fe722fcf653b6ef5dcc38732d6ff5188ac00000000" -H "accept: application/json"
*/
async decodeRawTransactionSingle (req, res) {
try {
const hex = req.params.hex
if (!hex || hex === '') {
return res.status(400).json({ error: 'hex can not be empty' })
}
const result = await this.rawtransactionsUseCases.decodeRawTransaction({ hex })
return res.status(200).json(result)
} catch (err) {
return this.handleError(err, res)
}
}
/**
* @api {post} /v6/full-node/rawtransactions/decodeRawTransaction Decode Bulk Raw Transactions
* @apiName DecodeBulkRawTransactions
* @apiGroup RawTransactions
* @apiDescription Return bulk hex encoded transaction.
*
* @apiParam {String[]} hexes Array of hex-encoded transactions
*
* @apiExample Example usage:
* curl -X POST "https://api.fullstack.cash/v6/full-node/rawtransactions/decodeRawTransaction" -H "accept: application/json" -H "Content-Type: application/json" -d '{"hexes":["01000000013ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4a000000006a4730440220540986d1c58d6e76f8f05501c520c38ce55393d0ed7ed3c3a82c69af04221232022058ea43ed6c05fec0eccce749a63332ed4525460105346f11108b9c26df93cd72012103083dfc5a0254613941ddc91af39ff90cd711cdcde03a87b144b883b524660c39ffffffff01807c814a000000001976a914d7e7c4e0b70eaa67ceff9d2823d1bbb9f6df9a5188ac00000000"]}'
*/
async decodeRawTransactionBulk (req, res) {
try {
const hexes = req.body.hexes
if (!Array.isArray(hexes)) {
return res.status(400).json({ error: 'hexes must be an array' })
}
if (!this.adapters.fullNode.validateArraySize(hexes.length)) {
return res.status(400).json({ error: 'Array too large.' })
}
// Validate each element in the array
for (const hex of hexes) {
if (!hex || hex === '') {
return res.status(400).json({ error: 'Encountered empty hex' })
}
}
const result = await this.rawtransactionsUseCases.decodeRawTransactions({ hexes })
return res.status(200).json(result)
} catch (err) {
return this.handleError(err, res)
}
}
/**
* @api {get} /v6/full-node/rawtransactions/decodeScript/:hex Decode Single Script
* @apiName DecodeSingleScript
* @apiGroup RawTransactions
* @apiDescription Decode a hex-encoded script.
*
* @apiParam {String} hex Hex-encoded script
*
* @apiExample Example usage:
* curl -X GET "https://api.fullstack.cash/v6/full-node/rawtransactions/decodeScript/4830450221009a51e00ec3524a7389592bc27bea4af5104a59510f5f0cfafa64bbd5c164ca2e02206c2a8bbb47eabdeed52f17d7df668d521600286406930426e3a9415fe10ed592012102e6e1423f7abde8b70bca3e78a7d030e5efabd3eb35c19302542b5fe7879c1a16" -H "accept: application/json"
*/
async decodeScriptSingle (req, res) {
try {
const hex = req.params.hex
if (!hex || hex === '') {
return res.status(400).json({ error: 'hex can not be empty' })
}
const result = await this.rawtransactionsUseCases.decodeScript({ hex })
return res.status(200).json(result)
} catch (err) {
return this.handleError(err, res)
}
}
/**
* @api {post} /v6/full-node/rawtransactions/decodeScript Bulk Decode Script
* @apiName DecodeBulkScript
* @apiGroup RawTransactions
* @apiDescription Decode multiple hex-encoded scripts.
*
* @apiParam {String[]} hexes Array of hex-encoded scripts
*
* @apiExample Example usage:
* curl -X POST "https://api.fullstack.cash/v6/full-node/rawtransactions/decodeScript" -H "accept: application/json" -H "Content-Type: application/json" -d '{"hexes":["01000000013ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4a000000006a4730440220540986d1c58d6e76f8f05501c520c38ce55393d0ed7ed3c3a82c69af04221232022058ea43ed6c05fec0eccce749a63332ed4525460105346f11108b9c26df93cd72012103083dfc5a0254613941ddc91af39ff90cd711cdcde03a87b144b883b524660c39ffffffff01807c814a000000001976a914d7e7c4e0b70eaa67ceff9d2823d1bbb9f6df9a5188ac00000000"]}'
*/
async decodeScriptBulk (req, res) {
try {
const hexes = req.body.hexes
if (!Array.isArray(hexes)) {
return res.status(400).json({ error: 'hexes must be an array' })
}
if (!this.adapters.fullNode.validateArraySize(hexes.length)) {
return res.status(400).json({ error: 'Array too large.' })
}
// Validate each hex in the array
for (const hex of hexes) {
if (!hex || hex === '') {
return res.status(400).json({ error: 'Encountered empty hex' })
}
}
const result = await this.rawtransactionsUseCases.decodeScripts({ hexes })
return res.status(200).json(result)
} catch (err) {
return this.handleError(err, res)
}
}
/**
* @api {get} /v6/full-node/rawtransactions/getRawTransaction/:txid Get Raw Transaction
* @apiName GetRawTransaction
* @apiGroup RawTransactions
* @apiDescription Return the raw transaction data. If verbose is 'true', returns an Object with information about 'txid'. If verbose is 'false' or omitted, returns a string that is serialized, hex-encoded data for 'txid'.
*
* @apiParam {String} txid Transaction ID
* @apiParam {Boolean} verbose Return verbose data (default false)
*
* @apiExample Example usage:
* curl -X GET "https://api.fullstack.cash/v6/full-node/rawtransactions/getRawTransaction/fe28050b93faea61fa88c4c630f0e1f0a1c24d0082dd0e10d369e13212128f33?verbose=true" -H "accept: application/json"
*/
async getRawTransactionSingle (req, res) {
try {
const txid = req.params.txid
const verbose = req.query.verbose === 'true'
if (!txid || txid === '') {
return res.status(400).json({ error: 'txid can not be empty' })
}
if (txid.length !== 64) {
return res.status(400).json({
error: `parameter 1 must be of length 64 (not ${txid.length})`
})
}
const result = await this.rawtransactionsUseCases.getRawTransactionWithHeight({ txid, verbose })
return res.status(200).json(result)
} catch (err) {
return this.handleError(err, res)
}
}
/**
* @api {post} /v6/full-node/rawtransactions/getRawTransaction Get Bulk Raw Transactions
* @apiName GetBulkRawTransactions
* @apiGroup RawTransactions
* @apiDescription Return the raw transaction data for multiple transactions. If verbose is 'true', returns an Object with information about 'txid'. If verbose is 'false' or omitted, returns a string that is serialized, hex-encoded data for 'txid'.
*
* @apiParam {String[]} txids Array of transaction IDs
* @apiParam {Boolean} verbose Return verbose data (default false)
*
* @apiExample Example usage:
* curl -X POST "https://api.fullstack.cash/v6/full-node/rawtransactions/getRawTransaction" -H "accept: application/json" -H "Content-Type: application/json" -d '{"txids":["a5f972572ee1753e2fd2457dd61ce5f40fa2f8a30173d417e49feef7542c96a1","5165dc531aad05d1149bb0f0d9b7bda99c73e2f05e314bcfb5b4bb9ca5e1af5e"],"verbose":true}'
*/
async getRawTransactionBulk (req, res) {
try {
const txids = req.body.txids
const verbose = !!req.body.verbose
if (!Array.isArray(txids)) {
return res.status(400).json({ error: 'txids must be an array' })
}
if (!this.adapters.fullNode.validateArraySize(txids.length)) {
return res.status(400).json({ error: 'Array too large.' })
}
// Validate each txid in the array
for (const txid of txids) {
if (!txid || txid === '') {
return res.status(400).json({ error: 'Encountered empty TXID' })
}
if (txid.length !== 64) {
return res.status(400).json({
error: `parameter 1 must be of length 64 (not ${txid.length})`
})
}
}
const result = await this.rawtransactionsUseCases.getRawTransactions({ txids, verbose })
return res.status(200).json(result)
} catch (err) {
return this.handleError(err, res)
}
}
/**
* @api {get} /v6/full-node/rawtransactions/sendRawTransaction/:hex Send Single Raw Transaction
* @apiName SendSingleRawTransaction
* @apiGroup RawTransactions
* @apiDescription Submits single raw transaction (serialized, hex-encoded) to local node and network.
*
* @apiParam {String} hex Hex-encoded transaction
*
* @apiExample Example usage:
* curl -X GET "https://api.fullstack.cash/v6/full-node/rawtransactions/sendRawTransaction/01000000013ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4a000000006a4730440220540986d1c58d6e76f8f05501c520c38ce55393d0ed7ed3c3a82c69af04221232022058ea43ed6c05fec0eccce749a63332ed4525460105346f11108b9c26df93cd72012103083dfc5a0254613941ddc91af39ff90cd711cdcde03a87b144b883b524660c39ffffffff01807c814a000000001976a914d7e7c4e0b70eaa67ceff9d2823d1bbb9f6df9a5188ac00000000" -H "accept: application/json"
*/
async sendRawTransactionSingle (req, res) {
try {
const hex = req.params.hex
if (typeof hex !== 'string') {
return res.status(400).json({ error: 'hex must be a string' })
}
if (hex === '') {
return res.status(400).json({ error: 'Encountered empty hex' })
}
const result = await this.rawtransactionsUseCases.sendRawTransaction({ hex })
return res.status(200).json(result)
} catch (err) {
return this.handleError(err, res)
}
}
/**
* @api {post} /v6/full-node/rawtransactions/sendRawTransaction Send Bulk Raw Transactions
* @apiName SendBulkRawTransactions
* @apiGroup RawTransactions
* @apiDescription Submits multiple raw transaction (serialized, hex-encoded) to local node and network.
*
* @apiParam {String[]} hexes Array of hex-encoded transactions
*
* @apiExample Example usage:
* curl -X POST "https://api.fullstack.cash/v6/full-node/rawtransactions/sendRawTransaction" -H "accept: application/json" -H "Content-Type: application/json" -d '{"hexes":["01000000013ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4a000000006a4730440220540986d1c58d6e76f8f05501c520c38ce55393d0ed7ed3c3a82c69af04221232022058ea43ed6c05fec0eccce749a63332ed4525460105346f11108b9c26df93cd72012103083dfc5a0254613941ddc91af39ff90cd711cdcde03a87b144b883b524660c39ffffffff01807c814a000000001976a914d7e7c4e0b70eaa67ceff9d2823d1bbb9f6df9a5188ac00000000"]}'
*/
async sendRawTransactionBulk (req, res) {
try {
const hexes = req.body.hexes
if (!Array.isArray(hexes)) {
return res.status(400).json({ error: 'hex must be an array' })
}
if (!this.adapters.fullNode.validateArraySize(hexes.length)) {
return res.status(400).json({ error: 'Array too large.' })
}
// Validate each element
for (const hex of hexes) {
if (hex === '') {
return res.status(400).json({ error: 'Encountered empty hex' })
}
}
const result = await this.rawtransactionsUseCases.sendRawTransactions({ hexes })
return res.status(200).json(result)
} catch (err) {
return this.handleError(err, res)
}
}
handleError (err, res) {
wlogger.error('Error in RawTransactionsRESTController:', err)
const status = err.status || 500
const message = err.message || 'Internal server error'
return res.status(status).json({ error: message })
}
}
export default RawTransactionsRESTController
@@ -0,0 +1,58 @@
/*
REST API router for /full-node/rawtransactions routes.
*/
import express from 'express'
import RawTransactionsRESTController from './controller.js'
class RawTransactionsRouter {
constructor (localConfig = {}) {
this.adapters = localConfig.adapters
if (!this.adapters) {
throw new Error(
'Instance of Adapters library required when instantiating RawTransactions REST Router.'
)
}
this.useCases = localConfig.useCases
if (!this.useCases) {
throw new Error(
'Instance of Use Cases library required when instantiating RawTransactions REST Router.'
)
}
const dependencies = {
adapters: this.adapters,
useCases: this.useCases
}
this.rawtransactionsController = new RawTransactionsRESTController(dependencies)
this.apiPrefix = (localConfig.apiPrefix || '').replace(/\/$/, '')
this.baseUrl = `${this.apiPrefix}/full-node/rawtransactions`
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.rawtransactionsController.root)
this.router.get('/decodeRawTransaction/:hex', this.rawtransactionsController.decodeRawTransactionSingle)
this.router.post('/decodeRawTransaction', this.rawtransactionsController.decodeRawTransactionBulk)
this.router.get('/decodeScript/:hex', this.rawtransactionsController.decodeScriptSingle)
this.router.post('/decodeScript', this.rawtransactionsController.decodeScriptBulk)
this.router.get('/getRawTransaction/:txid', this.rawtransactionsController.getRawTransactionSingle)
this.router.post('/getRawTransaction', this.rawtransactionsController.getRawTransactionBulk)
this.router.get('/sendRawTransaction/:hex', this.rawtransactionsController.sendRawTransactionSingle)
this.router.post('/sendRawTransaction', this.rawtransactionsController.sendRawTransactionBulk)
app.use(this.baseUrl, this.router)
}
}
export default RawTransactionsRouter
+4
View File
@@ -11,6 +11,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 RawTransactionsRouter from './full-node/rawtransactions/index.js'
import config from '../../config/index.js'
class RESTControllers {
@@ -68,6 +69,9 @@ class RESTControllers {
const miningRouter = new MiningRouter(dependencies)
miningRouter.attach(app)
const rawtransactionsRouter = new RawTransactionsRouter(dependencies)
rawtransactionsRouter.attach(app)
}
}
@@ -0,0 +1,121 @@
/*
Use cases for interacting with the BCH full node raw transactions RPC interface.
*/
import wlogger from '../adapters/wlogger.js'
class RawTransactionsUseCases {
constructor (localConfig = {}) {
this.adapters = localConfig.adapters
if (!this.adapters) {
throw new Error('Adapters instance required when instantiating RawTransactions use cases.')
}
this.fullNode = this.adapters.fullNode
if (!this.fullNode) {
throw new Error('Full node adapter required when instantiating RawTransactions use cases.')
}
}
async decodeRawTransaction ({ hex }) {
return this.fullNode.call('decoderawtransaction', [hex])
}
async decodeRawTransactions ({ hexes }) {
try {
const promises = hexes.map(hex =>
this.fullNode.call('decoderawtransaction', [hex], `decoderawtransaction-${hex.slice(0, 16)}`)
)
return await Promise.all(promises)
} catch (err) {
wlogger.error('Error in RawTransactionsUseCases.decodeRawTransactions()', err)
throw err
}
}
async decodeScript ({ hex }) {
return this.fullNode.call('decodescript', [hex])
}
async decodeScripts ({ hexes }) {
try {
const promises = hexes.map(hex =>
this.fullNode.call('decodescript', [hex], `decodescript-${hex.slice(0, 16)}`)
)
return await Promise.all(promises)
} catch (err) {
wlogger.error('Error in RawTransactionsUseCases.decodeScripts()', err)
throw err
}
}
async getRawTransaction ({ txid, verbose = false }) {
const verboseInt = verbose ? 1 : 0
return this.fullNode.call('getrawtransaction', [txid, verboseInt])
}
async getRawTransactions ({ txids, verbose = false }) {
try {
const verboseInt = verbose ? 1 : 0
const promises = txids.map(txid =>
this.fullNode.call('getrawtransaction', [txid, verboseInt], `getrawtransaction-${txid}`)
)
return await Promise.all(promises)
} catch (err) {
wlogger.error('Error in RawTransactionsUseCases.getRawTransactions()', err)
throw err
}
}
async getRawTransactionWithHeight ({ txid, verbose = false }) {
const verboseInt = verbose ? 1 : 0
const data = await this.fullNode.call('getrawtransaction', [txid, verboseInt])
if (verbose && data && data.blockhash) {
data.height = null
try {
// Look up the block height and append it to the TX response.
const blockHeader = await this.fullNode.call('getblockheader', [data.blockhash, true])
data.height = blockHeader.height
} catch (err) {
// Exit quietly if block header lookup fails
wlogger.debug('Could not fetch block header for height lookup', err)
}
}
return data
}
async getBlockHeader ({ blockHash, verbose = false }) {
return this.fullNode.call('getblockheader', [blockHash, verbose])
}
async sendRawTransaction ({ hex }) {
return this.fullNode.call('sendrawtransaction', [hex])
}
async sendRawTransactions ({ hexes }) {
// Dev Note: Sending the 'sendrawtransaction' RPC call to a full node in parallel will
// not work. Testing showed that the full node will return the same TXID for
// different TX hexes. I believe this is by design, to prevent double spends.
// In parallel, we are essentially asking the node to broadcast a new TX before
// it's finished broadcasting the previous one. Serial execution is required.
try {
const result = []
for (const hex of hexes) {
const txid = await this.fullNode.call('sendrawtransaction', [hex], `sendrawtransaction-${hex.slice(0, 16)}`)
result.push(txid)
}
return result
} catch (err) {
wlogger.error('Error in RawTransactionsUseCases.sendRawTransactions()', err)
throw err
}
}
}
export default RawTransactionsUseCases
+2
View File
@@ -9,6 +9,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'
import RawTransactionsUseCases from './full-node-rawtransactions-use-cases.js'
class UseCases {
constructor (localConfig = {}) {
@@ -23,6 +24,7 @@ class UseCases {
this.control = new ControlUseCases({ adapters: this.adapters })
this.dsproof = new DSProofUseCases({ adapters: this.adapters })
this.mining = new MiningUseCases({ adapters: this.adapters })
this.rawtransactions = new RawTransactionsUseCases({ adapters: this.adapters })
}
// Run any startup Use Cases at the start of the app.
@@ -161,8 +161,7 @@ describe('#blockchain-controller.js', () => {
it('should validate array size and call use case', async () => {
const hash = 'a'.repeat(64)
const req = createMockRequest({
body: { hashes: [hash], verbose: true },
locals: { proLimit: false }
body: { hashes: [hash], verbose: true }
})
const res = createMockResponse()
mockUseCases.blockchain.getBlockHeaders.resolves(['result'])
@@ -172,7 +171,7 @@ describe('#blockchain-controller.js', () => {
assert.equal(res.statusValue, 200)
assert.deepEqual(res.jsonData, ['result'])
assert.isTrue(
mockAdapters.fullNode.validateArraySize.calledOnceWithExactly(1, { isProUser: false })
mockAdapters.fullNode.validateArraySize.calledOnceWithExactly(1)
)
assert.isTrue(
mockUseCases.blockchain.getBlockHeaders.calledOnceWithExactly({
@@ -0,0 +1,388 @@
/*
Unit tests for RawTransactionsRESTController.
*/
import { assert } from 'chai'
import sinon from 'sinon'
import RawTransactionsRESTController from '../../../src/controllers/rest-api/full-node/rawtransactions/controller.js'
import { createMockRequest, createMockResponse } from '../mocks/controller-mocks.js'
describe('#rawtransactions-controller.js', () => {
let sandbox
let mockAdapters
let mockUseCases
let uut
beforeEach(() => {
sandbox = sinon.createSandbox()
mockAdapters = {
fullNode: {
validateArraySize: sandbox.stub().returns(true)
}
}
mockUseCases = {
rawtransactions: {
decodeRawTransaction: sandbox.stub().resolves({ txid: 'abc123' }),
decodeRawTransactions: sandbox.stub().resolves([{ txid: 'abc123' }]),
decodeScript: sandbox.stub().resolves({ asm: 'OP_DUP' }),
decodeScripts: sandbox.stub().resolves([{ asm: 'OP_DUP' }]),
getRawTransaction: sandbox.stub().resolves({ txid: 'abc123' }),
getRawTransactionWithHeight: sandbox.stub().resolves({ txid: 'abc123', height: 100 }),
getRawTransactions: sandbox.stub().resolves([{ txid: 'abc123' }]),
sendRawTransaction: sandbox.stub().resolves('txid123'),
sendRawTransactions: sandbox.stub().resolves(['txid1', 'txid2'])
}
}
uut = new RawTransactionsRESTController({
adapters: mockAdapters,
useCases: mockUseCases
})
})
afterEach(() => {
sandbox.restore()
})
describe('#constructor()', () => {
it('should require adapters', () => {
assert.throws(() => {
// eslint-disable-next-line no-new
new RawTransactionsRESTController({ useCases: mockUseCases })
}, /Adapters library required/)
})
it('should require rawtransactions use cases', () => {
assert.throws(() => {
// eslint-disable-next-line no-new
new RawTransactionsRESTController({ adapters: mockAdapters, useCases: {} })
}, /RawTransactions use cases required/)
})
})
describe('#root()', () => {
it('should return rawtransactions status', async () => {
const req = createMockRequest()
const res = createMockResponse()
await uut.root(req, res)
assert.equal(res.statusValue, 200)
assert.deepEqual(res.jsonData, { status: 'rawtransactions' })
})
})
describe('#decodeRawTransactionSingle()', () => {
it('should return decoded transaction on success', async () => {
const req = createMockRequest({ params: { hex: '01000000' } })
const res = createMockResponse()
await uut.decodeRawTransactionSingle(req, res)
assert.equal(res.statusValue, 200)
assert.deepEqual(res.jsonData, { txid: 'abc123' })
assert.isTrue(mockUseCases.rawtransactions.decodeRawTransaction.calledOnce)
assert.deepEqual(mockUseCases.rawtransactions.decodeRawTransaction.firstCall.args[0], { hex: '01000000' })
})
it('should return 400 if hex is empty', async () => {
const req = createMockRequest({ params: { hex: '' } })
const res = createMockResponse()
await uut.decodeRawTransactionSingle(req, res)
assert.equal(res.statusValue, 400)
assert.deepEqual(res.jsonData, { error: 'hex can not be empty' })
})
it('should handle errors via handleError', async () => {
const error = new Error('RPC error')
error.status = 500
mockUseCases.rawtransactions.decodeRawTransaction.rejects(error)
const req = createMockRequest({ params: { hex: '01000000' } })
const res = createMockResponse()
await uut.decodeRawTransactionSingle(req, res)
assert.equal(res.statusValue, 500)
assert.deepEqual(res.jsonData, { error: 'RPC error' })
})
})
describe('#decodeRawTransactionBulk()', () => {
it('should return decoded transactions on success', async () => {
const req = createMockRequest({ body: { hexes: ['hex1', 'hex2'] } })
const res = createMockResponse()
await uut.decodeRawTransactionBulk(req, res)
assert.equal(res.statusValue, 200)
assert.deepEqual(res.jsonData, [{ txid: 'abc123' }])
assert.isTrue(mockUseCases.rawtransactions.decodeRawTransactions.calledOnce)
})
it('should return 400 if hexes is not an array', async () => {
const req = createMockRequest({ body: { hexes: 'not-array' } })
const res = createMockResponse()
await uut.decodeRawTransactionBulk(req, res)
assert.equal(res.statusValue, 400)
assert.deepEqual(res.jsonData, { error: 'hexes must be an array' })
})
it('should return 400 if array is too large', async () => {
mockAdapters.fullNode.validateArraySize.returns(false)
const req = createMockRequest({ body: { hexes: new Array(25).fill('hex') } })
const res = createMockResponse()
await uut.decodeRawTransactionBulk(req, res)
assert.equal(res.statusValue, 400)
assert.deepEqual(res.jsonData, { error: 'Array too large.' })
})
it('should return 400 if empty hex encountered', async () => {
const req = createMockRequest({ body: { hexes: ['hex1', '', 'hex2'] } })
const res = createMockResponse()
await uut.decodeRawTransactionBulk(req, res)
assert.equal(res.statusValue, 400)
assert.deepEqual(res.jsonData, { error: 'Encountered empty hex' })
})
})
describe('#decodeScriptSingle()', () => {
it('should return decoded script on success', async () => {
const req = createMockRequest({ params: { hex: '76a914' } })
const res = createMockResponse()
await uut.decodeScriptSingle(req, res)
assert.equal(res.statusValue, 200)
assert.deepEqual(res.jsonData, { asm: 'OP_DUP' })
assert.isTrue(mockUseCases.rawtransactions.decodeScript.calledOnce)
})
it('should return 400 if hex is empty', async () => {
const req = createMockRequest({ params: { hex: '' } })
const res = createMockResponse()
await uut.decodeScriptSingle(req, res)
assert.equal(res.statusValue, 400)
assert.deepEqual(res.jsonData, { error: 'hex can not be empty' })
})
})
describe('#decodeScriptBulk()', () => {
it('should return decoded scripts on success', async () => {
const req = createMockRequest({ body: { hexes: ['script1', 'script2'] } })
const res = createMockResponse()
await uut.decodeScriptBulk(req, res)
assert.equal(res.statusValue, 200)
assert.deepEqual(res.jsonData, [{ asm: 'OP_DUP' }])
})
it('should return 400 if array is too large', async () => {
mockAdapters.fullNode.validateArraySize.returns(false)
const req = createMockRequest({ body: { hexes: new Array(25).fill('script') } })
const res = createMockResponse()
await uut.decodeScriptBulk(req, res)
assert.equal(res.statusValue, 400)
assert.deepEqual(res.jsonData, { error: 'Array too large.' })
})
})
describe('#getRawTransactionSingle()', () => {
it('should return raw transaction on success', async () => {
const req = createMockRequest({ params: { txid: 'a'.repeat(64) }, query: {} })
const res = createMockResponse()
await uut.getRawTransactionSingle(req, res)
assert.equal(res.statusValue, 200)
assert.deepEqual(res.jsonData, { txid: 'abc123', height: 100 })
assert.isTrue(mockUseCases.rawtransactions.getRawTransactionWithHeight.calledOnce)
})
it('should pass verbose=true when query param is set', async () => {
const req = createMockRequest({ params: { txid: 'a'.repeat(64) }, query: { verbose: 'true' } })
const res = createMockResponse()
await uut.getRawTransactionSingle(req, res)
assert.isTrue(mockUseCases.rawtransactions.getRawTransactionWithHeight.calledOnce)
assert.deepEqual(mockUseCases.rawtransactions.getRawTransactionWithHeight.firstCall.args[0], {
txid: 'a'.repeat(64),
verbose: true
})
})
it('should return 400 if txid is empty', async () => {
const req = createMockRequest({ params: { txid: '' } })
const res = createMockResponse()
await uut.getRawTransactionSingle(req, res)
assert.equal(res.statusValue, 400)
assert.deepEqual(res.jsonData, { error: 'txid can not be empty' })
})
it('should return 400 if txid length is not 64', async () => {
const req = createMockRequest({ params: { txid: 'short' } })
const res = createMockResponse()
await uut.getRawTransactionSingle(req, res)
assert.equal(res.statusValue, 400)
assert.deepEqual(res.jsonData, { error: 'parameter 1 must be of length 64 (not 5)' })
})
})
describe('#getRawTransactionBulk()', () => {
it('should return raw transactions on success', async () => {
const req = createMockRequest({
body: {
txids: ['a'.repeat(64), 'b'.repeat(64)],
verbose: true
}
})
const res = createMockResponse()
await uut.getRawTransactionBulk(req, res)
assert.equal(res.statusValue, 200)
assert.deepEqual(res.jsonData, [{ txid: 'abc123' }])
assert.isTrue(mockUseCases.rawtransactions.getRawTransactions.calledOnce)
assert.deepEqual(mockUseCases.rawtransactions.getRawTransactions.firstCall.args[0], {
txids: ['a'.repeat(64), 'b'.repeat(64)],
verbose: true
})
})
it('should return 400 if txids is not an array', async () => {
const req = createMockRequest({ body: { txids: 'not-array' } })
const res = createMockResponse()
await uut.getRawTransactionBulk(req, res)
assert.equal(res.statusValue, 400)
assert.deepEqual(res.jsonData, { error: 'txids must be an array' })
})
it('should return 400 if array is too large', async () => {
mockAdapters.fullNode.validateArraySize.returns(false)
const req = createMockRequest({ body: { txids: new Array(25).fill('a'.repeat(64)) } })
const res = createMockResponse()
await uut.getRawTransactionBulk(req, res)
assert.equal(res.statusValue, 400)
assert.deepEqual(res.jsonData, { error: 'Array too large.' })
})
it('should return 400 if empty txid encountered', async () => {
const req = createMockRequest({ body: { txids: ['a'.repeat(64), ''] } })
const res = createMockResponse()
await uut.getRawTransactionBulk(req, res)
assert.equal(res.statusValue, 400)
assert.deepEqual(res.jsonData, { error: 'Encountered empty TXID' })
})
it('should return 400 if txid length is not 64', async () => {
const req = createMockRequest({ body: { txids: ['short'] } })
const res = createMockResponse()
await uut.getRawTransactionBulk(req, res)
assert.equal(res.statusValue, 400)
assert.deepEqual(res.jsonData, { error: 'parameter 1 must be of length 64 (not 5)' })
})
})
describe('#sendRawTransactionSingle()', () => {
it('should return txid on success', async () => {
const req = createMockRequest({ params: { hex: '01000000' } })
const res = createMockResponse()
await uut.sendRawTransactionSingle(req, res)
assert.equal(res.statusValue, 200)
assert.equal(res.jsonData, 'txid123')
assert.isTrue(mockUseCases.rawtransactions.sendRawTransaction.calledOnce)
})
it('should return 400 if hex is empty', async () => {
const req = createMockRequest({ params: { hex: '' } })
const res = createMockResponse()
await uut.sendRawTransactionSingle(req, res)
assert.equal(res.statusValue, 400)
assert.deepEqual(res.jsonData, { error: 'Encountered empty hex' })
})
it('should return 400 if hex is not a string', async () => {
const req = createMockRequest({ params: { hex: 123 } })
const res = createMockResponse()
await uut.sendRawTransactionSingle(req, res)
assert.equal(res.statusValue, 400)
assert.deepEqual(res.jsonData, { error: 'hex must be a string' })
})
})
describe('#sendRawTransactionBulk()', () => {
it('should return txids on success', async () => {
const req = createMockRequest({ body: { hexes: ['hex1', 'hex2'] } })
const res = createMockResponse()
await uut.sendRawTransactionBulk(req, res)
assert.equal(res.statusValue, 200)
assert.deepEqual(res.jsonData, ['txid1', 'txid2'])
assert.isTrue(mockUseCases.rawtransactions.sendRawTransactions.calledOnce)
})
it('should return 400 if hexes is not an array', async () => {
const req = createMockRequest({ body: { hexes: 'not-array' } })
const res = createMockResponse()
await uut.sendRawTransactionBulk(req, res)
assert.equal(res.statusValue, 400)
assert.deepEqual(res.jsonData, { error: 'hex must be an array' })
})
it('should return 400 if array is too large', async () => {
mockAdapters.fullNode.validateArraySize.returns(false)
const req = createMockRequest({ body: { hexes: new Array(25).fill('hex') } })
const res = createMockResponse()
await uut.sendRawTransactionBulk(req, res)
assert.equal(res.statusValue, 400)
assert.deepEqual(res.jsonData, { error: 'Array too large.' })
})
it('should return 400 if empty hex encountered', async () => {
const req = createMockRequest({ body: { hexes: ['hex1', '', 'hex2'] } })
const res = createMockResponse()
await uut.sendRawTransactionBulk(req, res)
assert.equal(res.statusValue, 400)
assert.deepEqual(res.jsonData, { error: 'Encountered empty hex' })
})
})
})
@@ -10,6 +10,7 @@ import BlockchainRouter from '../../../src/controllers/rest-api/full-node/blockc
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'
import RawTransactionsRouter from '../../../src/controllers/rest-api/full-node/rawtransactions/index.js'
describe('#controllers/rest-api/index.js', () => {
let sandbox
@@ -56,6 +57,17 @@ describe('#controllers/rest-api/index.js', () => {
mining: {
getMiningInfo: () => {},
getNetworkHashPS: () => {}
},
rawtransactions: {
decodeRawTransaction: () => {},
decodeRawTransactions: () => {},
decodeScript: () => {},
decodeScripts: () => {},
getRawTransaction: () => {},
getRawTransactionWithHeight: () => {},
getRawTransactions: () => {},
sendRawTransaction: () => {},
sendRawTransactions: () => {}
}
}
})
@@ -86,6 +98,7 @@ describe('#controllers/rest-api/index.js', () => {
const controlAttachStub = sandbox.stub(ControlRouter.prototype, 'attach')
const dsproofAttachStub = sandbox.stub(DSProofRouter.prototype, 'attach')
const miningAttachStub = sandbox.stub(MiningRouter.prototype, 'attach')
const rawtransactionsAttachStub = sandbox.stub(RawTransactionsRouter.prototype, 'attach')
const restControllers = new RESTControllers({
adapters: mockAdapters,
useCases: mockUseCases
@@ -102,6 +115,8 @@ describe('#controllers/rest-api/index.js', () => {
assert.equal(dsproofAttachStub.getCall(0).args[0], app)
assert.isTrue(miningAttachStub.calledOnce)
assert.equal(miningAttachStub.getCall(0).args[0], app)
assert.isTrue(rawtransactionsAttachStub.calledOnce)
assert.equal(rawtransactionsAttachStub.getCall(0).args[0], app)
})
})
})
@@ -0,0 +1,267 @@
/*
Unit tests for RawTransactionsUseCases.
*/
import { assert } from 'chai'
import RawTransactionsUseCases from '../../../src/use-cases/full-node-rawtransactions-use-cases.js'
describe('#full-node-rawtransactions-use-cases.js', () => {
let mockAdapters
let uut
beforeEach(() => {
mockAdapters = {
fullNode: {
call: async () => ({})
}
}
uut = new RawTransactionsUseCases({ adapters: mockAdapters })
})
describe('#constructor()', () => {
it('should require adapters', () => {
assert.throws(() => {
// eslint-disable-next-line no-new
new RawTransactionsUseCases()
}, /Adapters instance required/)
})
it('should require full node adapter', () => {
assert.throws(() => {
// eslint-disable-next-line no-new
new RawTransactionsUseCases({ adapters: {} })
}, /Full node adapter required/)
})
})
describe('#decodeRawTransaction()', () => {
it('should call full node adapter with correct method', async () => {
let capturedMethod = ''
let capturedParams = []
mockAdapters.fullNode.call = async (method, params) => {
capturedMethod = method
capturedParams = params
return { txid: 'abc123', version: 2 }
}
const result = await uut.decodeRawTransaction({ hex: '01000000' })
assert.equal(capturedMethod, 'decoderawtransaction')
assert.deepEqual(capturedParams, ['01000000'])
assert.deepEqual(result, { txid: 'abc123', version: 2 })
})
})
describe('#decodeRawTransactions()', () => {
it('should call full node adapter for each hex in parallel', async () => {
const callCount = { count: 0 }
mockAdapters.fullNode.call = async (method, params) => {
callCount.count++
return { txid: `tx${callCount.count}`, hex: params[0] }
}
const hexes = ['hex1', 'hex2', 'hex3']
const result = await uut.decodeRawTransactions({ hexes })
assert.equal(callCount.count, 3)
assert.equal(result.length, 3)
assert.equal(result[0].txid, 'tx1')
assert.equal(result[1].txid, 'tx2')
assert.equal(result[2].txid, 'tx3')
})
})
describe('#decodeScript()', () => {
it('should call full node adapter with correct method', async () => {
let capturedMethod = ''
let capturedParams = []
mockAdapters.fullNode.call = async (method, params) => {
capturedMethod = method
capturedParams = params
return { asm: 'OP_DUP OP_HASH160', type: 'pubkeyhash' }
}
const result = await uut.decodeScript({ hex: '76a914' })
assert.equal(capturedMethod, 'decodescript')
assert.deepEqual(capturedParams, ['76a914'])
assert.deepEqual(result, { asm: 'OP_DUP OP_HASH160', type: 'pubkeyhash' })
})
})
describe('#decodeScripts()', () => {
it('should call full node adapter for each hex in parallel', async () => {
const callCount = { count: 0 }
mockAdapters.fullNode.call = async (method, params) => {
callCount.count++
return { asm: `script${callCount.count}`, hex: params[0] }
}
const hexes = ['script1', 'script2']
const result = await uut.decodeScripts({ hexes })
assert.equal(callCount.count, 2)
assert.equal(result.length, 2)
})
})
describe('#getRawTransaction()', () => {
it('should call full node adapter with verbose=false by default', async () => {
let capturedParams = []
mockAdapters.fullNode.call = async (method, params) => {
capturedParams = params
return '01000000'
}
await uut.getRawTransaction({ txid: 'abc123' })
assert.deepEqual(capturedParams, ['abc123', 0])
})
it('should call full node adapter with verbose=true when specified', async () => {
let capturedParams = []
mockAdapters.fullNode.call = async (method, params) => {
capturedParams = params
return { txid: 'abc123', version: 2 }
}
await uut.getRawTransaction({ txid: 'abc123', verbose: true })
assert.deepEqual(capturedParams, ['abc123', 1])
})
})
describe('#getRawTransactions()', () => {
it('should call full node adapter for each txid in parallel', async () => {
const callCount = { count: 0 }
mockAdapters.fullNode.call = async (method, params) => {
callCount.count++
return { txid: params[0], version: 2 }
}
const txids = ['tx1', 'tx2']
const result = await uut.getRawTransactions({ txids, verbose: true })
assert.equal(callCount.count, 2)
assert.equal(result.length, 2)
})
})
describe('#getRawTransactionWithHeight()', () => {
it('should return transaction without height when verbose=false', async () => {
mockAdapters.fullNode.call = async (method, params) => {
if (method === 'getrawtransaction') {
return '01000000'
}
return {}
}
const result = await uut.getRawTransactionWithHeight({ txid: 'abc123', verbose: false })
assert.equal(result, '01000000')
})
it('should fetch and append height when verbose=true and blockhash exists', async () => {
let callCount = 0
mockAdapters.fullNode.call = async (method, params) => {
callCount++
if (method === 'getrawtransaction') {
return { txid: 'abc123', blockhash: 'block123' }
}
if (method === 'getblockheader') {
return { height: 100, hash: 'block123' }
}
return {}
}
const result = await uut.getRawTransactionWithHeight({ txid: 'abc123', verbose: true })
assert.equal(callCount, 2)
assert.equal(result.height, 100)
assert.equal(result.txid, 'abc123')
})
it('should handle block header lookup failure gracefully', async () => {
let callCount = 0
mockAdapters.fullNode.call = async (method, params) => {
callCount++
if (method === 'getrawtransaction') {
return { txid: 'abc123', blockhash: 'block123' }
}
if (method === 'getblockheader') {
throw new Error('Block not found')
}
return {}
}
const result = await uut.getRawTransactionWithHeight({ txid: 'abc123', verbose: true })
assert.equal(callCount, 2)
assert.isNull(result.height)
assert.equal(result.txid, 'abc123')
})
})
describe('#getBlockHeader()', () => {
it('should call full node adapter with correct method', async () => {
let capturedMethod = ''
let capturedParams = []
mockAdapters.fullNode.call = async (method, params) => {
capturedMethod = method
capturedParams = params
return { height: 100, hash: 'block123' }
}
const result = await uut.getBlockHeader({ blockHash: 'block123', verbose: true })
assert.equal(capturedMethod, 'getblockheader')
assert.deepEqual(capturedParams, ['block123', true])
assert.deepEqual(result, { height: 100, hash: 'block123' })
})
})
describe('#sendRawTransaction()', () => {
it('should call full node adapter with correct method', async () => {
let capturedMethod = ''
let capturedParams = []
mockAdapters.fullNode.call = async (method, params) => {
capturedMethod = method
capturedParams = params
return 'txid123'
}
const result = await uut.sendRawTransaction({ hex: '01000000' })
assert.equal(capturedMethod, 'sendrawtransaction')
assert.deepEqual(capturedParams, ['01000000'])
assert.equal(result, 'txid123')
})
})
describe('#sendRawTransactions()', () => {
it('should send transactions serially, not in parallel', async () => {
const callOrder = []
mockAdapters.fullNode.call = async (method, params) => {
callOrder.push(params[0])
// Simulate some async work
await new Promise(resolve => setTimeout(resolve, 10))
return `txid-${params[0]}`
}
const hexes = ['hex1', 'hex2', 'hex3']
const startTime = Date.now()
const result = await uut.sendRawTransactions({ hexes })
const endTime = Date.now()
// Should take at least 30ms if serial (3 * 10ms)
assert.isAtLeast(endTime - startTime, 25)
assert.deepEqual(callOrder, ['hex1', 'hex2', 'hex3'])
assert.equal(result.length, 3)
assert.equal(result[0], 'txid-hex1')
assert.equal(result[1], 'txid-hex2')
assert.equal(result[2], 'txid-hex3')
})
})
})