From 1f38c5dd936370bda7666076e2763033f59001dc Mon Sep 17 00:00:00 2001 From: Dan Janosik Date: Thu, 20 Sep 2018 00:05:19 -0400 Subject: [PATCH] Majority of the work on #8 to support querying N ElectrumX servers for address balance and address txid history --- app.js | 7 + app/api/coreApi.js | 44 +++++ app/api/electrumApi.js | 98 +++++++++++ package.json | 1 + routes/baseActionsRouter.js | 148 +++++++++++++++- views/address.pug | 225 +++++++++++++++++++------ views/includes/electrum-trust-note.pug | 3 + 7 files changed, 468 insertions(+), 58 deletions(-) create mode 100644 app/api/electrumApi.js create mode 100644 views/includes/electrum-trust-note.pug diff --git a/app.js b/app.js index 204e179..50e9180 100755 --- a/app.js +++ b/app.js @@ -22,6 +22,7 @@ var coins = require("./app/coins.js"); var request = require("request"); var qrcode = require("qrcode"); var fs = require('fs'); +var electrumApi = require("./app/api/electrumApi.js"); var crawlerBotUserAgentStrings = [ "Googlebot", "Bingbot", "Slurp", "DuckDuckBot", "Baiduspider", "YandexBot", "Sogou", "Exabot", "facebot", "ia_archiver" ]; @@ -121,6 +122,12 @@ app.runOnStartup = function() { }); } + if (config.electrumXServers && config.electrumXServers.length > 0) { + electrumApi.connectToServers(); + + global.electrumApi = electrumApi; + } + if (global.coinConfig.miningPoolsConfigUrls) { var promises = []; diff --git a/app/api/coreApi.js b/app/api/coreApi.js index 684468a..c00934d 100644 --- a/app/api/coreApi.js +++ b/app/api/coreApi.js @@ -558,6 +558,49 @@ function getRawTransactions(txids) { }); } +function getRawTransactionsWithInputs(txids) { + return new Promise(function(resolve, reject) { + getRawTransactions(txids).then(function(transactions) { + var maxInputsTracked = config.site.txMaxInput; + var vinTxids = []; + for (var i = 0; i < transactions.length; i++) { + var transaction = transactions[i]; + + if (transaction && transaction.vin) { + for (var j = 0; j < Math.min(maxInputsTracked, transaction.vin.length); j++) { + if (transaction.vin[j].txid) { + vinTxids.push(transaction.vin[j].txid); + } + } + } + } + + var txInputsByTransaction = {}; + getRawTransactions(vinTxids).then(function(vinTransactions) { + var vinTxById = {}; + + vinTransactions.forEach(function(tx) { + vinTxById[tx.txid] = tx; + }); + + transactions.forEach(function(tx) { + txInputsByTransaction[tx.txid] = {}; + + if (tx && tx.vin) { + for (var i = 0; i < Math.min(maxInputsTracked, tx.vin.length); i++) { + if (vinTxById[tx.vin[i].txid]) { + txInputsByTransaction[tx.txid][i] = vinTxById[tx.vin[i].txid]; + } + } + } + + resolve({ transactions:transactions, txInputsByTransaction:txInputsByTransaction }); + }); + }); + }); + }); +} + function getBlockByHashWithTransactions(blockHash, txLimit, txOffset) { return new Promise(function(resolve, reject) { getBlockByHash(blockHash).then(function(block) { @@ -658,6 +701,7 @@ module.exports = { getBlockByHashWithTransactions: getBlockByHashWithTransactions, getRawTransaction: getRawTransaction, getRawTransactions: getRawTransactions, + getRawTransactionsWithInputs: getRawTransactionsWithInputs, getMempoolStats: getMempoolStats, getUptimeSeconds: getUptimeSeconds, getHelp: getHelp, diff --git a/app/api/electrumApi.js b/app/api/electrumApi.js new file mode 100644 index 0000000..f31c2de --- /dev/null +++ b/app/api/electrumApi.js @@ -0,0 +1,98 @@ +var config = require("./../config.js"); + +const ElectrumClient = require('electrum-client'); + +var electrumClients = []; + +function connectToServers() { + for (var i = 0; i < config.electrumXServers.length; i++) { + connectToServer(config.electrumXServers[i].host, config.electrumXServers[i].port); + } +} + +function connectToServer(host, port) { + console.log("Connecting to ElectrumX Server: " + host + ":" + port); + + var electrumClient = new ElectrumClient(port, host, 'tls'); + electrumClient.connect().then(function() { + electrumClient.server_version("btc-rpc-explorer-1.1", "1.2").then(function(res) { + console.log("Connected to ElectrumX Server: " + host + ":" + port + ", versions: " + res); + }); + }); + + electrumClients.push(electrumClient); +} + +function runOnServer(electrumClient, f) { + return new Promise(function(resolve, reject) { + f(electrumClient).then(function(result) { + resolve(result); + + }).catch(function(err) { + console.log("Error dif0e21qdh: " + JSON.stringify(err) + ", host=" + electrumClient.host + ", port=" + electrumClient.port); + }); + }); +} + +function runOnAllServers(f) { + return new Promise(function(resolve, reject) { + var promises = []; + + for (var i = 0; i < electrumClients.length; i++) { + promises.push(runOnServer(electrumClients[i], f)); + } + + Promise.all(promises).then(function(results) { + resolve(results); + + }).catch(function(err) { + reject(err); + }); + }); +} + +function getAddressTxids(addrScripthash) { + return new Promise(function(resolve, reject) { + runOnAllServers(function(electrumClient) { + return electrumClient.blockchainScripthash_getHistory(addrScripthash); + + }).then(function(results) { + resolve(results[0]); + + }).catch(function(err) { + reject(err); + }); + }); +} + +function getAddressBalance(addrScripthash) { + return new Promise(function(resolve, reject) { + runOnAllServers(function(electrumClient) { + return electrumClient.blockchainScripthash_getBalance(addrScripthash); + + }).then(function(results) { + var first = results[0]; + var done = false; + + for (var i = 1; i < results.length; i++) { + if (results[i].confirmed != first.confirmed) { + resolve({conflictedResults:results}); + done = true; + } + } + + if (!done) { + resolve(results[0]); + } + + }).catch(function(err) { + reject(err); + }); + }); +} + +module.exports = { + connectToServers: connectToServers, + getAddressTxids: getAddressTxids, + getAddressBalance: getAddressBalance +}; \ No newline at end of file diff --git a/package.json b/package.json index f5c2756..ab94a9c 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "crypto-js": "3.1.9-1", "debug": "~2.6.0", "decimal.js": "7.2.3", + "electrum-client": "0.0.6", "express": "~4.16.3", "express-session": "1.15.6", "jstransformer-markdown-it": "^2.0.0", diff --git a/routes/baseActionsRouter.js b/routes/baseActionsRouter.js index c4e4661..7b0f6fb 100644 --- a/routes/baseActionsRouter.js +++ b/routes/baseActionsRouter.js @@ -5,6 +5,9 @@ var moment = require('moment'); var bitcoinCore = require("bitcoin-core"); var qrcode = require('qrcode'); var bitcoinjs = require('bitcoinjs-lib'); +var sha256 = require("crypto-js/sha256"); +var hexEnc = require("crypto-js/enc-hex"); +var Decimal = require("decimal.js"); var utils = require('./../app/utils.js'); var coins = require("./../app/coins.js"); @@ -492,9 +495,37 @@ router.get("/tx/:transactionId", function(req, res) { }); router.get("/address/:address", function(req, res) { + var limit = config.site.addressTxPageSize; + var offset = 0; + var sort = "desc"; + + + if (req.query.limit) { + limit = parseInt(req.query.limit); + + // for demo sites, limit page sizes + if (config.demoSite && limit > config.site.addressTxPageSize) { + limit = config.site.addressTxPageSize; + + res.locals.userMessage = "Transaction page size limited to " + config.site.addressTxPageSize + ". If this is your site, you can change or disable this limit in the site config."; + } + } + + if (req.query.offset) { + offset = parseInt(req.query.offset); + } + + if (req.query.sort) { + sort = req.query.sort; + } + + var address = req.params.address; res.locals.address = address; + res.locals.limit = limit; + res.locals.offset = offset; + res.locals.paginationBaseUrl = ("/address/" + address); res.locals.result = {}; @@ -517,19 +548,118 @@ router.get("/address/:address", function(req, res) { res.locals.payoutAddressForMiner = global.miningPoolsConfigs[i].payout_addresses[address]; } } - - coreApi.getAddress(address).then(function(result) { - res.locals.result.validateaddress = result; - qrcode.toDataURL(address, function(err, url) { - if (err) { - console.log("Error 93ygfew0ygf2gf2: " + err); - } + coreApi.getAddress(address).then(function(validateaddressResult) { + res.locals.result.validateaddress = validateaddressResult; + + var promises = []; + if (global.electrumApi) { + var addrScripthash = hexEnc.stringify(sha256(hexEnc.parse(validateaddressResult.scriptPubKey))); + addrScripthash = addrScripthash.match(/.{2}/g).reverse().join(""); - res.locals.addressQrCodeUrl = url; + promises.push(new Promise(function(resolve, reject) { + electrumApi.getAddressBalance(addrScripthash).then(function(result) { + res.locals.balance = result; + + res.locals.electrumBalance = result; + + resolve(); + + }).catch(function(err) { + reject(err); + }); + })); + + promises.push(new Promise(function(resolve, reject) { + electrumApi.getAddressTxids(addrScripthash).then(function(result) { + res.locals.electrumHistory = result; + + var txids = []; + var blockHeightsByTxid = {}; + + for (var i = 0; i < result.length; i++) { + txids.push(result[i].tx_hash); + blockHeightsByTxid[result[i].tx_hash] = result[i].height; + } - res.render("address"); + if (sort == "desc") { + txids = txids.reverse(); + } + + res.locals.txids = txids; + + var pagedTxids = []; + for (var i = offset; i < (offset + limit); i++) { + pagedTxids.push(txids[i]); + } + + coreApi.getRawTransactionsWithInputs(pagedTxids).then(function(rawTxResult) { + res.locals.transactions = rawTxResult.transactions; + res.locals.txInputsByTransaction = rawTxResult.txInputsByTransaction; + res.locals.blockHeightsByTxid = blockHeightsByTxid; + + var addrGainsByTx = {}; + var addrLossesByTx = {}; + + for (var i = 0; i < rawTxResult.transactions.length; i++) { + var tx = rawTxResult.transactions[i]; + var txInputs = rawTxResult.txInputsByTransaction[tx.txid]; + + for (var j = 0; j < tx.vout.length; j++) { + if (tx.vout[j].scriptPubKey.addresses.includes(address)) { + if (addrGainsByTx[tx.txid] == null) { + addrGainsByTx[tx.txid] = new Decimal(0); + } + + addrGainsByTx[tx.txid] = addrGainsByTx[tx.txid].plus(new Decimal(tx.vout[j].value)); + } + } + + for (var j = 0; j < tx.vin.length; j++) { + var txInput = txInputs[j]; + + for (var k = 0; k < txInput.vout.length; k++) { + if (txInput.vout[k].scriptPubKey.addresses.includes(address)) { + if (addrLossesByTx[tx.txid] == null) { + addrLossesByTx[tx.txid] = new Decimal(0); + } + + addrLossesByTx[tx.txid] = addrLossesByTx[tx.txid].plus(new Decimal(txInput.vout[k].value)); + } + } + + } + + //console.log("tx: " + JSON.stringify(tx)); + //console.log("txInputs: " + JSON.stringify(txInputs)); + } + + res.locals.addrGainsByTx = addrGainsByTx; + res.locals.addrLossesByTx = addrLossesByTx; + + resolve(); + }); + + }).catch(function(err) { + reject(err); + }); + })); + } + + Promise.all(promises).then(function() { + qrcode.toDataURL(address, function(err, url) { + if (err) { + console.log("Error 93ygfew0ygf2gf2: " + err); + } + + res.locals.addressQrCodeUrl = url; + + res.render("address"); + }); + }).catch(function(err) { + console.log(err); }); + }).catch(function(err) { res.locals.userMessage = "Failed to load address " + address + " (" + err + ")"; diff --git a/views/address.pug b/views/address.pug index c2f8a9d..8d3d834 100644 --- a/views/address.pug +++ b/views/address.pug @@ -56,76 +56,203 @@ block content div(class="card-header") span(class="h6") Summary div(class="card-body") + div(class="row") + div(class="col-md-6") - if (addressObj.hash) - div(class="row") - div(class="summary-table-label") Hash 160 - div(class="summary-table-content monospace") #{addressObj.hash.toString("hex")} + if (addressObj.hash) + div(class="row") + div(class="summary-split-table-label") Hash 160 + div(class="summary-split-table-content monospace") #{addressObj.hash.toString("hex")} - if (result.validateaddress.scriptPubKey) - div(class="row") - div(class="summary-table-label") Script Public Key - div(class="summary-table-content monospace") #{result.validateaddress.scriptPubKey} + if (result.validateaddress.scriptPubKey) + div(class="row") + div(class="summary-split-table-label") Script Public Key + div(class="summary-split-table-content monospace") #{result.validateaddress.scriptPubKey} - if (addressObj.hasOwnProperty("version")) - div(class="row") - div(class="summary-table-label") Version - div(class="summary-table-content monospace") #{addressObj.version} + if (addressObj.hasOwnProperty("version")) + div(class="row") + div(class="summary-split-table-label") Version + div(class="summary-split-table-content monospace") #{addressObj.version} - if (result.validateaddress.hasOwnProperty("witness_version")) - div(class="row") - div(class="summary-table-label") Witness Version - div(class="summary-table-content monospace") #{result.validateaddress.witness_version} + if (result.validateaddress.hasOwnProperty("witness_version")) + div(class="row") + div(class="summary-split-table-label") Witness Version + div(class="summary-split-table-content monospace") #{result.validateaddress.witness_version} - if (result.validateaddress.witness_program) - div(class="row") - div(class="summary-table-label") Witness Program - div(class="summary-table-content monospace") #{result.validateaddress.witness_program} + if (result.validateaddress.witness_program) + div(class="row") + div(class="summary-split-table-label") Witness Program + div(class="summary-split-table-content monospace") #{result.validateaddress.witness_program} - div(class="row") - div(class="summary-table-label") QR Code - div(class="summary-table-content monospace") - img(src=addressQrCodeUrl, alt=address, style="border: solid 1px #ccc;") + if (balance) + if (balance.conflictedResults) + div(class="row") + div(class="summary-split-table-label") Balance + div(class="summary-split-table-content monospace") + span(class="text-danger") Conflicted ElectrumX Results + include includes/electrum-trust-note.pug - div(class="card mb-3") - div(class="card-header") - span(class="h6") Flags - div(class="card-body") - table(class="table table-responsive-sm text-center") - thead - tr - th Is Valid? - th Is Script? - th Is Witness? - th Is Mine? - th Is Watch-Only? - tbody - tr - - var x = result.validateaddress; - - var flags = [x.isvalid, x.isscript, x.iswitness, x.ismine, x.iswatchonly]; - - each flag in flags - td - if (flag) + each item in balance.conflictedResults + - var currencyValue = item.confirmed / 100000000; + include includes/value-display.pug + + + else + if (balance.confirmed) + div(class="row") + div(class="summary-split-table-label") Balance + div(class="summary-split-table-content monospace") + - var currencyValue = balance.confirmed / 100000000; + include includes/value-display.pug + include includes/electrum-trust-note.pug + + if (balance.unconfirmed) + div(class="row") + div(class="summary-split-table-label") Unconfirmed + div(class="summary-split-table-content monospace") + - var currencyValue = balance.unconfirmed / 100000000; + include includes/value-display.pug + include includes/electrum-trust-note.pug + + if (electrumHistory) + div(class="row") + div(class="summary-split-table-label") Transactions + div(class="summary-split-table-content monospace") #{electrumHistory.length.toLocaleString()} + include includes/electrum-trust-note.pug + + div(class="row") + div(class="summary-split-table-label") QR Code + div(class="summary-split-table-content monospace") + img(src=addressQrCodeUrl, alt=address, style="border: solid 1px #ccc;") + + div(class="col-md-6") + - var x = result.validateaddress; + - var flagNames = ["Is Valid?", "Is Script?", "Is Witness?", "Is Mine?", "Is Watch-Only?"]; + - var flags = [x.isvalid, x.isscript, x.iswitness, x.ismine, x.iswatchonly]; + + each flagName, index in flagNames + div(class="row") + div(class="summary-split-table-label") #{flagName} + div(class="summary-split-table-content monospace") + if (flags[index]) i(class="fas fa-check text-success") else i(class="fas fa-times text-danger") + if (false) + div(class="card mb-3") + div(class="card-header") + span(class="h6") Flags + div(class="card-body") + table(class="table table-responsive-sm text-center") + thead + tr + th Is Valid? + th Is Script? + th Is Witness? + th Is Mine? + th Is Watch-Only? + tbody + tr + - var x = result.validateaddress; + - var flags = [x.isvalid, x.isscript, x.iswitness, x.ismine, x.iswatchonly]; + + each flag in flags + td + if (flag) + i(class="fas fa-check text-success") + else + i(class="fas fa-times text-danger") + div(class="card") div(class="card-header") span(class="h6") Transactions + if (transactions) + include includes/electrum-trust-note.pug div(class="card-body") - table(class="table") - strong - p(class="text-warning") This is a work-in-progress - p Since this app is database-free, displaying a list of transactions involving the current address is tricky. I'm actively researching the best way to implement this. + if (transactions) + each tx, txIndex in transactions + //pre + // code #{JSON.stringify(tx, null, 4)} + div(class="xcard mb-3") + div(class="card-header monospace clearfix") + div(class="float-left", style="margin-right: 10px;") + span ##{(offset + txIndex + 1).toLocaleString()} + span – + div(class="float-left") + if (tx && tx.txid) + if (tx.time) + span #{moment.utc(new Date(tx["time"] * 1000)).format("Y-MM-DD HH:mm:ss")} utc + - var timeAgoTime = tx.time; + include includes/time-ago.pug - a(href="https://github.com/janoside/btc-rpc-explorer/issues/8") Suggestions and/or pull requests are welcome! + else + span(class="text-danger") Unconfirmed + + br + a(href=("/tx/" + tx.txid)) #{tx.txid} + br + + if (addrGainsByTx[tx.txid]) + - var currencyValue = addrGainsByTx[tx.txid]; + span(class="text-success") + + include includes/value-display.pug + + if (addrLossesByTx[tx.txid]) + span / + + if (addrLossesByTx[tx.txid]) + - var currencyValue = addrLossesByTx[tx.txid]; + span(class="text-danger") - + include includes/value-display.pug + + if (global.specialTransactions && global.specialTransactions[tx.txid]) + span + a(data-toggle="tooltip", title=(coinConfig.name + " Fun! See transaction for details")) + i(class="fas fa-certificate text-primary") + + div(class="card-body") + if (true) + - var txInputs = txInputsByTransaction[tx.txid]; + - var blockHeight = blockHeightsByTxid[tx.txid]; + + include includes/transaction-io-details.pug + else + p Since this explorer is database-free, it doesn't natively support address transaction history. However, you can configure it to communicate with one or more ElectrumX servers to build and display this data. In doing so, you should be aware that you'll be trusting those ElectrumX servers. If you configure multiple servers the results obtained from each will be cross-referenced against the others. Communicating with ElectrumX servers will also impact your privacy since the servers will know what addresses you're interested in. If these tradeoffs are acceptable, you can see a list of public ElectrumX servers here: + a(href="https://uasf.saltylemon.org/electrum") https://uasf.saltylemon.org/electrum + + if (false) + pre + code #{JSON.stringify(transactions, null, 4)} + + - var pageNumber = offset / limit + 1; + - var pageCount = Math.floor(txids.length / limit); + - if (pageCount * limit < txids.length) { + - pageCount++; + - } + - var paginationUrlFunction = function(x) { + - return paginationBaseUrl + "?limit=" + limit + "&offset=" + ((x - 1) * limit); + - } + + hr + + include includes/pagination.pug + + div(id="tab-json", class="tab-pane", role="tabpanel") div(class="highlight") + h4 Node.ValidateAddress pre code(class="language-json", data-lang="json") #{JSON.stringify(result.validateaddress, null, 4)} + h4 Electrum.Balance + pre + code(class="language-json", data-lang="json") #{JSON.stringify(electrumBalance, null, 4)} + + h4 Electrum.History + pre + code(class="language-json", data-lang="json") #{JSON.stringify(electrumHistory, null, 4)} + diff --git a/views/includes/electrum-trust-note.pug b/views/includes/electrum-trust-note.pug new file mode 100644 index 0000000..03dbe4c --- /dev/null +++ b/views/includes/electrum-trust-note.pug @@ -0,0 +1,3 @@ +span +span(data-toggle="tooltip", title="This data is at least partially generated from the ElectrumX servers currently configured: ") + i(class="fas fa-exclamation-triangle text-warning") \ No newline at end of file