diff --git a/.env-local b/.env-local new file mode 100644 index 0000000..36974a9 --- /dev/null +++ b/.env-local @@ -0,0 +1,4 @@ +# Full Node Connection +RPC_BASEURL=http://172.17.0.1:8332 +RPC_USERNAME=bitcoin +RPC_PASSWORD=password diff --git a/apidoc.json b/apidoc.json index d2eba9d..b7c7631 100644 --- a/apidoc.json +++ b/apidoc.json @@ -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" } diff --git a/package-lock.json b/package-lock.json index e2ebd7f..ce90e62 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 709d1c8..cb67874 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/adapters/full-node-rpc.js b/src/adapters/full-node-rpc.js new file mode 100644 index 0000000..c355e85 --- /dev/null +++ b/src/adapters/full-node-rpc.js @@ -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 diff --git a/src/adapters/index.js b/src/adapters/index.js index 7ea8a21..ffabb9d 100644 --- a/src/adapters/index.js +++ b/src/adapters/index.js @@ -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 () { diff --git a/src/config/env/common.js b/src/config/env/common.js index 2b39799..07dd8a1 100644 --- a/src/config/env/common.js +++ b/src/config/env/common.js @@ -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 } diff --git a/src/controllers/rest-api/full-node/blockchain/controller.js b/src/controllers/rest-api/full-node/blockchain/controller.js new file mode 100644 index 0000000..f31c51e --- /dev/null +++ b/src/controllers/rest-api/full-node/blockchain/controller.js @@ -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 diff --git a/src/controllers/rest-api/full-node/blockchain/index.js b/src/controllers/rest-api/full-node/blockchain/index.js new file mode 100644 index 0000000..1490217 --- /dev/null +++ b/src/controllers/rest-api/full-node/blockchain/index.js @@ -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 diff --git a/src/controllers/rest-api/index.js b/src/controllers/rest-api/index.js index 37e1e21..efb6923 100644 --- a/src/controllers/rest-api/index.js +++ b/src/controllers/rest-api/index.js @@ -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) } } diff --git a/src/use-cases/blockchain/index.js b/src/use-cases/blockchain/index.js new file mode 100644 index 0000000..bb60818 --- /dev/null +++ b/src/use-cases/blockchain/index.js @@ -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 diff --git a/src/use-cases/index.js b/src/use-cases/index.js index 73d2e0f..ac1eeb8 100644 --- a/src/use-cases/index.js +++ b/src/use-cases/index.js @@ -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.