diff --git a/src/adapters/full-node-rpc.js b/src/adapters/full-node-rpc.js index 89709fd..3704f8e 100644 --- a/src/adapters/full-node-rpc.js +++ b/src/adapters/full-node-rpc.js @@ -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 } diff --git a/src/controllers/rest-api/full-node/blockchain/controller.js b/src/controllers/rest-api/full-node/blockchain/controller.js index 90c8e99..78080d5 100644 --- a/src/controllers/rest-api/full-node/blockchain/controller.js +++ b/src/controllers/rest-api/full-node/blockchain/controller.js @@ -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.' }) } diff --git a/src/controllers/rest-api/full-node/rawtransactions/controller.js b/src/controllers/rest-api/full-node/rawtransactions/controller.js new file mode 100644 index 0000000..26b59bc --- /dev/null +++ b/src/controllers/rest-api/full-node/rawtransactions/controller.js @@ -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 diff --git a/src/controllers/rest-api/full-node/rawtransactions/index.js b/src/controllers/rest-api/full-node/rawtransactions/index.js new file mode 100644 index 0000000..a06ecd8 --- /dev/null +++ b/src/controllers/rest-api/full-node/rawtransactions/index.js @@ -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 diff --git a/src/controllers/rest-api/index.js b/src/controllers/rest-api/index.js index 67239d7..fafe8db 100644 --- a/src/controllers/rest-api/index.js +++ b/src/controllers/rest-api/index.js @@ -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) } } diff --git a/src/use-cases/full-node-rawtransactions-use-cases.js b/src/use-cases/full-node-rawtransactions-use-cases.js new file mode 100644 index 0000000..6d4b304 --- /dev/null +++ b/src/use-cases/full-node-rawtransactions-use-cases.js @@ -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 diff --git a/src/use-cases/index.js b/src/use-cases/index.js index 229a783..8d740f1 100644 --- a/src/use-cases/index.js +++ b/src/use-cases/index.js @@ -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. diff --git a/test/unit/controllers/blockchain-controller-unit.js b/test/unit/controllers/blockchain-controller-unit.js index 2c150de..af25c65 100644 --- a/test/unit/controllers/blockchain-controller-unit.js +++ b/test/unit/controllers/blockchain-controller-unit.js @@ -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({ diff --git a/test/unit/controllers/rawtransactions-controller-unit.js b/test/unit/controllers/rawtransactions-controller-unit.js new file mode 100644 index 0000000..fcd9100 --- /dev/null +++ b/test/unit/controllers/rawtransactions-controller-unit.js @@ -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' }) + }) + }) +}) diff --git a/test/unit/controllers/rest-api-index-unit.js b/test/unit/controllers/rest-api-index-unit.js index 00dc9d9..96685d2 100644 --- a/test/unit/controllers/rest-api-index-unit.js +++ b/test/unit/controllers/rest-api-index-unit.js @@ -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) }) }) }) diff --git a/test/unit/use-cases/full-node-rawtransactions-use-cases-unit.js b/test/unit/use-cases/full-node-rawtransactions-use-cases-unit.js new file mode 100644 index 0000000..95dae8d --- /dev/null +++ b/test/unit/use-cases/full-node-rawtransactions-use-cases-unit.js @@ -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') + }) + }) +})