feat(fulcrum): Ported fulcrum endpoints from bch-api

This commit is contained in:
Chris Troutner
2025-11-14 07:25:23 -08:00
parent 2af4b0a943
commit ca25e33517
13 changed files with 1719 additions and 0 deletions
+1
View File
@@ -9,6 +9,7 @@
"version": "1.0.0", "version": "1.0.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@psf/bch-js": "6.8.3",
"axios": "1.7.7", "axios": "1.7.7",
"cors": "2.8.5", "cors": "2.8.5",
"dotenv": "16.3.1", "dotenv": "16.3.1",
+1
View File
@@ -15,6 +15,7 @@
"license": "MIT", "license": "MIT",
"description": "REST API proxy to Bitcoin Cash infrastructure", "description": "REST API proxy to Bitcoin Cash infrastructure",
"dependencies": { "dependencies": {
"@psf/bch-js": "6.8.3",
"axios": "1.7.7", "axios": "1.7.7",
"cors": "2.8.5", "cors": "2.8.5",
"dotenv": "16.3.1", "dotenv": "16.3.1",
+124
View File
@@ -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
+2
View File
@@ -7,6 +7,7 @@
// Load individual adapter libraries. // Load individual adapter libraries.
// import NostrRelayAdapter from './nostr-relay.js' // import NostrRelayAdapter from './nostr-relay.js'
import FullNodeRPCAdapter from './full-node-rpc.js' import FullNodeRPCAdapter from './full-node-rpc.js'
import FulcrumAPIAdapter from './fulcrum-api.js'
import config from '../config/index.js' import config from '../config/index.js'
class Adapters { class Adapters {
@@ -33,6 +34,7 @@ class Adapters {
// this.nostrRelay = this.nostrRelays[0] // this.nostrRelay = this.nostrRelays[0]
this.fullNode = new FullNodeRPCAdapter({ config: this.config }) this.fullNode = new FullNodeRPCAdapter({ config: this.config })
this.fulcrum = new FulcrumAPIAdapter({ config: this.config })
} }
async start () { async start () {
+6
View File
@@ -60,6 +60,12 @@ export default {
rpcRequestIdPrefix: process.env.RPC_REQUEST_ID_PREFIX || 'psf-bch-api' 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, x402: x402Defaults,
// Version // Version
@@ -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
@@ -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
+4
View File
@@ -10,6 +10,7 @@
import BlockchainRouter from './full-node/blockchain/router.js' import BlockchainRouter from './full-node/blockchain/router.js'
import ControlRouter from './full-node/control/router.js' import ControlRouter from './full-node/control/router.js'
import DSProofRouter from './full-node/dsproof/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 MiningRouter from './full-node/mining/router.js'
import RawTransactionsRouter from './full-node/rawtransactions/router.js' import RawTransactionsRouter from './full-node/rawtransactions/router.js'
import config from '../../config/index.js' import config from '../../config/index.js'
@@ -67,6 +68,9 @@ class RESTControllers {
const dsproofRouter = new DSProofRouter(dependencies) const dsproofRouter = new DSProofRouter(dependencies)
dsproofRouter.attach(app) dsproofRouter.attach(app)
const fulcrumRouter = new FulcrumRouter(dependencies)
fulcrumRouter.attach(app)
const miningRouter = new MiningRouter(dependencies) const miningRouter = new MiningRouter(dependencies)
miningRouter.attach(app) miningRouter.attach(app)
@@ -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
+2
View File
@@ -8,6 +8,7 @@
import BlockchainUseCases from './full-node-blockchain-use-cases.js' import BlockchainUseCases from './full-node-blockchain-use-cases.js'
import ControlUseCases from './full-node-control-use-cases.js' import ControlUseCases from './full-node-control-use-cases.js'
import DSProofUseCases from './full-node-dsproof-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 MiningUseCases from './full-node-mining-use-cases.js'
import RawTransactionsUseCases from './full-node-rawtransactions-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.blockchain = new BlockchainUseCases({ adapters: this.adapters })
this.control = new ControlUseCases({ adapters: this.adapters }) this.control = new ControlUseCases({ adapters: this.adapters })
this.dsproof = new DSProofUseCases({ 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.mining = new MiningUseCases({ adapters: this.adapters })
this.rawtransactions = new RawTransactionsUseCases({ adapters: this.adapters }) this.rawtransactions = new RawTransactionsUseCases({ adapters: this.adapters })
} }
@@ -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' })
})
})
})
@@ -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 BlockchainRouter from '../../../src/controllers/rest-api/full-node/blockchain/router.js'
import ControlRouter from '../../../src/controllers/rest-api/full-node/control/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 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 MiningRouter from '../../../src/controllers/rest-api/full-node/mining/router.js'
import RawTransactionsRouter from '../../../src/controllers/rest-api/full-node/rawtransactions/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: { dsproof: {
getDSProof: () => {} getDSProof: () => {}
}, },
fulcrum: {
getBalance: () => {},
getBalances: () => {},
getUtxos: () => {},
getUtxosBulk: () => {},
getTransactionDetails: () => {},
getTransactionDetailsBulk: () => {},
broadcastTransaction: () => {},
getBlockHeaders: () => {},
getBlockHeadersBulk: () => {},
getTransactions: () => {},
getTransactionsBulk: () => {},
getMempool: () => {},
getMempoolBulk: () => {}
},
mining: { mining: {
getMiningInfo: () => {}, getMiningInfo: () => {},
getNetworkHashPS: () => {} getNetworkHashPS: () => {}
@@ -97,6 +113,7 @@ describe('#controllers/rest-api/index.js', () => {
const blockchainAttachStub = sandbox.stub(BlockchainRouter.prototype, 'attach') const blockchainAttachStub = sandbox.stub(BlockchainRouter.prototype, 'attach')
const controlAttachStub = sandbox.stub(ControlRouter.prototype, 'attach') const controlAttachStub = sandbox.stub(ControlRouter.prototype, 'attach')
const dsproofAttachStub = sandbox.stub(DSProofRouter.prototype, 'attach') const dsproofAttachStub = sandbox.stub(DSProofRouter.prototype, 'attach')
const fulcrumAttachStub = sandbox.stub(FulcrumRouter.prototype, 'attach')
const miningAttachStub = sandbox.stub(MiningRouter.prototype, 'attach') const miningAttachStub = sandbox.stub(MiningRouter.prototype, 'attach')
const rawtransactionsAttachStub = sandbox.stub(RawTransactionsRouter.prototype, 'attach') const rawtransactionsAttachStub = sandbox.stub(RawTransactionsRouter.prototype, 'attach')
const restControllers = new RESTControllers({ const restControllers = new RESTControllers({
@@ -113,6 +130,8 @@ describe('#controllers/rest-api/index.js', () => {
assert.equal(controlAttachStub.getCall(0).args[0], app) assert.equal(controlAttachStub.getCall(0).args[0], app)
assert.isTrue(dsproofAttachStub.calledOnce) assert.isTrue(dsproofAttachStub.calledOnce)
assert.equal(dsproofAttachStub.getCall(0).args[0], app) 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.isTrue(miningAttachStub.calledOnce)
assert.equal(miningAttachStub.getCall(0).args[0], app) assert.equal(miningAttachStub.getCall(0).args[0], app)
assert.isTrue(rawtransactionsAttachStub.calledOnce) assert.isTrue(rawtransactionsAttachStub.calledOnce)
@@ -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: [] })
})
})
})