From ca25e33517a8da6b6e408ce5b3e4f70daac3d87b Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Fri, 14 Nov 2025 07:25:23 -0800 Subject: [PATCH] feat(fulcrum): Ported fulcrum endpoints from bch-api --- package-lock.json | 1 + package.json | 1 + src/adapters/fulcrum-api.js | 124 ++++ src/adapters/index.js | 2 + src/config/env/common.js | 6 + .../rest-api/full-node/fulcrum/controller.js | 563 ++++++++++++++++++ .../rest-api/full-node/fulcrum/router.js | 64 ++ src/controllers/rest-api/index.js | 4 + src/use-cases/full-node-fulcrum-use-cases.js | 155 +++++ src/use-cases/index.js | 2 + .../controllers/fulcrum-controller-unit.js | 481 +++++++++++++++ test/unit/controllers/rest-api-index-unit.js | 19 + .../full-node-fulcrum-use-cases-unit.js | 297 +++++++++ 13 files changed, 1719 insertions(+) create mode 100644 src/adapters/fulcrum-api.js create mode 100644 src/controllers/rest-api/full-node/fulcrum/controller.js create mode 100644 src/controllers/rest-api/full-node/fulcrum/router.js create mode 100644 src/use-cases/full-node-fulcrum-use-cases.js create mode 100644 test/unit/controllers/fulcrum-controller-unit.js create mode 100644 test/unit/use-cases/full-node-fulcrum-use-cases-unit.js diff --git a/package-lock.json b/package-lock.json index 30839cd..cb3c2f2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "MIT", "dependencies": { + "@psf/bch-js": "6.8.3", "axios": "1.7.7", "cors": "2.8.5", "dotenv": "16.3.1", diff --git a/package.json b/package.json index c1d6292..5386d53 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "license": "MIT", "description": "REST API proxy to Bitcoin Cash infrastructure", "dependencies": { + "@psf/bch-js": "6.8.3", "axios": "1.7.7", "cors": "2.8.5", "dotenv": "16.3.1", diff --git a/src/adapters/fulcrum-api.js b/src/adapters/fulcrum-api.js new file mode 100644 index 0000000..d39a7f7 --- /dev/null +++ b/src/adapters/fulcrum-api.js @@ -0,0 +1,124 @@ +/* + Adapter library for interacting with Fulcrum API service over HTTP. +*/ + +import axios from 'axios' +import wlogger from './wlogger.js' +import config from '../config/index.js' + +class FulcrumAPIAdapter { + constructor (localConfig = {}) { + this.config = localConfig.config || config + + // Allow missing config for testing environments + if (!this.config.fulcrumApi || !this.config.fulcrumApi.baseUrl) { + if (process.env.NODE_ENV === 'test' || process.env.TEST) { + // In test environment, create a mock baseURL + this.config.fulcrumApi = { + baseUrl: 'http://localhost:50001', + timeoutMs: 15000 + } + } else { + throw new Error('FULCRUM_API env var not set. Can not connect to Fulcrum indexer.') + } + } + + const { + baseUrl, + timeoutMs = 15000 + } = this.config.fulcrumApi + + this.http = axios.create({ + baseURL: baseUrl, + timeout: timeoutMs + }) + } + + async get (path) { + try { + const response = await this.http.get(path) + return response.data + } catch (err) { + throw this._handleError(err) + } + } + + async post (path, data) { + try { + const response = await this.http.post(path, data) + return response.data + } catch (err) { + throw this._handleError(err) + } + } + + _handleError (err) { + const { status, message } = this.decodeError(err) + const error = new Error(message) + error.status = status + error.originalError = err + return error + } + + decodeError (err) { + try { + // Attempt to extract error message from response data + if (err.response && err.response.data) { + const data = err.response.data + // Handle structured error responses + if (data.error) { + return this._formatError(data.error, err.response.status || 400) + } + // Handle string error messages + if (typeof data === 'string') { + return this._formatError(data, err.response.status || 400) + } + // Handle object responses that might contain error info + if (typeof data === 'object' && data.message) { + return this._formatError(data.message, err.response.status || 400) + } + // Fallback to returning the status + return this._formatError('Fulcrum API error', err.response.status || 500) + } + + // Network errors + if (err.message) { + if (err.message.includes('ENOTFOUND') || err.message.includes('ENETUNREACH') || err.message.includes('EAI_AGAIN')) { + return this._formatError( + 'Network error: Could not communicate with Fulcrum API service.', + 503 + ) + } + } + + if (err.code && (err.code === 'ECONNABORTED' || err.code === 'ECONNREFUSED')) { + return this._formatError( + 'Network error: Could not communicate with Fulcrum API service.', + 503 + ) + } + + if (err.error && typeof err.error === 'string' && err.error.includes('429')) { + return this._formatError('429 Too Many Requests', 429) + } + + if (err.message) { + return this._formatError(err.message, err.status || 422) + } + + return this._formatError('Unhandled Fulcrum API error', 500) + } catch (decodeError) { + wlogger.error('Unhandled error in FulcrumAPIAdapter.decodeError()', decodeError) + return this._formatError('Internal server error', 500) + } + } + + _formatError (message, status = 500) { + return { + message: message || 'Internal server error', + status: status || 500 + } + } +} + +export default FulcrumAPIAdapter diff --git a/src/adapters/index.js b/src/adapters/index.js index ffabb9d..f9b5c96 100644 --- a/src/adapters/index.js +++ b/src/adapters/index.js @@ -7,6 +7,7 @@ // Load individual adapter libraries. // import NostrRelayAdapter from './nostr-relay.js' import FullNodeRPCAdapter from './full-node-rpc.js' +import FulcrumAPIAdapter from './fulcrum-api.js' import config from '../config/index.js' class Adapters { @@ -33,6 +34,7 @@ class Adapters { // this.nostrRelay = this.nostrRelays[0] this.fullNode = new FullNodeRPCAdapter({ config: this.config }) + this.fulcrum = new FulcrumAPIAdapter({ config: this.config }) } async start () { diff --git a/src/config/env/common.js b/src/config/env/common.js index 137fdd4..698fc6f 100644 --- a/src/config/env/common.js +++ b/src/config/env/common.js @@ -60,6 +60,12 @@ export default { rpcRequestIdPrefix: process.env.RPC_REQUEST_ID_PREFIX || 'psf-bch-api' }, + // Fulcrum API configuration + fulcrumApi: { + baseUrl: process.env.FULCRUM_API || '', + timeoutMs: Number(process.env.FULCRUM_TIMEOUT_MS || 15000) + }, + x402: x402Defaults, // Version diff --git a/src/controllers/rest-api/full-node/fulcrum/controller.js b/src/controllers/rest-api/full-node/fulcrum/controller.js new file mode 100644 index 0000000..5c172b0 --- /dev/null +++ b/src/controllers/rest-api/full-node/fulcrum/controller.js @@ -0,0 +1,563 @@ +/* + REST API Controller for the /full-node/fulcrum routes. +*/ + +import wlogger from '../../../../adapters/wlogger.js' +import BCHJS from '@psf/bch-js' + +const bchjs = new BCHJS() + +class FulcrumRESTController { + constructor (localConfig = {}) { + this.adapters = localConfig.adapters + if (!this.adapters) { + throw new Error( + 'Instance of Adapters library required when instantiating Fulcrum REST Controller.' + ) + } + + this.useCases = localConfig.useCases + if (!this.useCases || !this.useCases.fulcrum) { + throw new Error( + 'Instance of Fulcrum use cases required when instantiating Fulcrum REST Controller.' + ) + } + + this.fulcrumUseCases = this.useCases.fulcrum + + // Bind functions + this.root = this.root.bind(this) + this.getBalance = this.getBalance.bind(this) + this.balanceBulk = this.balanceBulk.bind(this) + this.getUtxos = this.getUtxos.bind(this) + this.utxosBulk = this.utxosBulk.bind(this) + this.getTransactionDetails = this.getTransactionDetails.bind(this) + this.transactionDetailsBulk = this.transactionDetailsBulk.bind(this) + this.broadcastTransaction = this.broadcastTransaction.bind(this) + this.getBlockHeaders = this.getBlockHeaders.bind(this) + this.blockHeadersBulk = this.blockHeadersBulk.bind(this) + this.getTransactions = this.getTransactions.bind(this) + this.transactionsBulk = this.transactionsBulk.bind(this) + this.getMempool = this.getMempool.bind(this) + this.mempoolBulk = this.mempoolBulk.bind(this) + this.handleError = this.handleError.bind(this) + } + + /** + * @api {get} /v6/full-node/fulcrum/ Service status + * @apiName FulcrumRoot + * @apiGroup Fulcrum + * + * @apiDescription Returns the status of the fulcrum service. + * + * @apiSuccess {String} status Service identifier + */ + async root (req, res) { + return res.status(200).json({ status: 'fulcrum' }) + } + + /** + * Validates and converts an address to cash address format + * @param {string} address - Address to validate and convert + * @returns {string} Cash address + * @throws {Error} If address is invalid or not mainnet + */ + _validateAndConvertAddress (address) { + if (!address) { + throw new Error('address is empty') + } + + // Convert legacy to cash address + const cashAddr = bchjs.Address.toCashAddress(address) + + // Ensure it's a valid BCH address + try { + bchjs.Address.toLegacyAddress(cashAddr) + } catch (err) { + throw new Error(`Invalid BCH address. Double check your address is valid: ${address}`) + } + + // Ensure it's mainnet (no testnet support) + const isMainnet = bchjs.Address.isMainnetAddress(cashAddr) + if (!isMainnet) { + throw new Error('Invalid network. Only mainnet addresses are supported.') + } + + return cashAddr + } + + /** + * @api {get} /v6/full-node/fulcrum/balance/:address Get balance for a single address + * @apiName GetBalance + * @apiGroup Fulcrum + * @apiDescription Returns an object with confirmed and unconfirmed balance associated with an address. + */ + async getBalance (req, res) { + try { + const address = req.params.address + + if (Array.isArray(address)) { + return res.status(400).json({ + success: false, + error: 'address can not be an array. Use POST for bulk upload.' + }) + } + + const cashAddr = this._validateAndConvertAddress(address) + + const result = await this.fulcrumUseCases.getBalance({ address: cashAddr }) + return res.status(200).json(result) + } catch (err) { + return this.handleError(err, res) + } + } + + /** + * @api {post} /v6/full-node/fulcrum/balance Get balances for an array of addresses + * @apiName GetBalances + * @apiGroup Fulcrum + * @apiDescription Returns an array of balances associated with an array of addresses. Limited to 20 items per request. + */ + async balanceBulk (req, res) { + try { + const addresses = req.body.addresses + + if (!Array.isArray(addresses)) { + return res.status(400).json({ + success: false, + error: 'addresses needs to be an array. Use GET for single address.' + }) + } + + if (!this.adapters.fullNode.validateArraySize(addresses.length)) { + return res.status(400).json({ + success: false, + error: 'Array too large.' + }) + } + + // Validate and convert all addresses + const validatedAddresses = [] + for (let i = 0; i < addresses.length; i++) { + try { + const cashAddr = this._validateAndConvertAddress(addresses[i]) + validatedAddresses.push(cashAddr) + } catch (err) { + return res.status(400).json({ + success: false, + error: err.message + }) + } + } + + const result = await this.fulcrumUseCases.getBalances({ addresses: validatedAddresses }) + return res.status(200).json(result) + } catch (err) { + return this.handleError(err, res) + } + } + + /** + * @api {get} /v6/full-node/fulcrum/utxos/:address Get utxos for a single address + * @apiName GetUtxos + * @apiGroup Fulcrum + * @apiDescription Returns an object with UTXOs associated with an address. + */ + async getUtxos (req, res) { + try { + const address = req.params.address + + if (Array.isArray(address)) { + return res.status(400).json({ + success: false, + error: 'address can not be an array. Use POST for bulk upload.' + }) + } + + const cashAddr = this._validateAndConvertAddress(address) + + const result = await this.fulcrumUseCases.getUtxos({ address: cashAddr }) + return res.status(200).json(result) + } catch (err) { + return this.handleError(err, res) + } + } + + /** + * @api {post} /v6/full-node/fulcrum/utxos Get utxos for an array of addresses + * @apiName GetUtxosBulk + * @apiGroup Fulcrum + * @apiDescription Returns an array of objects with UTXOs associated with an address. Limited to 20 items per request. + */ + async utxosBulk (req, res) { + try { + const addresses = req.body.addresses + + if (!Array.isArray(addresses)) { + return res.status(400).json({ + success: false, + error: 'addresses needs to be an array. Use GET for single address.' + }) + } + + if (!this.adapters.fullNode.validateArraySize(addresses.length)) { + return res.status(400).json({ + success: false, + error: 'Array too large.' + }) + } + + // Validate and convert all addresses + const validatedAddresses = [] + for (let i = 0; i < addresses.length; i++) { + try { + const cashAddr = this._validateAndConvertAddress(addresses[i]) + validatedAddresses.push(cashAddr) + } catch (err) { + return res.status(400).json({ + success: false, + error: err.message + }) + } + } + + const result = await this.fulcrumUseCases.getUtxosBulk({ addresses: validatedAddresses }) + return res.status(200).json(result) + } catch (err) { + return this.handleError(err, res) + } + } + + /** + * @api {get} /v6/full-node/fulcrum/tx/data/:txid Get transaction details for a TXID + * @apiName GetTransactionDetails + * @apiGroup Fulcrum + * @apiDescription Returns an object with transaction details of the TXID + */ + async getTransactionDetails (req, res) { + try { + const txid = req.params.txid + + if (typeof txid !== 'string') { + return res.status(400).json({ + success: false, + error: 'txid must be a string' + }) + } + + const result = await this.fulcrumUseCases.getTransactionDetails({ txid }) + return res.status(200).json(result) + } catch (err) { + return this.handleError(err, res) + } + } + + /** + * @api {post} /v6/full-node/fulcrum/tx/data Get transaction details for an array of TXIDs + * @apiName GetTransactionDetailsBulk + * @apiGroup Fulcrum + * @apiDescription Returns an array of objects with transaction details of an array of TXIDs. Limited to 20 items per request. + */ + async transactionDetailsBulk (req, res) { + try { + const txids = req.body.txids + const verbose = req.body.verbose !== undefined ? req.body.verbose : true + + if (!Array.isArray(txids)) { + return res.status(400).json({ + success: false, + error: 'txids needs to be an array. Use GET for single txid.' + }) + } + + if (!this.adapters.fullNode.validateArraySize(txids.length)) { + return res.status(400).json({ + success: false, + error: 'Array too large.' + }) + } + + const result = await this.fulcrumUseCases.getTransactionDetailsBulk({ txids, verbose }) + return res.status(200).json(result) + } catch (err) { + return this.handleError(err, res) + } + } + + /** + * @api {post} /v6/full-node/fulcrum/tx/broadcast Broadcast a raw transaction + * @apiName BroadcastTransaction + * @apiGroup Fulcrum + * @apiDescription Broadcast a raw transaction and return the transaction ID on success or error on failure. + */ + async broadcastTransaction (req, res) { + try { + const txHex = req.body.txHex + + if (typeof txHex !== 'string') { + return res.status(400).json({ + success: false, + error: 'txHex must be a string' + }) + } + + const result = await this.fulcrumUseCases.broadcastTransaction({ txHex }) + return res.status(200).json(result) + } catch (err) { + return this.handleError(err, res) + } + } + + /** + * @api {get} /v6/full-node/fulcrum/block/headers/:height Get block headers + * @apiName GetBlockHeaders + * @apiGroup Fulcrum + * @apiDescription Returns an array with block headers starting at the block height + * + * @apiParam {Number} height Block height + * @apiParam {Number} count Number of block headers to return (query parameter, default: 1) + */ + async getBlockHeaders (req, res) { + try { + const heightRaw = req.params.height + const countRaw = req.query.count + + const height = Number(heightRaw) + const count = countRaw === undefined ? 1 : Number(countRaw) + + if (Number.isNaN(height) || height < 0) { + return res.status(400).json({ + success: false, + error: 'height must be a positive number' + }) + } + + if (Number.isNaN(count) || count < 0) { + return res.status(400).json({ + success: false, + error: 'count must be a positive number' + }) + } + + const result = await this.fulcrumUseCases.getBlockHeaders({ height, count }) + return res.status(200).json(result) + } catch (err) { + return this.handleError(err, res) + } + } + + /** + * @api {post} /v6/full-node/fulcrum/block/headers Get block headers for an array of height + count pairs + * @apiName GetBlockHeadersBulk + * @apiGroup Fulcrum + * @apiDescription Returns an array of objects with block headers. Limited to 20 items per request. + */ + async blockHeadersBulk (req, res) { + try { + const heights = req.body.heights + + if (!Array.isArray(heights)) { + return res.status(400).json({ + success: false, + error: 'heights needs to be an array. Use GET for single height.' + }) + } + + if (!this.adapters.fullNode.validateArraySize(heights.length)) { + return res.status(400).json({ + success: false, + error: 'Array too large.' + }) + } + + // Validate each height object + for (const item of heights) { + if (!item || typeof item.height !== 'number' || typeof item.count !== 'number') { + return res.status(400).json({ + success: false, + error: 'Each height object must have numeric height and count properties' + }) + } + if (item.height < 0 || item.count < 0) { + return res.status(400).json({ + success: false, + error: 'height and count must be positive numbers' + }) + } + } + + const result = await this.fulcrumUseCases.getBlockHeadersBulk({ heights }) + return res.status(200).json(result) + } catch (err) { + return this.handleError(err, res) + } + } + + /** + * @api {get} /v6/full-node/fulcrum/transactions/:address Get transaction history for a single address + * @apiName GetTransactions + * @apiGroup Fulcrum + * @apiDescription Returns an array of historical transactions associated with an address. Results are returned in descending order (most recent TX first). Passing allTxs=true will return the entire transaction history, otherwise, only the last 100 TXIDs will be returned. + * + * @apiParam {String} address Address + * @apiParam {Boolean} allTxs Optional: return all transactions (default: false, limited to 100) + */ + async getTransactions (req, res) { + try { + const address = req.params.address + let allTxs = false + + // Check if allTxs is in params or query + if (req.params.allTxs) { + allTxs = req.params.allTxs === 'true' + } else if (req.query.allTxs) { + allTxs = req.query.allTxs === 'true' + } + + if (Array.isArray(address)) { + return res.status(400).json({ + success: false, + error: 'address can not be an array. Use POST for bulk upload.' + }) + } + + const cashAddr = this._validateAndConvertAddress(address) + + const result = await this.fulcrumUseCases.getTransactions({ address: cashAddr, allTxs }) + return res.status(200).json(result) + } catch (err) { + return this.handleError(err, res) + } + } + + /** + * @api {post} /v6/full-node/fulcrum/transactions Get the transaction history for an array of addresses + * @apiName GetTransactionsBulk + * @apiGroup Fulcrum + * @apiDescription Returns an array of transactions associated with an array of addresses. Limited to 20 items per request. Passing allTxs=true will return the entire transaction history, otherwise, only the last 100 TXIDs will be returned. + */ + async transactionsBulk (req, res) { + try { + const addresses = req.body.addresses + const allTxs = req.body.allTxs === true + + if (!Array.isArray(addresses)) { + return res.status(400).json({ + success: false, + error: 'addresses needs to be an array. Use GET for single address.' + }) + } + + if (!this.adapters.fullNode.validateArraySize(addresses.length)) { + return res.status(400).json({ + success: false, + error: 'Array too large.' + }) + } + + // Validate and convert all addresses + const validatedAddresses = [] + for (let i = 0; i < addresses.length; i++) { + try { + const cashAddr = this._validateAndConvertAddress(addresses[i]) + validatedAddresses.push(cashAddr) + } catch (err) { + return res.status(400).json({ + success: false, + error: err.message + }) + } + } + + const result = await this.fulcrumUseCases.getTransactionsBulk({ + addresses: validatedAddresses, + allTxs + }) + return res.status(200).json(result) + } catch (err) { + return this.handleError(err, res) + } + } + + /** + * @api {get} /v6/full-node/fulcrum/unconfirmed/:address Get unconfirmed utxos for a single address + * @apiName GetMempool + * @apiGroup Fulcrum + * @apiDescription Returns an object with unconfirmed UTXOs associated with an address. + */ + async getMempool (req, res) { + try { + const address = req.params.address + + if (Array.isArray(address)) { + return res.status(400).json({ + success: false, + error: 'address can not be an array. Use POST for bulk upload.' + }) + } + + const cashAddr = this._validateAndConvertAddress(address) + + const result = await this.fulcrumUseCases.getMempool({ address: cashAddr }) + return res.status(200).json(result) + } catch (err) { + return this.handleError(err, res) + } + } + + /** + * @api {post} /v6/full-node/fulcrum/unconfirmed Get unconfirmed utxos for an array of addresses + * @apiName GetMempoolBulk + * @apiGroup Fulcrum + * @apiDescription Returns an array of objects with unconfirmed UTXOs associated with an address. Limited to 20 items per request. + */ + async mempoolBulk (req, res) { + try { + const addresses = req.body.addresses + + if (!Array.isArray(addresses)) { + return res.status(400).json({ + success: false, + error: 'addresses needs to be an array. Use GET for single address.' + }) + } + + if (!this.adapters.fullNode.validateArraySize(addresses.length)) { + return res.status(400).json({ + success: false, + error: 'Array too large.' + }) + } + + // Validate and convert all addresses + const validatedAddresses = [] + for (let i = 0; i < addresses.length; i++) { + try { + const cashAddr = this._validateAndConvertAddress(addresses[i]) + validatedAddresses.push(cashAddr) + } catch (err) { + return res.status(400).json({ + success: false, + error: err.message + }) + } + } + + const result = await this.fulcrumUseCases.getMempoolBulk({ addresses: validatedAddresses }) + return res.status(200).json(result) + } catch (err) { + return this.handleError(err, res) + } + } + + handleError (err, res) { + wlogger.error('Error in FulcrumRESTController:', err) + + const status = err.status || 500 + const message = err.message || 'Internal server error' + + return res.status(status).json({ error: message }) + } +} + +export default FulcrumRESTController diff --git a/src/controllers/rest-api/full-node/fulcrum/router.js b/src/controllers/rest-api/full-node/fulcrum/router.js new file mode 100644 index 0000000..4f75073 --- /dev/null +++ b/src/controllers/rest-api/full-node/fulcrum/router.js @@ -0,0 +1,64 @@ +/* + REST API router for /full-node/fulcrum routes. +*/ + +import express from 'express' +import FulcrumRESTController from './controller.js' + +class FulcrumRouter { + constructor (localConfig = {}) { + this.adapters = localConfig.adapters + if (!this.adapters) { + throw new Error( + 'Instance of Adapters library required when instantiating Fulcrum REST Router.' + ) + } + + this.useCases = localConfig.useCases + if (!this.useCases) { + throw new Error( + 'Instance of Use Cases library required when instantiating Fulcrum REST Router.' + ) + } + + const dependencies = { + adapters: this.adapters, + useCases: this.useCases + } + + this.fulcrumController = new FulcrumRESTController(dependencies) + + this.apiPrefix = (localConfig.apiPrefix || '').replace(/\/$/, '') + this.baseUrl = `${this.apiPrefix}/full-node/fulcrum` + 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.fulcrumController.root) + this.router.get('/balance/:address', this.fulcrumController.getBalance) + this.router.post('/balance', this.fulcrumController.balanceBulk) + this.router.get('/utxos/:address', this.fulcrumController.getUtxos) + this.router.post('/utxos', this.fulcrumController.utxosBulk) + this.router.get('/tx/data/:txid', this.fulcrumController.getTransactionDetails) + this.router.post('/tx/data', this.fulcrumController.transactionDetailsBulk) + this.router.post('/tx/broadcast', this.fulcrumController.broadcastTransaction) + this.router.get('/block/headers/:height', this.fulcrumController.getBlockHeaders) + this.router.post('/block/headers', this.fulcrumController.blockHeadersBulk) + this.router.get('/transactions/:address', this.fulcrumController.getTransactions) + this.router.get('/transactions/:address/:allTxs', this.fulcrumController.getTransactions) + this.router.post('/transactions', this.fulcrumController.transactionsBulk) + this.router.get('/unconfirmed/:address', this.fulcrumController.getMempool) + this.router.post('/unconfirmed', this.fulcrumController.mempoolBulk) + + app.use(this.baseUrl, this.router) + } +} + +export default FulcrumRouter diff --git a/src/controllers/rest-api/index.js b/src/controllers/rest-api/index.js index 02961bd..1abd795 100644 --- a/src/controllers/rest-api/index.js +++ b/src/controllers/rest-api/index.js @@ -10,6 +10,7 @@ import BlockchainRouter from './full-node/blockchain/router.js' import ControlRouter from './full-node/control/router.js' import DSProofRouter from './full-node/dsproof/router.js' +import FulcrumRouter from './full-node/fulcrum/router.js' import MiningRouter from './full-node/mining/router.js' import RawTransactionsRouter from './full-node/rawtransactions/router.js' import config from '../../config/index.js' @@ -67,6 +68,9 @@ class RESTControllers { const dsproofRouter = new DSProofRouter(dependencies) dsproofRouter.attach(app) + const fulcrumRouter = new FulcrumRouter(dependencies) + fulcrumRouter.attach(app) + const miningRouter = new MiningRouter(dependencies) miningRouter.attach(app) diff --git a/src/use-cases/full-node-fulcrum-use-cases.js b/src/use-cases/full-node-fulcrum-use-cases.js new file mode 100644 index 0000000..75fa266 --- /dev/null +++ b/src/use-cases/full-node-fulcrum-use-cases.js @@ -0,0 +1,155 @@ +/* + Use cases for interacting with the Fulcrum API service. +*/ + +import wlogger from '../adapters/wlogger.js' +import BCHJS from '@psf/bch-js' + +const bchjs = new BCHJS() + +class FulcrumUseCases { + constructor (localConfig = {}) { + this.adapters = localConfig.adapters + + if (!this.adapters) { + throw new Error('Adapters instance required when instantiating Fulcrum use cases.') + } + + this.fulcrum = this.adapters.fulcrum + if (!this.fulcrum) { + throw new Error('Fulcrum adapter required when instantiating Fulcrum use cases.') + } + + // Allow bchjs to be injected for testing + this.bchjs = localConfig.bchjs || bchjs + } + + async getBalance ({ address }) { + return this.fulcrum.get(`electrumx/balance/${address}`) + } + + async getBalances ({ addresses }) { + try { + const response = await this.fulcrum.post('electrumx/balance/', { addresses }) + return response + } catch (err) { + wlogger.error('Error in FulcrumUseCases.getBalances()', err) + throw err + } + } + + async getUtxos ({ address }) { + return this.fulcrum.get(`electrumx/utxos/${address}`) + } + + async getUtxosBulk ({ addresses }) { + try { + const response = await this.fulcrum.post('electrumx/utxos/', { addresses }) + return response + } catch (err) { + wlogger.error('Error in FulcrumUseCases.getUtxosBulk()', err) + throw err + } + } + + async getTransactionDetails ({ txid }) { + return this.fulcrum.get(`electrumx/tx/data/${txid}`) + } + + async getTransactionDetailsBulk ({ txids, verbose }) { + try { + const response = await this.fulcrum.post('electrumx/tx/data', { txids, verbose }) + return response + } catch (err) { + wlogger.error('Error in FulcrumUseCases.getTransactionDetailsBulk()', err) + throw err + } + } + + async broadcastTransaction ({ txHex }) { + try { + const response = await this.fulcrum.post('electrumx/tx/broadcast', { txHex }) + return response + } catch (err) { + wlogger.error('Error in FulcrumUseCases.broadcastTransaction()', err) + throw err + } + } + + async getBlockHeaders ({ height, count }) { + return this.fulcrum.get(`electrumx/block/headers/${height}?count=${count}`) + } + + async getBlockHeadersBulk ({ heights }) { + try { + const response = await this.fulcrum.post('electrumx/block/headers', { heights }) + return response + } catch (err) { + wlogger.error('Error in FulcrumUseCases.getBlockHeadersBulk()', err) + throw err + } + } + + async getTransactions ({ address, allTxs }) { + try { + const response = await this.fulcrum.get(`electrumx/transactions/${address}`) + + // Sort transactions in descending order, so that newest transactions are first. + if (response.transactions && Array.isArray(response.transactions)) { + response.transactions = await this.bchjs.Electrumx.sortAllTxs(response.transactions, 'DESCENDING') + + if (!allTxs) { + // Return only the first 100 transactions of the history. + response.transactions = response.transactions.slice(0, 100) + } + } + + return response + } catch (err) { + wlogger.error('Error in FulcrumUseCases.getTransactions()', err) + throw err + } + } + + async getTransactionsBulk ({ addresses, allTxs }) { + try { + const response = await this.fulcrum.post('electrumx/transactions/', { addresses }) + + // Sort transactions in descending order for each address entry. + if (response.transactions && Array.isArray(response.transactions)) { + for (let i = 0; i < response.transactions.length; i++) { + const thisEntry = response.transactions[i] + if (thisEntry.transactions && Array.isArray(thisEntry.transactions)) { + thisEntry.transactions = await this.bchjs.Electrumx.sortAllTxs(thisEntry.transactions, 'DESCENDING') + + if (!allTxs && thisEntry.transactions.length > 100) { + // Extract only the first 100 transactions. + thisEntry.transactions = thisEntry.transactions.slice(0, 100) + } + } + } + } + + return response + } catch (err) { + wlogger.error('Error in FulcrumUseCases.getTransactionsBulk()', err) + throw err + } + } + + async getMempool ({ address }) { + return this.fulcrum.get(`electrumx/unconfirmed/${address}`) + } + + async getMempoolBulk ({ addresses }) { + try { + const response = await this.fulcrum.post('electrumx/unconfirmed/', { addresses }) + return response + } catch (err) { + wlogger.error('Error in FulcrumUseCases.getMempoolBulk()', err) + throw err + } + } +} + +export default FulcrumUseCases diff --git a/src/use-cases/index.js b/src/use-cases/index.js index 8d740f1..a769b79 100644 --- a/src/use-cases/index.js +++ b/src/use-cases/index.js @@ -8,6 +8,7 @@ import BlockchainUseCases from './full-node-blockchain-use-cases.js' import ControlUseCases from './full-node-control-use-cases.js' import DSProofUseCases from './full-node-dsproof-use-cases.js' +import FulcrumUseCases from './full-node-fulcrum-use-cases.js' import MiningUseCases from './full-node-mining-use-cases.js' import RawTransactionsUseCases from './full-node-rawtransactions-use-cases.js' @@ -23,6 +24,7 @@ class UseCases { this.blockchain = new BlockchainUseCases({ adapters: this.adapters }) this.control = new ControlUseCases({ adapters: this.adapters }) this.dsproof = new DSProofUseCases({ adapters: this.adapters }) + this.fulcrum = new FulcrumUseCases({ adapters: this.adapters }) this.mining = new MiningUseCases({ adapters: this.adapters }) this.rawtransactions = new RawTransactionsUseCases({ adapters: this.adapters }) } diff --git a/test/unit/controllers/fulcrum-controller-unit.js b/test/unit/controllers/fulcrum-controller-unit.js new file mode 100644 index 0000000..6213fee --- /dev/null +++ b/test/unit/controllers/fulcrum-controller-unit.js @@ -0,0 +1,481 @@ +/* + Unit tests for FulcrumRESTController. +*/ + +import { assert } from 'chai' +import sinon from 'sinon' + +import FulcrumRESTController from '../../../src/controllers/rest-api/full-node/fulcrum/controller.js' +import { + createMockRequest, + createMockResponse +} from '../mocks/controller-mocks.js' + +// Valid mainnet cash address for testing +const VALID_MAINNET_ADDRESS = 'bitcoincash:qrdka2205f4hyukutc2g0s6lykperc8nsu5u2ddpqf' + +describe('#fulcrum-controller.js', () => { + let sandbox + let mockUseCases + let mockAdapters + let uut + + const createFulcrumUseCaseStubs = () => ({ + getBalance: sandbox.stub().resolves({ balance: 1000 }), + getBalances: sandbox.stub().resolves({ balances: [] }), + getUtxos: sandbox.stub().resolves({ utxos: [] }), + getUtxosBulk: sandbox.stub().resolves({ utxos: [] }), + getTransactionDetails: sandbox.stub().resolves({ txid: 'abc' }), + getTransactionDetailsBulk: sandbox.stub().resolves({ transactions: [] }), + broadcastTransaction: sandbox.stub().resolves({ txid: 'abc' }), + getBlockHeaders: sandbox.stub().resolves({ headers: [] }), + getBlockHeadersBulk: sandbox.stub().resolves({ headers: [] }), + getTransactions: sandbox.stub().resolves({ transactions: [] }), + getTransactionsBulk: sandbox.stub().resolves({ transactions: [] }), + getMempool: sandbox.stub().resolves({ mempool: [] }), + getMempoolBulk: sandbox.stub().resolves({ mempool: [] }) + }) + + beforeEach(() => { + sandbox = sinon.createSandbox() + mockAdapters = { + fullNode: { + validateArraySize: sandbox.stub().returns(true) + } + } + mockUseCases = { + fulcrum: createFulcrumUseCaseStubs() + } + + uut = new FulcrumRESTController({ + adapters: mockAdapters, + useCases: mockUseCases + }) + }) + + afterEach(() => { + sandbox.restore() + }) + + describe('#constructor()', () => { + it('should require adapters', () => { + assert.throws(() => { + // eslint-disable-next-line no-new + new FulcrumRESTController({ useCases: mockUseCases }) + }, /Adapters library required/) + }) + + it('should require fulcrum use cases', () => { + assert.throws(() => { + // eslint-disable-next-line no-new + new FulcrumRESTController({ adapters: mockAdapters, useCases: {} }) + }, /Fulcrum use cases required/) + }) + }) + + describe('#root()', () => { + it('should return service status', async () => { + const req = createMockRequest() + const res = createMockResponse() + + await uut.root(req, res) + + assert.equal(res.statusValue, 200) + assert.deepEqual(res.jsonData, { status: 'fulcrum' }) + }) + }) + + describe('#getBalance()', () => { + it('should return balance on success', async () => { + const req = createMockRequest({ + params: { address: VALID_MAINNET_ADDRESS } + }) + const res = createMockResponse() + + await uut.getBalance(req, res) + + assert.equal(res.statusValue, 200) + assert.deepEqual(res.jsonData, { balance: 1000 }) + assert.isTrue(mockUseCases.fulcrum.getBalance.calledOnce) + }) + + it('should return error if address is array', async () => { + const req = createMockRequest({ + params: { address: [] } + }) + const res = createMockResponse() + + await uut.getBalance(req, res) + + assert.equal(res.statusValue, 400) + assert.property(res.jsonData, 'error') + }) + + it('should handle errors via handleError', async () => { + const error = new Error('failure') + error.status = 503 + mockUseCases.fulcrum.getBalance.rejects(error) + const req = createMockRequest({ + params: { address: VALID_MAINNET_ADDRESS } + }) + const res = createMockResponse() + + await uut.getBalance(req, res) + + assert.equal(res.statusValue, 503) + assert.deepEqual(res.jsonData, { error: 'failure' }) + }) + }) + + describe('#balanceBulk()', () => { + it('should return error if addresses is not array', async () => { + const req = createMockRequest({ + body: { addresses: 'not-an-array' } + }) + const res = createMockResponse() + + await uut.balanceBulk(req, res) + + assert.equal(res.statusValue, 400) + assert.property(res.jsonData, 'error') + }) + + it('should validate array size and call use case', async () => { + const req = createMockRequest({ + body: { addresses: [VALID_MAINNET_ADDRESS] } + }) + const res = createMockResponse() + + await uut.balanceBulk(req, res) + + assert.equal(res.statusValue, 200) + assert.isTrue(mockAdapters.fullNode.validateArraySize.calledOnce) + assert.isTrue(mockUseCases.fulcrum.getBalances.calledOnce) + }) + + it('should return error if array size invalid', async () => { + mockAdapters.fullNode.validateArraySize.returns(false) + const req = createMockRequest({ + body: { addresses: [VALID_MAINNET_ADDRESS] } + }) + const res = createMockResponse() + + await uut.balanceBulk(req, res) + + assert.equal(res.statusValue, 400) + assert.equal(res.jsonData.error, 'Array too large.') + }) + }) + + describe('#getUtxos()', () => { + it('should return utxos on success', async () => { + const req = createMockRequest({ + params: { address: VALID_MAINNET_ADDRESS } + }) + const res = createMockResponse() + + await uut.getUtxos(req, res) + + assert.equal(res.statusValue, 200) + assert.deepEqual(res.jsonData, { utxos: [] }) + assert.isTrue(mockUseCases.fulcrum.getUtxos.calledOnce) + }) + }) + + describe('#utxosBulk()', () => { + it('should validate array and call use case', async () => { + const req = createMockRequest({ + body: { addresses: [VALID_MAINNET_ADDRESS] } + }) + const res = createMockResponse() + + await uut.utxosBulk(req, res) + + assert.equal(res.statusValue, 200) + assert.isTrue(mockUseCases.fulcrum.getUtxosBulk.calledOnce) + }) + }) + + describe('#getTransactionDetails()', () => { + it('should return transaction details on success', async () => { + const txid = 'a'.repeat(64) + const req = createMockRequest({ + params: { txid } + }) + const res = createMockResponse() + + await uut.getTransactionDetails(req, res) + + assert.equal(res.statusValue, 200) + assert.deepEqual(res.jsonData, { txid: 'abc' }) + assert.isTrue(mockUseCases.fulcrum.getTransactionDetails.calledOnce) + }) + + it('should return error if txid is not string', async () => { + const req = createMockRequest({ + params: { txid: 123 } + }) + const res = createMockResponse() + + await uut.getTransactionDetails(req, res) + + assert.equal(res.statusValue, 400) + assert.property(res.jsonData, 'error') + }) + }) + + describe('#transactionDetailsBulk()', () => { + it('should validate array and call use case', async () => { + const req = createMockRequest({ + body: { txids: ['a'.repeat(64)], verbose: true } + }) + const res = createMockResponse() + + await uut.transactionDetailsBulk(req, res) + + assert.equal(res.statusValue, 200) + assert.isTrue(mockUseCases.fulcrum.getTransactionDetailsBulk.calledOnce) + }) + + it('should default verbose to true', async () => { + const req = createMockRequest({ + body: { txids: ['a'.repeat(64)] } + }) + const res = createMockResponse() + + await uut.transactionDetailsBulk(req, res) + + assert.isTrue( + mockUseCases.fulcrum.getTransactionDetailsBulk.calledWithMatch({ + txids: ['a'.repeat(64)], + verbose: true + }) + ) + }) + }) + + describe('#broadcastTransaction()', () => { + it('should broadcast transaction on success', async () => { + const req = createMockRequest({ + body: { txHex: '010203' } + }) + const res = createMockResponse() + + await uut.broadcastTransaction(req, res) + + assert.equal(res.statusValue, 200) + assert.deepEqual(res.jsonData, { txid: 'abc' }) + assert.isTrue(mockUseCases.fulcrum.broadcastTransaction.calledOnce) + }) + + it('should return error if txHex is not string', async () => { + const req = createMockRequest({ + body: { txHex: 123 } + }) + const res = createMockResponse() + + await uut.broadcastTransaction(req, res) + + assert.equal(res.statusValue, 400) + assert.property(res.jsonData, 'error') + }) + }) + + describe('#getBlockHeaders()', () => { + it('should return block headers on success', async () => { + const req = createMockRequest({ + params: { height: '100' }, + query: { count: '2' } + }) + const res = createMockResponse() + + await uut.getBlockHeaders(req, res) + + assert.equal(res.statusValue, 200) + assert.isTrue( + mockUseCases.fulcrum.getBlockHeaders.calledWithMatch({ + height: 100, + count: 2 + }) + ) + }) + + it('should default count to 1', async () => { + const req = createMockRequest({ + params: { height: '100' } + }) + const res = createMockResponse() + + await uut.getBlockHeaders(req, res) + + assert.isTrue( + mockUseCases.fulcrum.getBlockHeaders.calledWithMatch({ + height: 100, + count: 1 + }) + ) + }) + + it('should return error if height is invalid', async () => { + const req = createMockRequest({ + params: { height: 'invalid' } + }) + const res = createMockResponse() + + await uut.getBlockHeaders(req, res) + + assert.equal(res.statusValue, 400) + assert.property(res.jsonData, 'error') + }) + }) + + describe('#blockHeadersBulk()', () => { + it('should validate heights array and call use case', async () => { + const req = createMockRequest({ + body: { heights: [{ height: 100, count: 2 }] } + }) + const res = createMockResponse() + + await uut.blockHeadersBulk(req, res) + + assert.equal(res.statusValue, 200) + assert.isTrue(mockUseCases.fulcrum.getBlockHeadersBulk.calledOnce) + }) + + it('should return error if heights is not array', async () => { + const req = createMockRequest({ + body: { heights: 'not-an-array' } + }) + const res = createMockResponse() + + await uut.blockHeadersBulk(req, res) + + assert.equal(res.statusValue, 400) + assert.property(res.jsonData, 'error') + }) + + it('should validate height objects', async () => { + const req = createMockRequest({ + body: { heights: [{ height: 'invalid', count: 2 }] } + }) + const res = createMockResponse() + + await uut.blockHeadersBulk(req, res) + + assert.equal(res.statusValue, 400) + assert.property(res.jsonData, 'error') + }) + }) + + describe('#getTransactions()', () => { + it('should return transactions on success', async () => { + const req = createMockRequest({ + params: { address: VALID_MAINNET_ADDRESS } + }) + const res = createMockResponse() + + await uut.getTransactions(req, res) + + assert.equal(res.statusValue, 200) + assert.isTrue(mockUseCases.fulcrum.getTransactions.calledOnce) + }) + + it('should handle allTxs from params', async () => { + const req = createMockRequest({ + params: { address: VALID_MAINNET_ADDRESS, allTxs: 'true' } + }) + const res = createMockResponse() + + await uut.getTransactions(req, res) + + assert.isTrue( + mockUseCases.fulcrum.getTransactions.calledWithMatch({ + address: VALID_MAINNET_ADDRESS, + allTxs: true + }) + ) + }) + + it('should handle allTxs from query', async () => { + const req = createMockRequest({ + params: { address: VALID_MAINNET_ADDRESS }, + query: { allTxs: 'true' } + }) + const res = createMockResponse() + + await uut.getTransactions(req, res) + + assert.isTrue( + mockUseCases.fulcrum.getTransactions.calledWithMatch({ + allTxs: true + }) + ) + }) + }) + + describe('#transactionsBulk()', () => { + it('should validate addresses and call use case', async () => { + const req = createMockRequest({ + body: { addresses: [VALID_MAINNET_ADDRESS], allTxs: true } + }) + const res = createMockResponse() + + await uut.transactionsBulk(req, res) + + assert.equal(res.statusValue, 200) + assert.isTrue(mockUseCases.fulcrum.getTransactionsBulk.calledOnce) + }) + }) + + describe('#getMempool()', () => { + it('should return mempool on success', async () => { + const req = createMockRequest({ + params: { address: VALID_MAINNET_ADDRESS } + }) + const res = createMockResponse() + + await uut.getMempool(req, res) + + assert.equal(res.statusValue, 200) + assert.deepEqual(res.jsonData, { mempool: [] }) + assert.isTrue(mockUseCases.fulcrum.getMempool.calledOnce) + }) + }) + + describe('#mempoolBulk()', () => { + it('should validate addresses and call use case', async () => { + const req = createMockRequest({ + body: { addresses: [VALID_MAINNET_ADDRESS] } + }) + const res = createMockResponse() + + await uut.mempoolBulk(req, res) + + assert.equal(res.statusValue, 200) + assert.isTrue(mockUseCases.fulcrum.getMempoolBulk.calledOnce) + }) + }) + + describe('#handleError()', () => { + it('should handle errors with status', async () => { + const error = new Error('test error') + error.status = 400 + const res = createMockResponse() + + uut.handleError(error, res) + + assert.equal(res.statusValue, 400) + assert.deepEqual(res.jsonData, { error: 'test error' }) + }) + + it('should default status to 500', async () => { + const error = new Error('test error') + const res = createMockResponse() + + uut.handleError(error, res) + + assert.equal(res.statusValue, 500) + assert.deepEqual(res.jsonData, { error: 'test error' }) + }) + }) +}) diff --git a/test/unit/controllers/rest-api-index-unit.js b/test/unit/controllers/rest-api-index-unit.js index 1ecccc1..1814509 100644 --- a/test/unit/controllers/rest-api-index-unit.js +++ b/test/unit/controllers/rest-api-index-unit.js @@ -9,6 +9,7 @@ import RESTControllers from '../../../src/controllers/rest-api/index.js' import BlockchainRouter from '../../../src/controllers/rest-api/full-node/blockchain/router.js' import ControlRouter from '../../../src/controllers/rest-api/full-node/control/router.js' import DSProofRouter from '../../../src/controllers/rest-api/full-node/dsproof/router.js' +import FulcrumRouter from '../../../src/controllers/rest-api/full-node/fulcrum/router.js' import MiningRouter from '../../../src/controllers/rest-api/full-node/mining/router.js' import RawTransactionsRouter from '../../../src/controllers/rest-api/full-node/rawtransactions/router.js' @@ -54,6 +55,21 @@ describe('#controllers/rest-api/index.js', () => { dsproof: { getDSProof: () => {} }, + fulcrum: { + getBalance: () => {}, + getBalances: () => {}, + getUtxos: () => {}, + getUtxosBulk: () => {}, + getTransactionDetails: () => {}, + getTransactionDetailsBulk: () => {}, + broadcastTransaction: () => {}, + getBlockHeaders: () => {}, + getBlockHeadersBulk: () => {}, + getTransactions: () => {}, + getTransactionsBulk: () => {}, + getMempool: () => {}, + getMempoolBulk: () => {} + }, mining: { getMiningInfo: () => {}, getNetworkHashPS: () => {} @@ -97,6 +113,7 @@ describe('#controllers/rest-api/index.js', () => { const blockchainAttachStub = sandbox.stub(BlockchainRouter.prototype, 'attach') const controlAttachStub = sandbox.stub(ControlRouter.prototype, 'attach') const dsproofAttachStub = sandbox.stub(DSProofRouter.prototype, 'attach') + const fulcrumAttachStub = sandbox.stub(FulcrumRouter.prototype, 'attach') const miningAttachStub = sandbox.stub(MiningRouter.prototype, 'attach') const rawtransactionsAttachStub = sandbox.stub(RawTransactionsRouter.prototype, 'attach') const restControllers = new RESTControllers({ @@ -113,6 +130,8 @@ describe('#controllers/rest-api/index.js', () => { assert.equal(controlAttachStub.getCall(0).args[0], app) assert.isTrue(dsproofAttachStub.calledOnce) assert.equal(dsproofAttachStub.getCall(0).args[0], app) + assert.isTrue(fulcrumAttachStub.calledOnce) + assert.equal(fulcrumAttachStub.getCall(0).args[0], app) assert.isTrue(miningAttachStub.calledOnce) assert.equal(miningAttachStub.getCall(0).args[0], app) assert.isTrue(rawtransactionsAttachStub.calledOnce) diff --git a/test/unit/use-cases/full-node-fulcrum-use-cases-unit.js b/test/unit/use-cases/full-node-fulcrum-use-cases-unit.js new file mode 100644 index 0000000..266bc95 --- /dev/null +++ b/test/unit/use-cases/full-node-fulcrum-use-cases-unit.js @@ -0,0 +1,297 @@ +/* + Unit tests for FulcrumUseCases. +*/ + +import { assert } from 'chai' +import sinon from 'sinon' +import BCHJS from '@psf/bch-js' + +import FulcrumUseCases from '../../../src/use-cases/full-node-fulcrum-use-cases.js' + +describe('#full-node-fulcrum-use-cases.js', () => { + let sandbox + let mockAdapters + let uut + let sortAllTxsStub + + beforeEach(() => { + sandbox = sinon.createSandbox() + mockAdapters = { + fulcrum: { + get: sandbox.stub().resolves({}), + post: sandbox.stub().resolves({}) + } + } + + // Create a mock BCHJS instance with stubbed sortAllTxs method + const mockBchjs = new BCHJS() + if (!mockBchjs.Electrumx) { + mockBchjs.Electrumx = {} + } + + // Create a stub that sorts transactions + sortAllTxsStub = sandbox.stub(mockBchjs.Electrumx, 'sortAllTxs') + sortAllTxsStub.callsFake(async (txs, order) => { + const sorted = [...txs].sort((a, b) => { + if (order === 'DESCENDING') { + return (b.height || 0) - (a.height || 0) + } + return (a.height || 0) - (b.height || 0) + }) + return sorted + }) + + // Inject the mocked bchjs instance into the use cases + uut = new FulcrumUseCases({ adapters: mockAdapters, bchjs: mockBchjs }) + }) + + afterEach(() => { + sandbox.restore() + }) + + describe('#constructor()', () => { + it('should require adapters', () => { + assert.throws(() => { + // eslint-disable-next-line no-new + new FulcrumUseCases() + }, /Adapters instance required/) + }) + + it('should require fulcrum adapter', () => { + assert.throws(() => { + // eslint-disable-next-line no-new + new FulcrumUseCases({ adapters: {} }) + }, /Fulcrum adapter required/) + }) + }) + + describe('#getBalance()', () => { + it('should call fulcrum adapter get method', async () => { + const address = 'bitcoincash:qrdka2205f4hyukutc2g0s6lykperc8nsu5u2ddpqf' + mockAdapters.fulcrum.get.resolves({ balance: 1000 }) + + const result = await uut.getBalance({ address }) + + assert.isTrue(mockAdapters.fulcrum.get.calledOnceWith(`electrumx/balance/${address}`)) + assert.deepEqual(result, { balance: 1000 }) + }) + }) + + describe('#getBalances()', () => { + it('should call fulcrum adapter post method', async () => { + const addresses = ['bitcoincash:qrdka2205f4hyukutc2g0s6lykperc8nsu5u2ddpqf'] + mockAdapters.fulcrum.post.resolves({ balances: [] }) + + const result = await uut.getBalances({ addresses }) + + assert.isTrue( + mockAdapters.fulcrum.post.calledOnceWith('electrumx/balance/', { addresses }) + ) + assert.deepEqual(result, { balances: [] }) + }) + }) + + describe('#getUtxos()', () => { + it('should call fulcrum adapter get method', async () => { + const address = 'bitcoincash:qrdka2205f4hyukutc2g0s6lykperc8nsu5u2ddpqf' + mockAdapters.fulcrum.get.resolves({ utxos: [] }) + + const result = await uut.getUtxos({ address }) + + assert.isTrue(mockAdapters.fulcrum.get.calledOnceWith(`electrumx/utxos/${address}`)) + assert.deepEqual(result, { utxos: [] }) + }) + }) + + describe('#getUtxosBulk()', () => { + it('should call fulcrum adapter post method', async () => { + const addresses = ['bitcoincash:qrdka2205f4hyukutc2g0s6lykperc8nsu5u2ddpqf'] + mockAdapters.fulcrum.post.resolves({ utxos: [] }) + + const result = await uut.getUtxosBulk({ addresses }) + + assert.isTrue( + mockAdapters.fulcrum.post.calledOnceWith('electrumx/utxos/', { addresses }) + ) + assert.deepEqual(result, { utxos: [] }) + }) + }) + + describe('#getTransactionDetails()', () => { + it('should call fulcrum adapter get method', async () => { + const txid = 'a'.repeat(64) + mockAdapters.fulcrum.get.resolves({ txid }) + + const result = await uut.getTransactionDetails({ txid }) + + assert.isTrue(mockAdapters.fulcrum.get.calledOnceWith(`electrumx/tx/data/${txid}`)) + assert.deepEqual(result, { txid }) + }) + }) + + describe('#getTransactionDetailsBulk()', () => { + it('should call fulcrum adapter post method with verbose', async () => { + const txids = ['a'.repeat(64)] + const verbose = true + mockAdapters.fulcrum.post.resolves({ transactions: [] }) + + const result = await uut.getTransactionDetailsBulk({ txids, verbose }) + + assert.isTrue( + mockAdapters.fulcrum.post.calledOnceWith('electrumx/tx/data', { txids, verbose }) + ) + assert.deepEqual(result, { transactions: [] }) + }) + }) + + describe('#broadcastTransaction()', () => { + it('should call fulcrum adapter post method', async () => { + const txHex = '010203' + mockAdapters.fulcrum.post.resolves({ txid: 'abc' }) + + const result = await uut.broadcastTransaction({ txHex }) + + assert.isTrue( + mockAdapters.fulcrum.post.calledOnceWith('electrumx/tx/broadcast', { txHex }) + ) + assert.deepEqual(result, { txid: 'abc' }) + }) + }) + + describe('#getBlockHeaders()', () => { + it('should call fulcrum adapter get method with height and count', async () => { + const height = 100 + const count = 2 + mockAdapters.fulcrum.get.resolves({ headers: [] }) + + const result = await uut.getBlockHeaders({ height, count }) + + assert.isTrue( + mockAdapters.fulcrum.get.calledOnceWith(`electrumx/block/headers/${height}?count=${count}`) + ) + assert.deepEqual(result, { headers: [] }) + }) + }) + + describe('#getBlockHeadersBulk()', () => { + it('should call fulcrum adapter post method', async () => { + const heights = [{ height: 100, count: 2 }] + mockAdapters.fulcrum.post.resolves({ headers: [] }) + + const result = await uut.getBlockHeadersBulk({ heights }) + + assert.isTrue( + mockAdapters.fulcrum.post.calledOnceWith('electrumx/block/headers', { heights }) + ) + assert.deepEqual(result, { headers: [] }) + }) + }) + + describe('#getTransactions()', () => { + it('should call fulcrum adapter and sort transactions when allTxs is false', async () => { + const address = 'bitcoincash:qrdka2205f4hyukutc2g0s6lykperc8nsu5u2ddpqf' + const allTxs = false + const mockTransactions = [ + { tx_hash: 'aaa', height: 100 }, + { tx_hash: 'bbb', height: 200 }, + { tx_hash: 'ccc', height: 150 } + ] + mockAdapters.fulcrum.get.resolves({ + transactions: mockTransactions + }) + + const result = await uut.getTransactions({ address, allTxs }) + + assert.isTrue(mockAdapters.fulcrum.get.calledOnceWith(`electrumx/transactions/${address}`)) + assert.property(result, 'transactions') + // Transactions should be sorted and limited to 100 + if (result.transactions && result.transactions.length > 100) { + assert.isAtMost(result.transactions.length, 100) + } + }) + + it('should return all transactions when allTxs is true', async () => { + const address = 'bitcoincash:qrdka2205f4hyukutc2g0s6lykperc8nsu5u2ddpqf' + const allTxs = true + const mockTransactions = Array(150).fill({ tx_hash: 'aaa', height: 100 }) + mockAdapters.fulcrum.get.resolves({ + transactions: mockTransactions + }) + + const result = await uut.getTransactions({ address, allTxs }) + + assert.property(result, 'transactions') + // All transactions should be returned when allTxs is true + assert.equal(result.transactions.length, 150) + }) + }) + + describe('#getTransactionsBulk()', () => { + it('should call fulcrum adapter and sort transactions for each address', async () => { + const addresses = ['bitcoincash:qrdka2205f4hyukutc2g0s6lykperc8nsu5u2ddpqf'] + const allTxs = false + const mockResponse = { + transactions: [ + { + transactions: [ + { tx_hash: 'aaa', height: 100 }, + { tx_hash: 'bbb', height: 200 } + ] + } + ] + } + mockAdapters.fulcrum.post.resolves(mockResponse) + + const result = await uut.getTransactionsBulk({ addresses, allTxs }) + + assert.isTrue( + mockAdapters.fulcrum.post.calledOnceWith('electrumx/transactions/', { addresses }) + ) + assert.property(result, 'transactions') + }) + + it('should limit to 100 transactions when allTxs is false', async () => { + const addresses = ['bitcoincash:qrdka2205f4hyukutc2g0s6lykperc8nsu5u2ddpqf'] + const allTxs = false + const mockTransactions = Array(150).fill({ tx_hash: 'aaa', height: 100 }) + const mockResponse = { + transactions: [ + { + transactions: mockTransactions + } + ] + } + mockAdapters.fulcrum.post.resolves(mockResponse) + + const result = await uut.getTransactionsBulk({ addresses, allTxs }) + + assert.isAtMost(result.transactions[0].transactions.length, 100) + }) + }) + + describe('#getMempool()', () => { + it('should call fulcrum adapter get method', async () => { + const address = 'bitcoincash:qrdka2205f4hyukutc2g0s6lykperc8nsu5u2ddpqf' + mockAdapters.fulcrum.get.resolves({ mempool: [] }) + + const result = await uut.getMempool({ address }) + + assert.isTrue(mockAdapters.fulcrum.get.calledOnceWith(`electrumx/unconfirmed/${address}`)) + assert.deepEqual(result, { mempool: [] }) + }) + }) + + describe('#getMempoolBulk()', () => { + it('should call fulcrum adapter post method', async () => { + const addresses = ['bitcoincash:qrdka2205f4hyukutc2g0s6lykperc8nsu5u2ddpqf'] + mockAdapters.fulcrum.post.resolves({ mempool: [] }) + + const result = await uut.getMempoolBulk({ addresses }) + + assert.isTrue( + mockAdapters.fulcrum.post.calledOnceWith('electrumx/unconfirmed/', { addresses }) + ) + assert.deepEqual(result, { mempool: [] }) + }) + }) +})