feat(blockchain): Adding blockchain full-node endpoints

This commit is contained in:
Chris Troutner
2025-11-09 19:18:39 -08:00
parent ba2afd47a4
commit 3fff9000bf
12 changed files with 1024 additions and 11 deletions
+4
View File
@@ -0,0 +1,4 @@
# Full Node Connection
RPC_BASEURL=http://172.17.0.1:8332
RPC_USERNAME=bitcoin
RPC_PASSWORD=password
+5 -5
View File
@@ -1,9 +1,9 @@
{
"name": "REST2NOSTR Proxy API",
"name": "psf-bch-api REST API",
"version": "1.0.0",
"description": "REST API proxy for Nostr WebSocket protocol",
"title": "REST2NOSTR Proxy API",
"url": "https://nostr-relay-api.psfoundation.info",
"sampleUrl": "https://nostr-relay-api.psfoundation.info"
"description": "REST API proxy to Bitcoin Cash infrastructure",
"title": "psf-bch-api REST API",
"url": "http://localhost:5942",
"sampleUrl": "http://localhost:5942"
}
+102 -2
View File
@@ -9,6 +9,7 @@
"version": "1.0.0",
"license": "MIT",
"dependencies": {
"axios": "1.7.7",
"cors": "2.8.5",
"dotenv": "16.3.1",
"express": "5.1.0",
@@ -1469,6 +1470,12 @@
"node": ">= 0.4"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/available-typed-arrays": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
@@ -1485,6 +1492,17 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/axios": {
"version": "1.7.7",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.7.tgz",
"integrity": "sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -1938,6 +1956,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/commander": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz",
@@ -2156,6 +2186,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -2458,7 +2497,6 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -3352,6 +3390,26 @@
"integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==",
"license": "MIT"
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/for-each": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
@@ -3385,6 +3443,43 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/form-data": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/form-data/node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/form-data/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -3759,7 +3854,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
@@ -5849,6 +5943,12 @@
"node": ">= 0.10"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/pstree.remy": {
"version": "1.1.8",
"resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz",
+1
View File
@@ -15,6 +15,7 @@
"license": "MIT",
"description": "REST API proxy to Bitcoin Cash infrastructure",
"dependencies": {
"axios": "1.7.7",
"cors": "2.8.5",
"dotenv": "16.3.1",
"express": "5.1.0",
+134
View File
@@ -0,0 +1,134 @@
/*
Adapter library for interacting with a BCH full node over JSON-RPC.
*/
import axios from 'axios'
import wlogger from './wlogger.js'
import config from '../config/index.js'
class FullNodeRPCAdapter {
constructor (localConfig = {}) {
this.config = localConfig.config || config
if (!this.config.fullNode || !this.config.fullNode.rpcBaseUrl) {
throw new Error('Full node RPC configuration is required')
}
const {
rpcBaseUrl,
rpcUsername,
rpcPassword,
rpcTimeoutMs = 15000
} = this.config.fullNode
console.log('this.config.fullNode', this.config.fullNode)
this.requestIdPrefix = this.config.fullNode.rpcRequestIdPrefix || 'psf-bch-api'
this.http = axios.create({
baseURL: rpcBaseUrl,
timeout: rpcTimeoutMs,
auth: {
username: rpcUsername,
password: rpcPassword
}
})
this.defaultRequestPayload = {
jsonrpc: '1.0'
}
}
async call (method, params = [], requestId) {
const id = requestId || `${this.requestIdPrefix}-${method}`
try {
const response = await this.http.post('', {
...this.defaultRequestPayload,
id,
method,
params
})
if (response.data && response.data.error) {
const rpcError = this._formatError(response.data.error.message, 400)
throw rpcError
}
return response.data.result
} 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 {
if (
err.response &&
err.response.data &&
err.response.data.error &&
err.response.data.error.message
) {
return this._formatError(err.response.data.error.message, 400)
}
if (err.response && err.response.data) {
return this._formatError(err.response.data, err.response.status || 500)
}
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 full node or other external service.',
503
)
}
}
if (err.code && (err.code === 'ECONNABORTED' || err.code === 'ECONNREFUSED')) {
return this._formatError(
'Network error: Could not communicate with full node or other external 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 full node error', 500)
} catch (decodeError) {
wlogger.error('Unhandled error in FullNodeRPCAdapter.decodeError()', decodeError)
return this._formatError('Internal server error', 500)
}
}
validateArraySize (length, options = {}) {
const { isProUser = false } = options
const freemiumLimit = Number(this.config.fullNode?.freemiumArrayLimit || 20)
const proLimit = Number(this.config.fullNode?.proArrayLimit || freemiumLimit)
const limit = isProUser ? proLimit : freemiumLimit
return length <= limit
}
_formatError (message, status = 500) {
return {
message: message || 'Internal server error',
status: status || 500
}
}
}
export default FullNodeRPCAdapter
+3
View File
@@ -6,6 +6,7 @@
// Load individual adapter libraries.
// import NostrRelayAdapter from './nostr-relay.js'
import FullNodeRPCAdapter from './full-node-rpc.js'
import config from '../config/index.js'
class Adapters {
@@ -30,6 +31,8 @@ class Adapters {
// Maintain backward compatibility: expose first relay as nostrRelay
// This allows existing code to work during transition
// this.nostrRelay = this.nostrRelays[0]
this.fullNode = new FullNodeRPCAdapter({ config: this.config })
}
async start () {
+12
View File
@@ -3,10 +3,13 @@
environments.
*/
import dotenv from 'dotenv'
// Hack to get __dirname back.
// https://blog.logrocket.com/alternatives-dirname-node-js-es-modules/
import * as url from 'url'
import { readFileSync } from 'fs'
dotenv.config()
const __dirname = url.fileURLToPath(new URL('.', import.meta.url))
const pkgInfo = JSON.parse(readFileSync(`${__dirname.toString()}/../../../package.json`))
@@ -47,6 +50,15 @@ export default {
return ['wss://nostr-relay.psfoundation.info', 'wss://relay.damus.io']
})(),
// Full node RPC configuration
fullNode: {
rpcBaseUrl: process.env.RPC_BASEURL || 'http://127.0.0.1:8332',
rpcUsername: process.env.RPC_USERNAME || '',
rpcPassword: process.env.RPC_PASSWORD || '',
rpcTimeoutMs: Number(process.env.RPC_TIMEOUT_MS || 15000),
rpcRequestIdPrefix: process.env.RPC_REQUEST_ID_PREFIX || 'psf-bch-api'
},
// Version
version
}
@@ -0,0 +1,553 @@
/*
REST API Controller for the /full-node/blockchain routes.
*/
import wlogger from '../../../../adapters/wlogger.js'
class BlockchainRESTController {
constructor (localConfig = {}) {
this.adapters = localConfig.adapters
if (!this.adapters) {
throw new Error(
'Instance of Adapters library required when instantiating Blockchain REST Controller.'
)
}
this.useCases = localConfig.useCases
if (!this.useCases || !this.useCases.blockchain) {
throw new Error(
'Instance of Blockchain use cases required when instantiating Blockchain REST Controller.'
)
}
this.blockchainUseCases = this.useCases.blockchain
// Bind functions
this.root = this.root.bind(this)
this.getBestBlockHash = this.getBestBlockHash.bind(this)
this.getBlockchainInfo = this.getBlockchainInfo.bind(this)
this.getBlockCount = this.getBlockCount.bind(this)
this.getBlockHeaderSingle = this.getBlockHeaderSingle.bind(this)
this.getBlockHeaderBulk = this.getBlockHeaderBulk.bind(this)
this.getChainTips = this.getChainTips.bind(this)
this.getDifficulty = this.getDifficulty.bind(this)
this.getMempoolEntrySingle = this.getMempoolEntrySingle.bind(this)
this.getMempoolEntryBulk = this.getMempoolEntryBulk.bind(this)
this.getMempoolAncestorsSingle = this.getMempoolAncestorsSingle.bind(this)
this.getMempoolInfo = this.getMempoolInfo.bind(this)
this.getRawMempool = this.getRawMempool.bind(this)
this.getTxOut = this.getTxOut.bind(this)
this.getTxOutPost = this.getTxOutPost.bind(this)
this.getTxOutProofSingle = this.getTxOutProofSingle.bind(this)
this.getTxOutProofBulk = this.getTxOutProofBulk.bind(this)
this.verifyTxOutProofSingle = this.verifyTxOutProofSingle.bind(this)
this.verifyTxOutProofBulk = this.verifyTxOutProofBulk.bind(this)
this.getBlock = this.getBlock.bind(this)
this.getBlockHash = this.getBlockHash.bind(this)
this.handleError = this.handleError.bind(this)
}
/**
* @api {get} /full-node/blockchain/ Service status
* @apiName BlockchainRoot
* @apiGroup Blockchain
*
* @apiDescription Returns the status of the blockchain service.
*
* @apiSuccess {String} status Service identifier
*/
async root (req, res) {
return res.status(200).json({ status: 'blockchain' })
}
/**
* @api {get} /full-node/blockchain/getBestBlockHash Get best block hash
* @apiName GetBestBlockHash
* @apiGroup Blockchain
* @apiDescription Returns the hash of the best (tip) block in the longest block chain.
*
* @apiExample Example usage:
* curl -X GET "https://api.fullstack.cash/v5/blockchain/getBestBlockHash" -H "accept: application/json"
*
* @apiSuccess {String} bestBlockHash Hash of the best block
*/
async getBestBlockHash (req, res) {
try {
const result = await this.blockchainUseCases.getBestBlockHash()
return res.status(200).json(result)
} catch (err) {
return this.handleError(err, res)
}
}
/**
* @api {get} /full-node/blockchain/getBlockchainInfo Get blockchain info
* @apiName GetBlockchainInfo
* @apiGroup Blockchain
* @apiDescription Returns various state info regarding blockchain processing.
*/
async getBlockchainInfo (req, res) {
try {
const result = await this.blockchainUseCases.getBlockchainInfo()
return res.status(200).json(result)
} catch (err) {
return this.handleError(err, res)
}
}
/**
* @api {get} /full-node/blockchain/getBlockCount Get block count
* @apiName GetBlockCount
* @apiGroup Blockchain
* @apiDescription Returns the number of blocks in the longest blockchain.
*/
async getBlockCount (req, res) {
try {
const result = await this.blockchainUseCases.getBlockCount()
return res.status(200).json(result)
} catch (err) {
return this.handleError(err, res)
}
}
/**
* @api {get} /full-node/blockchain/getBlockHeader/:hash Get single block header
* @apiName GetSingleBlockHeader
* @apiGroup Blockchain
* @apiDescription Returns serialized block header data.
*
* @apiParam {String} hash Block hash
* @apiParam {Boolean} verbose Return verbose data (default false)
*/
async getBlockHeaderSingle (req, res) {
try {
const hash = req.params.hash
if (!hash) {
return res.status(400).json({ error: 'hash can not be empty' })
}
const verbose = req.query.verbose?.toString() === 'true'
const result = await this.blockchainUseCases.getBlockHeader({ hash, verbose })
return res.status(200).json(result)
} catch (err) {
return this.handleError(err, res)
}
}
/**
* @api {post} /full-node/blockchain/getBlockHeader Get multiple block headers
* @apiName GetBulkBlockHeader
* @apiGroup Blockchain
* @apiDescription Returns serialized block header data for multiple hashes.
*
* @apiParam {String[]} hashes Block hashes
* @apiParam {Boolean} verbose Return verbose data (default false)
*/
async getBlockHeaderBulk (req, res) {
try {
const hashes = req.body.hashes
const verbose = !!req.body.verbose
if (!Array.isArray(hashes)) {
return res.status(400).json({
error: 'hashes needs to be an array. Use GET for single hash.'
})
}
if (!this.adapters.fullNode.validateArraySize(hashes.length, { isProUser: Boolean(req.locals?.proLimit) })) {
return res.status(400).json({ error: 'Array too large.' })
}
for (const hash of hashes) {
if (!hash || hash.length !== 64) {
return res.status(400).json({ error: `This is not a hash: ${hash}` })
}
}
const result = await this.blockchainUseCases.getBlockHeaders({ hashes, verbose })
return res.status(200).json(result)
} catch (err) {
return this.handleError(err, res)
}
}
/**
* @api {get} /full-node/blockchain/getChainTips Get chain tips
* @apiName GetChainTips
* @apiGroup Blockchain
* @apiDescription Returns information about known tips in the block tree.
*/
async getChainTips (req, res) {
try {
const result = await this.blockchainUseCases.getChainTips()
return res.status(200).json(result)
} catch (err) {
return this.handleError(err, res)
}
}
/**
* @api {get} /full-node/blockchain/getDifficulty Get difficulty
* @apiName GetDifficulty
* @apiGroup Blockchain
* @apiDescription Returns the current difficulty value.
*/
async getDifficulty (req, res) {
try {
const result = await this.blockchainUseCases.getDifficulty()
return res.status(200).json(result)
} catch (err) {
return this.handleError(err, res)
}
}
/**
* @api {get} /full-node/blockchain/getMempoolEntry/:txid Get single mempool entry
* @apiName GetMempoolEntry
* @apiGroup Blockchain
* @apiDescription Returns mempool data for a transaction.
*/
async getMempoolEntrySingle (req, res) {
try {
const txid = req.params.txid
if (!txid) {
return res.status(400).json({ error: 'txid can not be empty' })
}
const result = await this.blockchainUseCases.getMempoolEntry({ txid })
return res.status(200).json(result)
} catch (err) {
return this.handleError(err, res)
}
}
/**
* @api {post} /full-node/blockchain/getMempoolEntry Get bulk mempool entry
* @apiName GetMempoolEntryBulk
* @apiGroup Blockchain
* @apiDescription Returns mempool data for multiple transactions.
*/
async getMempoolEntryBulk (req, res) {
try {
const txids = req.body.txids
if (!Array.isArray(txids)) {
return res.status(400).json({
error: 'txids needs to be an array. Use GET for single txid.'
})
}
if (!this.adapters.fullNode.validateArraySize(txids.length, { isProUser: Boolean(req.locals?.proLimit) })) {
return res.status(400).json({ error: 'Array too large.' })
}
for (const txid of txids) {
if (!txid || txid.length !== 64) {
return res.status(400).json({ error: 'This is not a txid' })
}
}
const result = await this.blockchainUseCases.getMempoolEntries({ txids })
return res.status(200).json(result)
} catch (err) {
return this.handleError(err, res)
}
}
/**
* @api {get} /full-node/blockchain/getMempoolAncestors/:txid Get mempool ancestors
* @apiName GetMempoolAncestors
* @apiGroup Blockchain
* @apiDescription Returns mempool ancestor data for a transaction.
*/
async getMempoolAncestorsSingle (req, res) {
try {
const txid = req.params.txid
if (!txid) {
return res.status(400).json({ error: 'txid can not be empty' })
}
let verbose = false
if (req.query.verbose && req.query.verbose.toString() === 'true') {
verbose = true
}
const result = await this.blockchainUseCases.getMempoolAncestors({ txid, verbose })
return res.status(200).json(result)
} catch (err) {
return this.handleError(err, res)
}
}
/**
* @api {get} /full-node/blockchain/getMempoolInfo Get mempool info
* @apiName GetMempoolInfo
* @apiGroup Blockchain
* @apiDescription Returns details on the state of the mempool.
*/
async getMempoolInfo (req, res) {
try {
const result = await this.blockchainUseCases.getMempoolInfo()
return res.status(200).json(result)
} catch (err) {
return this.handleError(err, res)
}
}
/**
* @api {get} /full-node/blockchain/getRawMempool Get raw mempool
* @apiName GetRawMempool
* @apiGroup Blockchain
* @apiDescription Returns all transaction ids in the mempool.
*
* @apiParam {Boolean} verbose Return verbose data (default false)
*/
async getRawMempool (req, res) {
try {
const verbose = req.query.verbose === 'true'
const result = await this.blockchainUseCases.getRawMempool({ verbose })
return res.status(200).json(result)
} catch (err) {
return this.handleError(err, res)
}
}
/**
* @api {get} /full-node/blockchain/getTxOut/:txid/:n Get transaction output
* @apiName GetTxOut
* @apiGroup Blockchain
* @apiDescription Returns details about an unspent transaction output.
*/
async getTxOut (req, res) {
try {
const txid = req.params.txid
if (!txid) {
return res.status(400).json({ error: 'txid can not be empty' })
}
const nRaw = req.params.n
if (nRaw === undefined || nRaw === '') {
return res.status(400).json({ error: 'n can not be empty' })
}
const n = parseInt(nRaw)
const includeMempool = req.query.includeMempool === 'true'
const result = await this.blockchainUseCases.getTxOut({
txid,
n,
includeMempool
})
return res.status(200).json(result)
} catch (err) {
return this.handleError(err, res)
}
}
/**
* @api {post} /full-node/blockchain/getTxOut Validate a UTXO
* @apiName GetTxOutPost
* @apiGroup Blockchain
* @apiDescription Returns details about an unspent transaction output.
*/
async getTxOutPost (req, res) {
try {
const txid = req.body.txid
if (!txid) {
return res.status(400).json({ error: 'txid can not be empty' })
}
const voutRaw = req.body.vout
if (voutRaw === undefined || voutRaw === '') {
return res.status(400).json({ error: 'vout can not be empty' })
}
const n = parseInt(voutRaw)
const mempool = req.body.mempool !== undefined ? !!req.body.mempool : true
const result = await this.blockchainUseCases.getTxOut({
txid,
n,
includeMempool: mempool
})
return res.status(200).json(result)
} catch (err) {
return this.handleError(err, res)
}
}
/**
* @api {get} /full-node/blockchain/getTxOutProof/:txid Get TxOut proof
* @apiName GetTxOutProofSingle
* @apiGroup Blockchain
* @apiDescription Returns a hex-encoded proof that the transaction was included in a block.
*/
async getTxOutProofSingle (req, res) {
try {
const txid = req.params.txid
if (!txid) {
return res.status(400).json({ error: 'txid can not be empty' })
}
const result = await this.blockchainUseCases.getTxOutProof({ txid })
return res.status(200).json(result)
} catch (err) {
return this.handleError(err, res)
}
}
/**
* @api {post} /full-node/blockchain/getTxOutProof Get TxOut proofs
* @apiName GetTxOutProofBulk
* @apiGroup Blockchain
* @apiDescription Returns hex-encoded proofs for transactions.
*/
async getTxOutProofBulk (req, res) {
try {
const txids = req.body.txids
if (!Array.isArray(txids)) {
return res.status(400).json({
error: 'txids needs to be an array. Use GET for single txid.'
})
}
if (!this.adapters.fullNode.validateArraySize(txids.length, { isProUser: Boolean(req.locals?.proLimit) })) {
return res.status(400).json({ error: 'Array too large.' })
}
for (const txid of txids) {
if (!txid || txid.length !== 64) {
return res.status(400).json({
error: `Invalid txid. Double check your txid is valid: ${txid}`
})
}
}
const result = await this.blockchainUseCases.getTxOutProofs({ txids })
return res.status(200).json(result)
} catch (err) {
return this.handleError(err, res)
}
}
/**
* @api {get} /full-node/blockchain/verifyTxOutProof/:proof Verify TxOut proof
* @apiName VerifyTxOutProofSingle
* @apiGroup Blockchain
* @apiDescription Verifies a hex-encoded proof was included in a block.
*/
async verifyTxOutProofSingle (req, res) {
try {
const proof = req.params.proof
if (!proof) {
return res.status(400).json({ error: 'proof can not be empty' })
}
const result = await this.blockchainUseCases.verifyTxOutProof({ proof })
return res.status(200).json(result)
} catch (err) {
return this.handleError(err, res)
}
}
/**
* @api {post} /full-node/blockchain/verifyTxOutProof Verify TxOut proofs
* @apiName VerifyTxOutProofBulk
* @apiGroup Blockchain
* @apiDescription Verifies hex-encoded proofs were included in blocks.
*/
async verifyTxOutProofBulk (req, res) {
try {
const proofs = req.body.proofs
if (!Array.isArray(proofs)) {
return res.status(400).json({
error: 'proofs needs to be an array. Use GET for single proof.'
})
}
if (!this.adapters.fullNode.validateArraySize(proofs.length, { isProUser: Boolean(req.locals?.proLimit) })) {
return res.status(400).json({ error: 'Array too large.' })
}
for (const proof of proofs) {
if (!proof) {
return res.status(400).json({ error: `proof can not be empty: ${proof}` })
}
}
const result = await this.blockchainUseCases.verifyTxOutProofs({ proofs })
const flattened = result.map(entry => Array.isArray(entry) ? entry[0] : entry)
return res.status(200).json(flattened)
} catch (err) {
return this.handleError(err, res)
}
}
/**
* @api {post} /full-node/blockchain/getBlock Get block details
* @apiName GetBlock
* @apiGroup Blockchain
* @apiDescription Returns block details for a hash.
*/
async getBlock (req, res) {
try {
const blockhash = req.body.blockhash
if (!blockhash) {
return res.status(400).json({ error: 'blockhash can not be empty' })
}
let verbosity = req.body.verbosity
if (verbosity === undefined || verbosity === null) {
verbosity = 1
}
const result = await this.blockchainUseCases.getBlock({
blockhash,
verbosity
})
return res.status(200).json(result)
} catch (err) {
return this.handleError(err, res)
}
}
/**
* @api {get} /full-node/blockchain/getBlockHash/:height Get block hash
* @apiName GetBlockHash
* @apiGroup Blockchain
* @apiDescription Returns the hash of a block by height.
*/
async getBlockHash (req, res) {
try {
const heightRaw = req.params.height
if (!heightRaw) {
return res.status(400).json({ error: 'height can not be empty' })
}
const height = parseInt(heightRaw)
const result = await this.blockchainUseCases.getBlockHash({ height })
return res.status(200).json(result)
} catch (err) {
return this.handleError(err, res)
}
}
handleError (err, res) {
wlogger.error('Error in BlockchainRESTController:', err)
const status = err.status || 500
const message = err.message || 'Internal server error'
return res.status(status).json({ error: message })
}
}
export default BlockchainRESTController
@@ -0,0 +1,66 @@
/*
REST API router for /full-node/blockchain routes.
*/
import express from 'express'
import BlockchainRESTController from './controller.js'
class BlockchainRouter {
constructor (localConfig = {}) {
this.adapters = localConfig.adapters
if (!this.adapters) {
throw new Error(
'Instance of Adapters library required when instantiating Blockchain REST Router.'
)
}
this.useCases = localConfig.useCases
if (!this.useCases) {
throw new Error(
'Instance of Use Cases library required when instantiating Blockchain REST Router.'
)
}
const dependencies = {
adapters: this.adapters,
useCases: this.useCases
}
this.blockchainController = new BlockchainRESTController(dependencies)
this.baseUrl = '/full-node/blockchain'
this.router = express.Router()
}
attach (app) {
if (!app) {
throw new Error('Must pass app object when attaching REST API controllers.')
}
this.router.get('/', this.blockchainController.root)
this.router.get('/getBestBlockHash', this.blockchainController.getBestBlockHash)
this.router.get('/getBlockchainInfo', this.blockchainController.getBlockchainInfo)
this.router.get('/getBlockCount', this.blockchainController.getBlockCount)
this.router.get('/getBlockHeader/:hash', this.blockchainController.getBlockHeaderSingle)
this.router.post('/getBlockHeader', this.blockchainController.getBlockHeaderBulk)
this.router.get('/getChainTips', this.blockchainController.getChainTips)
this.router.get('/getDifficulty', this.blockchainController.getDifficulty)
this.router.get('/getMempoolEntry/:txid', this.blockchainController.getMempoolEntrySingle)
this.router.post('/getMempoolEntry', this.blockchainController.getMempoolEntryBulk)
this.router.get('/getMempoolAncestors/:txid', this.blockchainController.getMempoolAncestorsSingle)
this.router.get('/getMempoolInfo', this.blockchainController.getMempoolInfo)
this.router.get('/getRawMempool', this.blockchainController.getRawMempool)
this.router.get('/getTxOut/:txid/:n', this.blockchainController.getTxOut)
this.router.post('/getTxOut', this.blockchainController.getTxOutPost)
this.router.get('/getTxOutProof/:txid', this.blockchainController.getTxOutProofSingle)
this.router.post('/getTxOutProof', this.blockchainController.getTxOutProofBulk)
this.router.get('/verifyTxOutProof/:proof', this.blockchainController.verifyTxOutProofSingle)
this.router.post('/verifyTxOutProof', this.blockchainController.verifyTxOutProofBulk)
this.router.post('/getBlock', this.blockchainController.getBlock)
this.router.get('/getBlockHash/:height', this.blockchainController.getBlockHash)
app.use(this.baseUrl, this.router)
}
}
export default BlockchainRouter
+8 -4
View File
@@ -7,6 +7,7 @@
// Local libraries
// import EventRouter from './event/index.js'
// import ReqRouter from './req/index.js'
import BlockchainRouter from './full-node/blockchain/index.js'
import config from '../../config/index.js'
class RESTControllers {
@@ -33,10 +34,10 @@ class RESTControllers {
}
attachRESTControllers (app) {
// const dependencies = {
// adapters: this.adapters,
// useCases: this.useCases
// }
const dependencies = {
adapters: this.adapters,
useCases: this.useCases
}
// Attach the REST API Controllers associated with the /event route
// const eventRouter = new EventRouter(dependencies)
@@ -45,6 +46,9 @@ class RESTControllers {
// Attach the REST API Controllers associated with the /req route
// const reqRouter = new ReqRouter(dependencies)
// reqRouter.attach(app)
const blockchainRouter = new BlockchainRouter(dependencies)
blockchainRouter.attach(app)
}
}
+134
View File
@@ -0,0 +1,134 @@
/*
Use cases for interacting with the BCH full node blockchain RPC interface.
*/
import wlogger from '../../adapters/wlogger.js'
class BlockchainUseCases {
constructor (localConfig = {}) {
this.adapters = localConfig.adapters
if (!this.adapters) {
throw new Error('Adapters instance required when instantiating Blockchain use cases.')
}
this.fullNode = this.adapters.fullNode
if (!this.fullNode) {
throw new Error('Full node adapter required when instantiating Blockchain use cases.')
}
}
async getBestBlockHash () {
return this.fullNode.call('getbestblockhash')
}
async getBlockchainInfo () {
return this.fullNode.call('getblockchaininfo')
}
async getBlockCount () {
return this.fullNode.call('getblockcount')
}
async getBlockHeader ({ hash, verbose = false }) {
return this.fullNode.call('getblockheader', [hash, verbose])
}
async getBlockHeaders ({ hashes, verbose = false }) {
try {
const promises = hashes.map(hash =>
this.fullNode.call('getblockheader', [hash, verbose], `getblockheader-${hash}`)
)
return await Promise.all(promises)
} catch (err) {
wlogger.error('Error in BlockchainUseCases.getBlockHeaders()', err)
throw err
}
}
async getChainTips () {
return this.fullNode.call('getchaintips')
}
async getDifficulty () {
return this.fullNode.call('getdifficulty')
}
async getMempoolEntry ({ txid }) {
return this.fullNode.call('getmempoolentry', [txid])
}
async getMempoolEntries ({ txids }) {
try {
const promises = txids.map(txid =>
this.fullNode.call('getmempoolentry', [txid], `getmempoolentry-${txid}`)
)
return await Promise.all(promises)
} catch (err) {
wlogger.error('Error in BlockchainUseCases.getMempoolEntries()', err)
throw err
}
}
async getMempoolAncestors ({ txid, verbose = false }) {
return this.fullNode.call('getmempoolancestors', [txid, verbose])
}
async getMempoolInfo () {
return this.fullNode.call('getmempoolinfo')
}
async getRawMempool ({ verbose = false }) {
return this.fullNode.call('getrawmempool', [verbose])
}
async getTxOut ({ txid, n, includeMempool }) {
return this.fullNode.call('gettxout', [txid, n, includeMempool])
}
async getTxOutProof ({ txid }) {
return this.fullNode.call('gettxoutproof', [[txid]])
}
async getTxOutProofs ({ txids }) {
try {
const promises = txids.map(txid =>
this.fullNode.call('gettxoutproof', [[txid]], `gettxoutproof-${txid}`)
)
return await Promise.all(promises)
} catch (err) {
wlogger.error('Error in BlockchainUseCases.getTxOutProofs()', err)
throw err
}
}
async verifyTxOutProof ({ proof }) {
return this.fullNode.call('verifytxoutproof', [proof])
}
async verifyTxOutProofs ({ proofs }) {
try {
const promises = proofs.map(proof =>
this.fullNode.call('verifytxoutproof', [proof], `verifytxoutproof-${proof.slice(0, 16)}`)
)
return await Promise.all(promises)
} catch (err) {
wlogger.error('Error in BlockchainUseCases.verifyTxOutProofs()', err)
throw err
}
}
async getBlock ({ blockhash, verbosity }) {
return this.fullNode.call('getblock', [blockhash, verbosity])
}
async getBlockHash ({ height }) {
return this.fullNode.call('getblockhash', [height])
}
}
export default BlockchainUseCases
+2
View File
@@ -8,6 +8,7 @@
// import PublishEventUseCase from './publish-event.js'
// import QueryEventsUseCase from './query-events.js'
// import ManageSubscriptionUseCase from './manage-subscription.js'
import BlockchainUseCases from './blockchain/index.js'
class UseCases {
constructor (localConfig = {}) {
@@ -21,6 +22,7 @@ class UseCases {
// this.publishEvent = new PublishEventUseCase({ adapters: this.adapters })
// this.queryEvents = new QueryEventsUseCase({ adapters: this.adapters })
// this.manageSubscription = new ManageSubscriptionUseCase({ adapters: this.adapters })
this.blockchain = new BlockchainUseCases({ adapters: this.adapters })
}
// Run any startup Use Cases at the start of the app.