mirror of
https://github.com/fullstack-cash/bch-api.git
synced 2026-09-22 01:02:05 -07:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1655e843e | ||
|
|
af5d44c278 | ||
|
|
0d9caca8b0 | ||
|
|
0cdb9572f1 | ||
|
|
8507abd5db | ||
|
|
fae9a75f6a | ||
|
|
fc6da588ee | ||
|
|
6f8c00bab9 | ||
|
|
a2ab88adab | ||
|
|
60528dbd6e | ||
|
|
cebf1ea1d6 | ||
|
|
7acb778470 |
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"sampleUrl": null
|
||||
|
||||
}
|
||||
Generated
+25
-22
@@ -395,9 +395,9 @@
|
||||
}
|
||||
},
|
||||
"@psf/bch-js": {
|
||||
"version": "4.16.1",
|
||||
"resolved": "https://registry.npmjs.org/@psf/bch-js/-/bch-js-4.16.1.tgz",
|
||||
"integrity": "sha512-0rAOLGwxuzCrpe6dbz5nQ9KQTJIJNGzihSVSVfv0PXIeBKQKq+MuEG7u6rZXNeJlLWokosGwf6aysK2EbDGcqA==",
|
||||
"version": "4.17.6",
|
||||
"resolved": "https://registry.npmjs.org/@psf/bch-js/-/bch-js-4.17.6.tgz",
|
||||
"integrity": "sha512-dXR3S7wHZEivB/1LvvuBaAxn8FRRQpBrLtLcuL8c1J9v82xMO+qVr6AwvsOjFzHmcswuT1jlzQ3X1XPAaRgb5w==",
|
||||
"requires": {
|
||||
"@psf/bip21": "^2.0.1",
|
||||
"@psf/bip32-utils": "^1.0.0",
|
||||
@@ -931,23 +931,23 @@
|
||||
}
|
||||
},
|
||||
"@uppy/companion-client": {
|
||||
"version": "1.8.2",
|
||||
"resolved": "https://registry.npmjs.org/@uppy/companion-client/-/companion-client-1.8.2.tgz",
|
||||
"integrity": "sha512-FBjAJU3xaWRqYXDBlrMDQKlRBqi4Ng54Dmnoe3hGSggfgBQAl9RzRAg0WvrGjaK3QjCTZPhzSdTpBrqDP22Rng==",
|
||||
"version": "1.8.3",
|
||||
"resolved": "https://registry.npmjs.org/@uppy/companion-client/-/companion-client-1.8.3.tgz",
|
||||
"integrity": "sha512-IieE5DMmJC1rJrXHB2u1cO+r3BF7VqnezFAZj8gkNm7zBuplswKALlvwnXI+KU3TNs8isU55xK5yfF0ecglIEw==",
|
||||
"requires": {
|
||||
"@uppy/utils": "^3.4.1",
|
||||
"@uppy/utils": "^3.4.2",
|
||||
"namespace-emitter": "^2.0.1",
|
||||
"qs-stringify": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"@uppy/core": {
|
||||
"version": "1.16.1",
|
||||
"resolved": "https://registry.npmjs.org/@uppy/core/-/core-1.16.1.tgz",
|
||||
"integrity": "sha512-GI8baCa1S21j7KoCq0LK4MLD6sII8OvWVgp+auXNcZIbKa182dOMQRtk7bXVY0OzS1TxkIq0DAzDEUnP7MbnDg==",
|
||||
"version": "1.16.2",
|
||||
"resolved": "https://registry.npmjs.org/@uppy/core/-/core-1.16.2.tgz",
|
||||
"integrity": "sha512-JWK03gXiMMZ+lSVj+v9FIFEOUbKyvRX4v2kaYhf0NqD1MssCwMQe4mvL2MNzjOaYFBwMRacDJh8Mvz2gbu2Vqw==",
|
||||
"requires": {
|
||||
"@transloadit/prettier-bytes": "0.0.7",
|
||||
"@uppy/store-default": "^1.2.5",
|
||||
"@uppy/utils": "^3.4.1",
|
||||
"@uppy/utils": "^3.4.2",
|
||||
"cuid": "^2.1.1",
|
||||
"lodash.throttle": "^4.1.1",
|
||||
"mime-match": "^1.0.2",
|
||||
@@ -961,19 +961,19 @@
|
||||
"integrity": "sha512-jnf0U8cfb8Bhgt6yh86YRJO9EEnCyG9BgXZ8dPWWLybgC9Expw3Ah/s3T21tcdChgv4zzdhSACd0JKxCQowyYg=="
|
||||
},
|
||||
"@uppy/tus": {
|
||||
"version": "1.8.5",
|
||||
"resolved": "https://registry.npmjs.org/@uppy/tus/-/tus-1.8.5.tgz",
|
||||
"integrity": "sha512-a5z+bpAyKKKRWNSOYtIMd2EAKsM7BLhd6hQg2YrQNFFzdwbxaeqmrjKIVW1EUBvg1if/ZZePEm0QevJvNwQs0g==",
|
||||
"version": "1.8.6",
|
||||
"resolved": "https://registry.npmjs.org/@uppy/tus/-/tus-1.8.6.tgz",
|
||||
"integrity": "sha512-axK+TzRRvSkYZUIjfe6XCjfO5NPb/k8AC73p+MOKOzth1JHSZSJHqYi8+HY7T4aXgR86vWE/cE+wHsjvPhyCZw==",
|
||||
"requires": {
|
||||
"@uppy/companion-client": "^1.8.2",
|
||||
"@uppy/utils": "^3.4.1",
|
||||
"@uppy/companion-client": "^1.8.3",
|
||||
"@uppy/utils": "^3.4.2",
|
||||
"tus-js-client": "^2.1.1"
|
||||
}
|
||||
},
|
||||
"@uppy/utils": {
|
||||
"version": "3.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@uppy/utils/-/utils-3.4.1.tgz",
|
||||
"integrity": "sha512-9YVKjVRK/AmX3yV0GJsD5p/eHUzqn4+eXzsjv3wHahipKCo0o7Jwpw/7kl+bv12EHOOy5jPsATWkjn40aSWW8w==",
|
||||
"version": "3.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@uppy/utils/-/utils-3.4.2.tgz",
|
||||
"integrity": "sha512-PWLZgX4L+0tdBqImncuATDYb1M6RHgDfGDZCh2Qg+PTG+btyyZ4/GkOEwK5fJ4Gw1gNgWF0ewAFL28+oCrD7/Q==",
|
||||
"requires": {
|
||||
"abortcontroller-polyfill": "^1.4.0",
|
||||
"lodash.throttle": "^4.1.1"
|
||||
@@ -10930,9 +10930,12 @@
|
||||
"dev": true
|
||||
},
|
||||
"qs": {
|
||||
"version": "6.9.6",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.9.6.tgz",
|
||||
"integrity": "sha512-TIRk4aqYLNoJUbd+g2lEdz5kLWIuTMRagAXxl78Q0RiVjAOugHmeKNGdd3cwo/ktpf9aL9epCfFqWDEKysUlLQ=="
|
||||
"version": "6.10.1",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.10.1.tgz",
|
||||
"integrity": "sha512-M528Hph6wsSVOBiYUnGf+K/7w0hNshs/duGsNXPUCLH5XAqjEtiPGwNONLV0tBH8NoGb0mvD5JubnUTrujKDTg==",
|
||||
"requires": {
|
||||
"side-channel": "^1.0.4"
|
||||
}
|
||||
},
|
||||
"qs-stringify": {
|
||||
"version": "1.2.1",
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@
|
||||
"node": ">=10.15.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@psf/bch-js": "^4.16.1",
|
||||
"@psf/bch-js": "^4.17.6",
|
||||
"apidoc": "^0.26.0",
|
||||
"axios": "^0.21.1",
|
||||
"bitcore-lib-cash": "^8.23.1",
|
||||
|
||||
+5
-3
@@ -189,9 +189,11 @@ server.listen(port)
|
||||
server.on('error', onError)
|
||||
server.on('listening', onListening)
|
||||
|
||||
// Set the time before a timeout error is generated. This impacts testing and
|
||||
// the handling of timeout errors. Is 10 seconds too agressive?
|
||||
server.setTimeout(30 * 1000)
|
||||
// Set the time before a timeout error is generated.
|
||||
// 10 seconds is way too agressive. 30 Seconds was used for a while, but with
|
||||
// being able to set a timeout between UTXOs for tokenUtxoDetails, the timeout
|
||||
// needed to be extended.
|
||||
server.setTimeout(1000 * 60 * 5) // 5 minutes
|
||||
|
||||
/**
|
||||
* Normalize a port into a number, string, or false.
|
||||
|
||||
@@ -1,521 +0,0 @@
|
||||
/*
|
||||
Blockbook API route
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const express = require('express')
|
||||
const axios = require('axios')
|
||||
const wlogger = require('../../util/winston-logging')
|
||||
|
||||
const RouteUtils = require('../../util/route-utils')
|
||||
const routeUtils = new RouteUtils()
|
||||
|
||||
// Library for easily switching the API paths to use different instances of
|
||||
// Blockbook.
|
||||
const BlockbookPath = require('../../util/blockbook-path')
|
||||
const BLOCKBOOKPATH = new BlockbookPath()
|
||||
// BLOCKBOOKPATH.toOpenBazaar()
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
// Used for processing error messages before sending them to the user.
|
||||
const util = require('util')
|
||||
util.inspect.defaultOptions = { depth: 1 }
|
||||
|
||||
const BCHJS = require('@psf/bch-js')
|
||||
const bchjs = new BCHJS()
|
||||
|
||||
let _this
|
||||
|
||||
class Blockbook {
|
||||
constructor () {
|
||||
_this = this
|
||||
|
||||
_this.axios = axios
|
||||
_this.routeUtils = routeUtils
|
||||
_this.bchjs = bchjs
|
||||
_this.BLOCKBOOKPATH = BLOCKBOOKPATH
|
||||
|
||||
_this.router = router
|
||||
_this.router.get('/', _this.root)
|
||||
_this.router.get('/balance/:address', _this.balanceSingle)
|
||||
_this.router.post('/balance', _this.balanceBulk)
|
||||
_this.router.get('/utxos/:address', _this.utxosSingle)
|
||||
_this.router.post('/utxos', _this.utxosBulk)
|
||||
_this.router.get('/tx/:txid', _this.txSingle)
|
||||
_this.router.post('/tx', _this.txBulk)
|
||||
}
|
||||
|
||||
// DRY error handler.
|
||||
errorHandler (err, res) {
|
||||
// Attempt to decode the error message.
|
||||
const { msg, status } = _this.routeUtils.decodeError(err)
|
||||
if (msg) {
|
||||
res.status(status)
|
||||
return res.json({ error: msg })
|
||||
}
|
||||
|
||||
res.status(500)
|
||||
return res.json({ error: util.inspect(err) })
|
||||
}
|
||||
|
||||
// Root API endpoint. Simply acknowledges that it exists.
|
||||
root (req, res, next) {
|
||||
return res.json({ status: 'address' })
|
||||
}
|
||||
|
||||
// Query the Blockbook Node API for a balance on a single BCH address.
|
||||
// Returns a Promise.
|
||||
async balanceFromBlockbook (thisAddress) {
|
||||
try {
|
||||
// console.log(`BLOCKBOOK_URL: ${BLOCKBOOK_URL}`)
|
||||
|
||||
// Convert the address to a cashaddr without a prefix.
|
||||
const addr = _this.bchjs.Address.toCashAddress(thisAddress)
|
||||
|
||||
const path = `${_this.BLOCKBOOKPATH.addrPath}${addr}`
|
||||
// console.log(`path: ${path}`)
|
||||
|
||||
// Query the Blockbook Node API.
|
||||
const options = {
|
||||
method: 'get',
|
||||
baseURL: path
|
||||
}
|
||||
|
||||
const axiosResponse = await _this.axios.request(options)
|
||||
const retData = axiosResponse.data
|
||||
// console.log(`retData: ${util.inspect(retData)}`)
|
||||
|
||||
return retData
|
||||
} catch (err) {
|
||||
// Dev Note: Do not log error messages here. Throw them instead and let the
|
||||
// parent function handle it.
|
||||
wlogger.debug('Error in blockbook.js/balanceFromBlockbook()')
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async balanceSingle (req, res, next) {
|
||||
try {
|
||||
const address = req.params.address
|
||||
|
||||
if (!address || address === '') {
|
||||
res.status(400)
|
||||
return res.json({ error: 'address can not be empty' })
|
||||
}
|
||||
|
||||
// Reject if address is an array.
|
||||
if (Array.isArray(address)) {
|
||||
res.status(400)
|
||||
return res.json({
|
||||
error: 'address can not be an array. Use POST for bulk upload.'
|
||||
})
|
||||
}
|
||||
|
||||
wlogger.debug(
|
||||
'Executing blockbook/balanceSingle with this address: ',
|
||||
address
|
||||
)
|
||||
|
||||
// Ensure the input is a valid BCH address.
|
||||
try {
|
||||
// const legacyAddr = bchjs.Address.toLegacyAddress(address)
|
||||
_this.bchjs.Address.toLegacyAddress(address)
|
||||
} catch (err) {
|
||||
res.status(400)
|
||||
return res.json({
|
||||
error: `Invalid BCH address. Double check your address is valid: ${address}`
|
||||
})
|
||||
}
|
||||
|
||||
// Prevent a common user error. Ensure they are using the correct network address.
|
||||
const networkIsValid = _this.routeUtils.validateNetwork(address)
|
||||
if (!networkIsValid) {
|
||||
res.status(400)
|
||||
return res.json({
|
||||
error:
|
||||
'Invalid network. Trying to use a testnet address on mainnet, or vice versa.'
|
||||
})
|
||||
}
|
||||
|
||||
// Query the Blockbook Node API.
|
||||
const retData = await _this.balanceFromBlockbook(address)
|
||||
|
||||
// Return the retrieved address information.
|
||||
res.status(200)
|
||||
return res.json(retData)
|
||||
} catch (err) {
|
||||
// Write out error to error log.
|
||||
wlogger.error('Error in blockbook.js/balanceSingle().', err)
|
||||
|
||||
return _this.errorHandler(err, res)
|
||||
}
|
||||
}
|
||||
|
||||
// POST handler for bulk queries on address details
|
||||
async balanceBulk (req, res, next) {
|
||||
try {
|
||||
let addresses = req.body.addresses
|
||||
// const currentPage = req.body.page ? parseInt(req.body.page, 10) : 0
|
||||
|
||||
// Reject if addresses is not an array.
|
||||
if (!Array.isArray(addresses)) {
|
||||
res.status(400)
|
||||
return res.json({
|
||||
error: 'addresses needs to be an array. Use GET for single address.'
|
||||
})
|
||||
}
|
||||
|
||||
// Enforce array size rate limits
|
||||
if (!_this.routeUtils.validateArraySize(req, addresses)) {
|
||||
res.status(400) // https://github.com/Bitcoin-com/rest.bitcoin.com/issues/330
|
||||
return res.json({
|
||||
error: 'Array too large.'
|
||||
})
|
||||
}
|
||||
|
||||
wlogger.debug(
|
||||
'Executing blockbook.js/balanceBulk with these addresses: ',
|
||||
addresses
|
||||
)
|
||||
|
||||
// Validate each element in the address array.
|
||||
for (let i = 0; i < addresses.length; i++) {
|
||||
const thisAddress = addresses[i]
|
||||
|
||||
// Ensure the input is a valid BCH address.
|
||||
try {
|
||||
_this.bchjs.Address.toLegacyAddress(thisAddress)
|
||||
} catch (err) {
|
||||
res.status(400)
|
||||
return res.json({
|
||||
error: `Invalid BCH address. Double check your address is valid: ${thisAddress}`
|
||||
})
|
||||
}
|
||||
|
||||
// Prevent a common user error. Ensure they are using the correct network address.
|
||||
const networkIsValid = _this.routeUtils.validateNetwork(thisAddress)
|
||||
if (!networkIsValid) {
|
||||
res.status(400)
|
||||
return res.json({
|
||||
error: `Invalid network for address ${thisAddress}. Trying to use a testnet address on mainnet, or vice versa.`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Loops through each address and creates an array of Promises, querying
|
||||
// Insight API in parallel.
|
||||
addresses = addresses.map(async (address, index) =>
|
||||
// console.log(`address: ${address}`)
|
||||
_this.balanceFromBlockbook(address)
|
||||
)
|
||||
|
||||
// Wait for all parallel Insight requests to return.
|
||||
const result = await _this.axios.all(addresses)
|
||||
|
||||
// Return the array of retrieved address information.
|
||||
res.status(200)
|
||||
return res.json(result)
|
||||
} catch (err) {
|
||||
wlogger.error('Error in blockbook.js/balanceBulk().', err)
|
||||
|
||||
return _this.errorHandler(err, res)
|
||||
}
|
||||
}
|
||||
|
||||
// Query the Blockbook API for utxos associated with a BCH address.
|
||||
// Returns a Promise.
|
||||
async utxosFromBlockbook (thisAddress) {
|
||||
try {
|
||||
// console.log(`BLOCKBOOK_URL: ${BLOCKBOOK_URL}`)
|
||||
|
||||
// Convert the address to a cashaddr without a prefix.
|
||||
const addr = _this.bchjs.Address.toCashAddress(thisAddress)
|
||||
|
||||
const path = `${_this.BLOCKBOOKPATH.utxoPath}${addr}`
|
||||
// console.log(`path: ${path}`)
|
||||
|
||||
// Query the Blockbook API.
|
||||
// Query the Blockbook Node API.
|
||||
const options = {
|
||||
method: 'get',
|
||||
baseURL: path
|
||||
}
|
||||
const axiosResponse = await _this.axios.request(options)
|
||||
const retData = axiosResponse.data
|
||||
// console.log(`retData: ${util.inspect(retData)}`)
|
||||
|
||||
// Add the satoshis property to each UTXO.
|
||||
for (let i = 0; i < retData.length; i++) {
|
||||
retData[i].satoshis = Number(retData[i].value)
|
||||
}
|
||||
|
||||
return retData
|
||||
} catch (err) {
|
||||
// Dev Note: Do not log error messages here. Throw them instead and let the
|
||||
// parent function handle it.
|
||||
wlogger.debug('Error in blockbook.js/utxosFromBlockbook()')
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// GET handler for single balance
|
||||
async utxosSingle (req, res, next) {
|
||||
try {
|
||||
const address = req.params.address
|
||||
|
||||
if (!address || address === '') {
|
||||
res.status(400)
|
||||
return res.json({ error: 'address can not be empty' })
|
||||
}
|
||||
|
||||
// Reject if address is an array.
|
||||
if (Array.isArray(address)) {
|
||||
res.status(400)
|
||||
return res.json({
|
||||
error: 'address can not be an array. Use POST for bulk upload.'
|
||||
})
|
||||
}
|
||||
|
||||
wlogger.debug(
|
||||
'Executing blockbook/utxosSingle with this address: ',
|
||||
address
|
||||
)
|
||||
|
||||
// Ensure the input is a valid BCH address.
|
||||
try {
|
||||
// const legacyAddr = bchjs.Address.toLegacyAddress(address)
|
||||
_this.bchjs.Address.toLegacyAddress(address)
|
||||
} catch (err) {
|
||||
res.status(400)
|
||||
return res.json({
|
||||
error: `Invalid BCH address. Double check your address is valid: ${address}`
|
||||
})
|
||||
}
|
||||
|
||||
// Prevent a common user error. Ensure they are using the correct network address.
|
||||
const networkIsValid = _this.routeUtils.validateNetwork(address)
|
||||
if (!networkIsValid) {
|
||||
res.status(400)
|
||||
return res.json({
|
||||
error:
|
||||
'Invalid network. Trying to use a testnet address on mainnet, or vice versa.'
|
||||
})
|
||||
}
|
||||
|
||||
// Query the Blockbook API.
|
||||
const retData = await _this.utxosFromBlockbook(address)
|
||||
|
||||
// Return the retrieved address information.
|
||||
res.status(200)
|
||||
return res.json(retData)
|
||||
} catch (err) {
|
||||
// Write out error to error log.
|
||||
wlogger.error('Error in blockbook.js/utxosSingle().', err)
|
||||
return _this.errorHandler(err, res)
|
||||
}
|
||||
}
|
||||
|
||||
// POST handler for bulk queries on address utxos
|
||||
async utxosBulk (req, res, next) {
|
||||
try {
|
||||
let addresses = req.body.addresses
|
||||
// const currentPage = req.body.page ? parseInt(req.body.page, 10) : 0
|
||||
|
||||
// Reject if addresses is not an array.
|
||||
if (!Array.isArray(addresses)) {
|
||||
res.status(400)
|
||||
return res.json({
|
||||
error: 'addresses needs to be an array. Use GET for single address.'
|
||||
})
|
||||
}
|
||||
|
||||
// Enforce array size rate limits
|
||||
if (!_this.routeUtils.validateArraySize(req, addresses)) {
|
||||
res.status(400) // https://github.com/Bitcoin-com/rest.bitcoin.com/issues/330
|
||||
return res.json({
|
||||
error: 'Array too large.'
|
||||
})
|
||||
}
|
||||
|
||||
wlogger.debug(
|
||||
'Executing blockbook.js/utxosBulk with these addresses: ',
|
||||
addresses
|
||||
)
|
||||
|
||||
// Validate each element in the address array.
|
||||
for (let i = 0; i < addresses.length; i++) {
|
||||
const thisAddress = addresses[i]
|
||||
|
||||
// Ensure the input is a valid BCH address.
|
||||
try {
|
||||
_this.bchjs.Address.toLegacyAddress(thisAddress)
|
||||
} catch (err) {
|
||||
res.status(400)
|
||||
return res.json({
|
||||
error: `Invalid BCH address. Double check your address is valid: ${thisAddress}`
|
||||
})
|
||||
}
|
||||
|
||||
// Prevent a common user error. Ensure they are using the correct network address.
|
||||
const networkIsValid = _this.routeUtils.validateNetwork(thisAddress)
|
||||
if (!networkIsValid) {
|
||||
res.status(400)
|
||||
return res.json({
|
||||
error: `Invalid network for address ${thisAddress}. Trying to use a testnet address on mainnet, or vice versa.`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Loops through each address and creates an array of Promises, querying
|
||||
// Insight API in parallel.
|
||||
addresses = addresses.map(async (address, index) =>
|
||||
// console.log(`address: ${address}`)
|
||||
_this.utxosFromBlockbook(address)
|
||||
)
|
||||
|
||||
// Wait for all parallel Insight requests to return.
|
||||
const result = await _this.axios.all(addresses)
|
||||
|
||||
// Return the array of retrieved address information.
|
||||
res.status(200)
|
||||
return res.json(result)
|
||||
} catch (err) {
|
||||
wlogger.error('Error in blockbook.js/utxosBulk().', err)
|
||||
return _this.errorHandler(err, res)
|
||||
}
|
||||
}
|
||||
|
||||
// Query the Blockbook Node API for transactions on a single TXID.
|
||||
// Returns a Promise.
|
||||
async transactionsFromBlockbook (txid) {
|
||||
try {
|
||||
// console.log(`BLOCKBOOK_URL: ${BLOCKBOOK_URL}`)
|
||||
|
||||
const path = `${_this.BLOCKBOOKPATH.txPath}${txid}`
|
||||
// console.log(`path: ${path}`)
|
||||
|
||||
// Query the Blockbook Node API.
|
||||
const options = {
|
||||
method: 'get',
|
||||
baseURL: path
|
||||
}
|
||||
const axiosResponse = await _this.axios.request(options)
|
||||
const retPromise = axiosResponse.data
|
||||
// console.log(`retData: ${util.inspect(retData)}`)
|
||||
|
||||
return retPromise
|
||||
} catch (err) {
|
||||
// Dev Note: Do not log error messages here. Throw them instead and let the
|
||||
// parent function handle it.
|
||||
wlogger.debug('Error in blockbook.js/transactionsFromBlockbook()')
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// GET handler for single transaction details.
|
||||
async txSingle (req, res, next) {
|
||||
try {
|
||||
const txid = req.params.txid
|
||||
|
||||
if (!txid || txid === '') {
|
||||
res.status(400)
|
||||
return res.json({ error: 'txid can not be empty' })
|
||||
}
|
||||
|
||||
// Reject if address is an array.
|
||||
if (Array.isArray(txid)) {
|
||||
res.status(400)
|
||||
return res.json({
|
||||
error: 'txid can not be an array. Use POST for bulk upload.'
|
||||
})
|
||||
}
|
||||
|
||||
// TODO: Add regex comparison of txid to ensure it's valid.
|
||||
if (txid.length !== 64) {
|
||||
res.status(400)
|
||||
return res.json({
|
||||
error: `txid must be of length 64 (not ${txid.length})`
|
||||
})
|
||||
}
|
||||
|
||||
wlogger.debug('Executing blockbook/txSingle with this txid: ', txid)
|
||||
|
||||
// Query the Blockbook Node API.
|
||||
const retData = await _this.transactionsFromBlockbook(txid)
|
||||
|
||||
// Return the retrieved address information.
|
||||
res.status(200)
|
||||
return res.json(retData)
|
||||
} catch (err) {
|
||||
// Write out error to error log.
|
||||
wlogger.error('Error in blockbook.js/txSingle().', err)
|
||||
|
||||
return _this.errorHandler(err, res)
|
||||
}
|
||||
}
|
||||
|
||||
// POST handler for bulk queries on tx details
|
||||
async txBulk (req, res, next) {
|
||||
try {
|
||||
let txids = req.body.txids
|
||||
// const currentPage = req.body.page ? parseInt(req.body.page, 10) : 0
|
||||
|
||||
// Reject if txids is not an array.
|
||||
if (!Array.isArray(txids)) {
|
||||
res.status(400)
|
||||
return res.json({
|
||||
error: 'txids need to be an array. Use GET for single address.'
|
||||
})
|
||||
}
|
||||
|
||||
// Enforce array size rate limits
|
||||
if (!_this.routeUtils.validateArraySize(req, txids)) {
|
||||
res.status(400) // https://github.com/Bitcoin-com/rest.bitcoin.com/issues/330
|
||||
return res.json({
|
||||
error: 'Array too large.'
|
||||
})
|
||||
}
|
||||
|
||||
wlogger.debug('Executing blockbook.js/txBulk with these txids: ', txids)
|
||||
|
||||
// Validate each element in the txids array.
|
||||
for (let i = 0; i < txids.length; i++) {
|
||||
const thisTxid = txids[i]
|
||||
|
||||
if (!thisTxid || thisTxid === '') {
|
||||
res.status(400)
|
||||
return res.json({ error: 'txid can not be empty' })
|
||||
}
|
||||
|
||||
// TODO: Add regex comparison of txid to ensure it's valid.
|
||||
if (thisTxid.length !== 64) {
|
||||
res.status(400)
|
||||
return res.json({
|
||||
error: `txid must be of length 64 (not ${thisTxid.length})`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Loops through each address and creates an array of Promises, querying
|
||||
// Insight API in parallel.
|
||||
txids = txids.map(async (txid, index) =>
|
||||
// console.log(`address: ${address}`)
|
||||
_this.transactionsFromBlockbook(txid)
|
||||
)
|
||||
|
||||
// Wait for all parallel Insight requests to return.
|
||||
const result = await _this.axios.all(txids)
|
||||
|
||||
// Return the array of retrieved address information.
|
||||
res.status(200)
|
||||
return res.json(result)
|
||||
} catch (err) {
|
||||
wlogger.error('Error in blockbook.js/txBulk().', err)
|
||||
return _this.errorHandler(err, res)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Blockbook
|
||||
@@ -181,7 +181,7 @@ class Encryption {
|
||||
publicKey: 'not found'
|
||||
})
|
||||
} catch (err) {
|
||||
console.log('Error in encryption.js/getPublicKey().', err)
|
||||
// console.log('Error in encryption.js/getPublicKey().', err)
|
||||
wlogger.error('Error in encryption.js/getPublicKey().', err)
|
||||
|
||||
return _this.errorHandler(err, res)
|
||||
|
||||
@@ -53,6 +53,7 @@ class Blockchain {
|
||||
this.router.post('/getTxOutProof', this.getTxOutProofBulk)
|
||||
this.router.get('/verifyTxOutProof/:proof', this.verifyTxOutProofSingle)
|
||||
this.router.post('/verifyTxOutProof', this.verifyTxOutProofBulk)
|
||||
this.router.post('/getBlock', this.getBlock)
|
||||
}
|
||||
|
||||
root (req, res, next) {
|
||||
@@ -937,6 +938,52 @@ class Blockchain {
|
||||
return _this.errorHandler(err, res)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} /blockchain/getBlock/ Get block details
|
||||
* @apiName getBlock
|
||||
* @apiGroup Blockchain
|
||||
* @apiDescription Returns block details
|
||||
*
|
||||
* @apiExample Example usage:
|
||||
* curl "https://api.fullstack.cash/v4/blockchain/getblock/" -X POST -H "Content-Type: application/json" --data-binary '{"blockhash":"000000000000000002a5fe0bdd6e3f04342a975c0f55e57f97e73bb90041676b","verbosity":0 }'
|
||||
*
|
||||
* @apiParam {String} blockhash Block hash (required)
|
||||
* @apiParam {Number} verbosity Default 1 (optional)
|
||||
*
|
||||
*/
|
||||
async getBlock (req, res, next) {
|
||||
try {
|
||||
// Validate input parameter
|
||||
const blockhash = req.body.blockhash
|
||||
let verbosity = req.body.verbosity
|
||||
|
||||
// Default to a value of 1 if another verbosity level is not defined.
|
||||
if (!verbosity && verbosity !== 0) verbosity = 1
|
||||
|
||||
if (!blockhash || blockhash === '') {
|
||||
res.status(400)
|
||||
return res.json({ error: 'blockhash can not be empty' })
|
||||
}
|
||||
|
||||
// Axios options
|
||||
const options = _this.routeUtils.getAxiosOptions()
|
||||
|
||||
options.data.id = 'getblock'
|
||||
options.data.method = 'getblock'
|
||||
options.data.params = [blockhash, verbosity]
|
||||
|
||||
const response = await _this.axios.request(options)
|
||||
|
||||
return res.json(response.data.result)
|
||||
} catch (err) {
|
||||
// Write out error to error log.
|
||||
// logger.error(`Error in rawtransactions/decodeRawTransaction: `, err)
|
||||
wlogger.error('Error in blockchain.js/getBlock()', err)
|
||||
|
||||
return _this.errorHandler(err, res)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Blockchain
|
||||
|
||||
@@ -156,7 +156,7 @@ class RouteUtils {
|
||||
|
||||
// Handle 429 errors thrown by nginx
|
||||
if (err.error) {
|
||||
// console.log('decodeError: err: ', err)
|
||||
console.log('decodeError: err: ', err)
|
||||
|
||||
if (err.error.includes('429 Too Many Requests')) {
|
||||
const internalMsg =
|
||||
|
||||
@@ -1823,4 +1823,204 @@ describe('#BlockchainRouter', () => {
|
||||
assert.equal(result.length, 2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('#getBlock()', () => {
|
||||
it('returns proper error when downstream service stalls', async () => {
|
||||
// Mock the timeout error.
|
||||
sandbox.stub(uut.axios, 'request').throws({ code: 'ECONNABORTED' })
|
||||
|
||||
req.body.blockhash =
|
||||
'000000202ed2723e7590e2b937f6821a99d6764cb8799bf30f8e300000000000000000001d311c02df9a1e3f57b8dbdcf97ec8dbc3109a26779724c63e560b29ad9ea501e2af955d286403183049e39c1e000000067139f230701a2819a76795564bd2f67ded7eeae68596f368eddb3dd5bc54e59320e896f71d61cfc3ae3d4a90fca08b1aa35ba91256d8939d2cad11e638c0081f66724abdef55cf7b8b9fed064bce0369171434f8b289c1330ccef765f8e97a2c0d794d81aafb535855f7daa6bb51e40f77c6b59d7af7f62d0eb726a4fc4df82353d56fcbda7c7ea6bd935d61af8fb3b295637e6f323b10231135b3f10a034cfb238f635830c0595e52c6c31247cf677b555f7a287076e20cd0e1d3cc9af7260f02b700'
|
||||
|
||||
const result = await uut.getBlock(req, res)
|
||||
// console.log(`result: ${JSON.stringify(result, null, 2)}`)
|
||||
|
||||
assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.')
|
||||
assert.include(
|
||||
result.error,
|
||||
'Could not communicate with full node',
|
||||
'Error message expected'
|
||||
)
|
||||
})
|
||||
|
||||
it('returns proper error when downstream service is down', async () => {
|
||||
// Mock the timeout error.
|
||||
sandbox.stub(uut.axios, 'request').throws({ code: 'ECONNREFUSED' })
|
||||
|
||||
req.body.blockhash =
|
||||
'000000202ed2723e7590e2b937f6821a99d6764cb8799bf30f8e300000000000000000001d311c02df9a1e3f57b8dbdcf97ec8dbc3109a26779724c63e560b29ad9ea501e2af955d286403183049e39c1e000000067139f230701a2819a76795564bd2f67ded7eeae68596f368eddb3dd5bc54e59320e896f71d61cfc3ae3d4a90fca08b1aa35ba91256d8939d2cad11e638c0081f66724abdef55cf7b8b9fed064bce0369171434f8b289c1330ccef765f8e97a2c0d794d81aafb535855f7daa6bb51e40f77c6b59d7af7f62d0eb726a4fc4df82353d56fcbda7c7ea6bd935d61af8fb3b295637e6f323b10231135b3f10a034cfb238f635830c0595e52c6c31247cf677b555f7a287076e20cd0e1d3cc9af7260f02b700'
|
||||
const result = await uut.getBlock(req, res)
|
||||
// console.log(`result: ${JSON.stringify(result, null, 2)}`)
|
||||
|
||||
assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.')
|
||||
assert.include(
|
||||
result.error,
|
||||
'Could not communicate with full node',
|
||||
'Error message expected'
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw 400 if blockhash is empty', async () => {
|
||||
const result = await uut.getBlock(req, res)
|
||||
// console.log(`result: ${util.inspect(result)}`)
|
||||
|
||||
assert.hasAllKeys(result, ['error'])
|
||||
assert.include(result.error, 'blockhash can not be empty')
|
||||
})
|
||||
|
||||
it('should return block info with verbosity 0', async () => {
|
||||
// Mock the RPC call for unit tests.
|
||||
if (process.env.TEST === 'unit') {
|
||||
sandbox
|
||||
.stub(uut.axios, 'request')
|
||||
.resolves({ data: { result: mockData.mockBlockInfo.verbosity0 } })
|
||||
}
|
||||
|
||||
req.body.blockhash =
|
||||
'0000000000000000008e8d83cba6d45a9314bc2ef4538d4e0577c6bed8593536'
|
||||
req.body.verbosity = 0
|
||||
|
||||
const result = await uut.getBlock(req, res)
|
||||
// console.log(`result: ${JSON.stringify(result, null, 2)}`)
|
||||
|
||||
assert.isString(result)
|
||||
})
|
||||
|
||||
it('should return block info with verbosity 1', async () => {
|
||||
// Mock the RPC call for unit tests.
|
||||
if (process.env.TEST === 'unit') {
|
||||
sandbox
|
||||
.stub(uut.axios, 'request')
|
||||
.resolves({ data: { result: mockData.mockBlockInfo.verbosity1 } })
|
||||
}
|
||||
|
||||
req.body.blockhash =
|
||||
'0000000000000000008e8d83cba6d45a9314bc2ef4538d4e0577c6bed8593536'
|
||||
req.body.verbosity = 1
|
||||
|
||||
const result = await uut.getBlock(req, res)
|
||||
// console.log(`result: ${JSON.stringify(result, null, 2)}`)
|
||||
|
||||
assert.property(result, 'hash', 'hash property expected')
|
||||
assert.property(
|
||||
result,
|
||||
'confirmations',
|
||||
'confirmations property expected'
|
||||
)
|
||||
assert.property(result, 'size', 'size property expected')
|
||||
assert.property(result, 'height', 'height property expected')
|
||||
assert.property(result, 'version', 'version property expected')
|
||||
assert.property(result, 'versionHex', 'versionHex property expected')
|
||||
assert.property(result, 'merkleroot', 'merkleroot property expected')
|
||||
assert.property(result, 'tx', 'tx property expected')
|
||||
assert.property(result, 'time', 'time property expected')
|
||||
assert.property(result, 'mediantime', 'mediantime property expected')
|
||||
assert.property(result, 'nonce', 'nonce property expected')
|
||||
assert.property(result, 'bits', 'bits property expected')
|
||||
assert.property(result, 'difficulty', 'difficulty property expected')
|
||||
assert.property(result, 'chainwork', 'chainwork property expected')
|
||||
assert.property(result, 'nTx', 'nTx property expected')
|
||||
assert.property(
|
||||
result,
|
||||
'previousblockhash',
|
||||
'previousblockhash property expected'
|
||||
)
|
||||
assert.property(
|
||||
result,
|
||||
'nextblockhash',
|
||||
'nextblockhash property expected'
|
||||
)
|
||||
})
|
||||
|
||||
it('should return block info with verbosity 2', async () => {
|
||||
// Mock the RPC call for unit tests.
|
||||
if (process.env.TEST === 'unit') {
|
||||
sandbox
|
||||
.stub(uut.axios, 'request')
|
||||
.resolves({ data: { result: mockData.mockBlockInfo.verbosity1 } })
|
||||
}
|
||||
|
||||
req.body.blockhash =
|
||||
'0000000000000000008e8d83cba6d45a9314bc2ef4538d4e0577c6bed8593536'
|
||||
req.body.verbosity = 2
|
||||
|
||||
const result = await uut.getBlock(req, res)
|
||||
// console.log(`result: ${JSON.stringify(result, null, 2)}`)
|
||||
|
||||
assert.property(result, 'hash', 'hash property expected')
|
||||
assert.property(
|
||||
result,
|
||||
'confirmations',
|
||||
'confirmations property expected'
|
||||
)
|
||||
assert.property(result, 'size', 'size property expected')
|
||||
assert.property(result, 'height', 'height property expected')
|
||||
assert.property(result, 'version', 'version property expected')
|
||||
assert.property(result, 'versionHex', 'versionHex property expected')
|
||||
assert.property(result, 'merkleroot', 'merkleroot property expected')
|
||||
assert.property(result, 'tx', 'tx property expected')
|
||||
assert.property(result, 'time', 'time property expected')
|
||||
assert.property(result, 'mediantime', 'mediantime property expected')
|
||||
assert.property(result, 'nonce', 'nonce property expected')
|
||||
assert.property(result, 'bits', 'bits property expected')
|
||||
assert.property(result, 'difficulty', 'difficulty property expected')
|
||||
assert.property(result, 'chainwork', 'chainwork property expected')
|
||||
assert.property(result, 'nTx', 'nTx property expected')
|
||||
assert.property(
|
||||
result,
|
||||
'previousblockhash',
|
||||
'previousblockhash property expected'
|
||||
)
|
||||
assert.property(
|
||||
result,
|
||||
'nextblockhash',
|
||||
'nextblockhash property expected'
|
||||
)
|
||||
})
|
||||
|
||||
it('should return block info without verbosity especified', async () => {
|
||||
// Mock the RPC call for unit tests.
|
||||
if (process.env.TEST === 'unit') {
|
||||
sandbox
|
||||
.stub(uut.axios, 'request')
|
||||
.resolves({ data: { result: mockData.mockBlockInfo.verbosity1 } })
|
||||
}
|
||||
|
||||
req.body.blockhash =
|
||||
'0000000000000000008e8d83cba6d45a9314bc2ef4538d4e0577c6bed8593536'
|
||||
|
||||
const result = await uut.getBlock(req, res)
|
||||
// console.log(`result: ${JSON.stringify(result, null, 2)}`)
|
||||
|
||||
assert.property(result, 'hash', 'hash property expected')
|
||||
assert.property(
|
||||
result,
|
||||
'confirmations',
|
||||
'confirmations property expected'
|
||||
)
|
||||
assert.property(result, 'size', 'size property expected')
|
||||
assert.property(result, 'height', 'height property expected')
|
||||
assert.property(result, 'version', 'version property expected')
|
||||
assert.property(result, 'versionHex', 'versionHex property expected')
|
||||
assert.property(result, 'merkleroot', 'merkleroot property expected')
|
||||
assert.property(result, 'tx', 'tx property expected')
|
||||
assert.property(result, 'time', 'time property expected')
|
||||
assert.property(result, 'mediantime', 'mediantime property expected')
|
||||
assert.property(result, 'nonce', 'nonce property expected')
|
||||
assert.property(result, 'bits', 'bits property expected')
|
||||
assert.property(result, 'difficulty', 'difficulty property expected')
|
||||
assert.property(result, 'chainwork', 'chainwork property expected')
|
||||
assert.property(result, 'nTx', 'nTx property expected')
|
||||
assert.property(
|
||||
result,
|
||||
'previousblockhash',
|
||||
'previousblockhash property expected'
|
||||
)
|
||||
assert.property(
|
||||
result,
|
||||
'nextblockhash',
|
||||
'nextblockhash property expected'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -285,7 +285,86 @@ const mockAncestors = [
|
||||
'e68dad4a7292105cfa84fcaef7f99e5d4f2ece9613ca625d4d2ebf61efa84118',
|
||||
'fe94caf5da672be3772d2304a6272eb8bc3d3f5cb4a886f39b7981e1485cf74b'
|
||||
]
|
||||
|
||||
const mockBlockInfo = {
|
||||
verbosity0: '20000000',
|
||||
verbosity1: {
|
||||
hash: '00000000c937983704a73af28acdec37b049d214adbda81d7e2a3dd146f6ed09',
|
||||
confirmations: 1,
|
||||
size: 3725,
|
||||
strippedsize: 3725,
|
||||
weight: 3725,
|
||||
height: 6725,
|
||||
version: 1,
|
||||
versionHex: '00000000',
|
||||
merkleroot: 'xxxx',
|
||||
tx: [
|
||||
'2afb8264508e2bf3e5288ccad01ed2ab766745b6b9747666b519d59212012c01',
|
||||
'18f40b1ae56bba3fa1934b737fbe46ed8d5ca40fa9aed95073eeb5a119530cd3',
|
||||
'349720d878547752607a69eb19e330592fee271fb5376cdfd811bee423558ed8',
|
||||
'35571c80e7d0e9247b467454ef147d1d5833c775bc2d4164b1bebd4c1f69164f',
|
||||
'480937e8efacdafeeb97d401ff0dd9ea8e8ddb27244cefa67a03621315bdb0e6',
|
||||
'523327469d0b90c0de9a905c2fe6e227278fc5b55b9d9911ca151e1c26647065',
|
||||
'61de4af971d94dbc741762f21dcef08b74b62863a56a1cb3496becdd8d47a858',
|
||||
'69f70a288403b5bba23030ccd05d2e5cb00394620fdae3b050cff9432cc590ce',
|
||||
'94472a90fbdba2eb2cba68308415181faedf3585c66b89efcbdb927a0c10ba23',
|
||||
'bc6f781f9e2f2df460f89995c5e1c7224e48ccbe1aa2a575961f3f5330259864',
|
||||
'f2d945a79bec5454a9ab4d570a150d81124daa308f1c81106a977d6413476944'
|
||||
],
|
||||
time: 111,
|
||||
mediantime: 111,
|
||||
nonce: 111,
|
||||
bits: '1d00ffff',
|
||||
difficulty: 99.999,
|
||||
chainwork: 'xxxx',
|
||||
nTx: 1,
|
||||
previousblockhash:
|
||||
'00000000c937983704a73af28acdec37b049d214adbda81d7e2a3dd146f6ed09',
|
||||
nextblockhash:
|
||||
'00000000c937983704a73af28acdec37b049d214adbda81d7e2a3dd146f6ed09'
|
||||
},
|
||||
verbosity2: {
|
||||
hash: '00000000c937983704a73af28acdec37b049d214adbda81d7e2a3dd146f6ed09',
|
||||
confirmations: 1,
|
||||
size: 3725,
|
||||
strippedsize: 3725,
|
||||
weight: 3725,
|
||||
height: 6725,
|
||||
version: 1,
|
||||
versionHex: '00000000',
|
||||
merkleroot: 'xxxx',
|
||||
tx: [
|
||||
{
|
||||
hex:
|
||||
'01000000010000000000000000000000000000000000000000000000000000000000000000ffffffff0704ffff001d0104ffffffff0100f2052a0100000043410496b538e853519c726a2c91e61ec11600ae1390813a627c66fb8be7947be63c52da7589379515d4e0a604f8141781e62294721166bf621e73a82cbf2342c858eeac00000000',
|
||||
txid:
|
||||
'0e3e2357e806b6cdb1f70b54c3a3a17b6714ee1f0e68bebb44a74b1efd512098',
|
||||
hash:
|
||||
'0e3e2357e806b6cdb1f70b54c3a3a17b6714ee1f0e68bebb44a74b1efd512098',
|
||||
size: 134,
|
||||
version: 1,
|
||||
locktime: 0,
|
||||
vin: [],
|
||||
vout: [],
|
||||
blockhash:
|
||||
'00000000839a8e6886ab5951d76f411475428afc90947ee320161bbf18eb6048',
|
||||
confirmations: 581882,
|
||||
time: 1231469665,
|
||||
blocktime: 1231469665
|
||||
}
|
||||
],
|
||||
time: 111,
|
||||
mediantime: 111,
|
||||
nonce: 111,
|
||||
bits: '1d00ffff',
|
||||
difficulty: 99.999,
|
||||
chainwork: 'xxxx',
|
||||
nTx: 1,
|
||||
previousblockhash:
|
||||
'00000000c937983704a73af28acdec37b049d214adbda81d7e2a3dd146f6ed09',
|
||||
nextblockhash:
|
||||
'00000000c937983704a73af28acdec37b049d214adbda81d7e2a3dd146f6ed09'
|
||||
}
|
||||
}
|
||||
module.exports = {
|
||||
mockBlockHash,
|
||||
mockBlockchainInfo,
|
||||
@@ -296,5 +375,6 @@ module.exports = {
|
||||
mockBlockHeader,
|
||||
mockTxOut,
|
||||
mockTxOutProof,
|
||||
mockAncestors
|
||||
mockAncestors,
|
||||
mockBlockInfo
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
Unit tests for the route-utils.js library.
|
||||
*/
|
||||
|
||||
const assert = require('chai').assert
|
||||
|
||||
const RouteUtils = require('../../src/util/route-utils.js')
|
||||
|
||||
describe('#route-utils', () => {
|
||||
let uut
|
||||
|
||||
beforeEach(() => {
|
||||
uut = new RouteUtils()
|
||||
})
|
||||
|
||||
describe('#decodeError', () => {
|
||||
it('should decode a 429 error from nginx', () => {
|
||||
const err = {
|
||||
error: '<html>\r\n<head><title>429 Too Many Requests</title></head>\r\n<body>\r\n<center><h1>429 Too Many Requests</h1></center>\r\n<hr><center>nginx/1.18.0 (Ubuntu)</center>\r\n</body>\r\n</html>\r\n',
|
||||
level: 'error',
|
||||
message: 'Error in slp.js/hydrateUtxos().',
|
||||
timestamp: '2021-03-31T04:13:36.662Z'
|
||||
}
|
||||
|
||||
const result = uut.decodeError(err)
|
||||
// console.log('result: ', result)
|
||||
|
||||
assert.property(result, 'msg')
|
||||
assert.equal(result.msg, '429 Too Many Requests')
|
||||
assert.property(result, 'status')
|
||||
assert.equal(result.status, 429)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user