diff --git a/app/api/electrumAddressApi.js b/app/api/electrumAddressApi.js index fd9c9818f..589e2c964 100644 --- a/app/api/electrumAddressApi.js +++ b/app/api/electrumAddressApi.js @@ -353,6 +353,58 @@ function lookupTxBlockHash(txid) { }); } +// Lookup the spending transaction and height of a given transaction output. Only works with Electrum 1.5 protocol, ElectRS 0.9.0 does not implement subscriptions +// but can return the transaction spending the given outpoint +function lookupOutpointsTx(outpoints) { + if (electrumClients.length == 0) { + return Promise.reject({ error: "Not supported by Electrum 1.4", userText: noConnectionsErrorText }); + } + + if (outpoints.length == 0) { + return Promise.resolve(null); + } + return runOnAllServers(function(electrumClient) { + return new Promise((resolve, reject) => { + let contents = []; + let arguments_far_calls = {}; + for (let outpoint of outpoints) { + const id = ++electrumClient.id; + const request = utils.makeRequest('blockchain.outpoint.subscribe', outpoint, id); + contents.push(request); + arguments_far_calls[id] = outpoint; + } + const content = '[' + contents.join(',') + ']'; + const promise = utils.createPromiseResultBatch(resolve, reject, arguments_far_calls); + electrumClient.callback_message_queue[electrumClient.id] = promise; + electrumClient.conn.write(content + '\n'); + }); + }).then(function(results) { + runOnAllServers(function(electrumClient) { + return new Promise((resolve, reject) => { + let contents = []; + let arguments_far_calls = {}; + for (let outpoint of outpoints) { + const id = ++electrumClient.id; + const request = utils.makeRequest('blockchain.outpoint.unsubscribe', outpoint, id); + contents.push(request); + arguments_far_calls[id] = outpoint; + } + const content = '[' + contents.join(',') + ']'; + const promise = utils.createPromiseResultBatch(resolve, reject, arguments_far_calls); + electrumClient.callback_message_queue[electrumClient.id] = promise; + electrumClient.conn.write(content + '\n'); + }); + }); + const spend_infos = results[0].result; + if (results.slice(1).every(({ result }) => result == spend_infos)) { + return spend_infos; + } else { + return Promise.reject({conflictedResults:results}); + } + }); +} + + function logStats(cmd, dt, success) { if (!global.electrumStats.rpc[cmd]) { global.electrumStats.rpc[cmd] = {count:0, time:0, successes:0, failures:0}; @@ -380,5 +432,6 @@ module.exports = { connectToServers: connectToServers, getAddressDetails: getAddressDetails, lookupTxBlockHash: lookupTxBlockHash, + lookupOutpointsTx: lookupOutpointsTx, }; diff --git a/app/utils.js b/app/utils.js index 1f8252d6e..a84ea98bc 100644 --- a/app/utils.js +++ b/app/utils.js @@ -1167,6 +1167,28 @@ function bip32Addresses(extPubkey, addressType, account, limit=10, offset=0) { return addresses; } +function createPromiseResultBatch(resolve, reject, argz) { + return (err, result) => { + if (result && result[0] && result[0].id) { + // this is a batch request response + for (let r of result) { + r.param = argz[r.id]; + } + } + if (err) reject(err); + else resolve(result); + }; +}; + +function makeRequest(method, params, id) { + return JSON.stringify({ + jsonrpc: '2.0', + method: method, + params: params, + id: id, + }); +}; + module.exports = { reflectPromise: reflectPromise, redirectToConnectPageIfNeeded: redirectToConnectPageIfNeeded, @@ -1220,5 +1242,7 @@ module.exports = { getVoutAddress: getVoutAddress, getVoutAddresses: getVoutAddresses, xpubChangeVersionBytes: xpubChangeVersionBytes, - bip32Addresses: bip32Addresses + bip32Addresses: bip32Addresses, + createPromiseResultBatch: createPromiseResultBatch, + makeRequest: makeRequest, }; diff --git a/btc-rpc-explorer-node-details.png b/btc-rpc-explorer-node-details.png new file mode 100644 index 000000000..2fff416b1 Binary files /dev/null and b/btc-rpc-explorer-node-details.png differ diff --git a/routes/baseRouter.js b/routes/baseRouter.js index 24ae04d31..a5a9abd8e 100644 --- a/routes/baseRouter.js +++ b/routes/baseRouter.js @@ -26,6 +26,7 @@ const coins = require("./../app/coins.js"); const config = require("./../app/config.js"); const coreApi = require("./../app/api/coreApi.js"); const addressApi = require("./../app/api/addressApi.js"); +const electrumAddressApi = require("./../app/api/electrumAddressApi.js"); const rpcApi = require("./../app/api/rpcApi.js"); const btcQuotes = require("./../app/coins/btcQuotes.js"); @@ -1362,6 +1363,61 @@ router.get("/tx/:transactionId@:blockHeight", asyncHandler(async (req, res, next next(); })); +router.get("/outpoint/:transactionId-:vout", asyncHandler(async (req, res, next) => { + if ((config.addressApi == "electrum" || config.addressApi == "electrumx") && config.electrumTxIndex) { + try { + var txid = utils.asHash(req.params.transactionId); + var vout = parseInt(req.params.vout); + var outpoint = [txid, vout]; + + const spent_outpoints = await electrumAddressApi.lookupOutpointsTx([outpoint]); + const spending_tx = spent_outpoints[0].result; + + if (spending_tx.spender_txhash == undefined) { + res.locals.userMessageMarkdown = `Outpoint **${txid}:${vout}** unspent`; + req.url = "/tx/" + req.params.transactionId + `#output-${vout}`; + next(); + } else { + var diff_height = "in the mempool"; + if (spending_tx.spender_height > 0) { + diff_height = `after ${spending_tx.spender_height - spending_tx.height} block` + if (spending_tx.spender_height - spending_tx.height > 1) { + diff_height += `s` + } + } + res.locals.userMessageMarkdown = `Outpoint **${txid}:${vout}** spent by this transaction ` + diff_height; + req.url = "/tx/" + spending_tx.spender_txhash; + next(); + } + } catch (err) { + if (global.prunedBlockchain && res.locals.blockHeight && res.locals.blockHeight < global.pruneHeight) { + // Failure to load tx here is expected and a full description of the situation is given to the user + // in the UI. No need to also show an error userMessage here. + + } else if (!global.txindexAvailable) { + res.locals.noTxIndexMsg = noTxIndexMsg; + + // As above, failure to load the tx is expected here and good user feedback is given in the UI. + // No need for error userMessage. + + } else { + res.locals.userMessageMarkdown = `Outpoint not found: txid=**${txid}**, vout=**${vout}**`; + } + + + + utils.logError("1237y4ewssgt", err); + + res.render("transaction"); + + next(); + } + } else { + req.url = "/tx/" + req.params.transactionId; + + next(); + } +})); router.get("/tx/:transactionId", asyncHandler(async (req, res, next) => { try { @@ -1433,6 +1489,32 @@ router.get("/tx/:transactionId", asyncHandler(async (req, res, next) => { await Promise.all(promises); + // Electrs 0.9.0 support spending transaction lookup for an outpoint + if ((config.addressApi == "electrum" || config.addressApi == "electrumx") && config.electrumTxIndex) { + if (res.locals.utxos.length > 200) { + res.locals.spendings = null; + } else { + let outpoints = []; + for (const vout in tx.vout) { + if (res.locals.utxos[vout] == null) { + outpoints.push([txid, parseInt(vout)]); + } + } + const spent_outpoints = await electrumAddressApi.lookupOutpointsTx(outpoints); + let spendings_status = []; + let spent_idx = 0; + for (const vout in tx.vout) { + if (res.locals.utxos[vout] == null) { + spendings_status.push(spent_outpoints[spent_idx].result); + ++spent_idx; + } else { + spendings_status.push(false) + } + } + res.locals.spendings = spendings_status + } + } + if (global.specialTransactions && global.specialTransactions[txid]) { let funInfo = global.specialTransactions[txid]; diff --git a/views/includes/transaction-io-details.pug b/views/includes/transaction-io-details.pug index 8091cd7cd..523667e2b 100644 --- a/views/includes/transaction-io-details.pug +++ b/views/includes/transaction-io-details.pug @@ -241,6 +241,10 @@ mixin outputValueDisplay(vout, voutIndex) - hiddenRow = true; - hiddenRowCount++; + - var spent = false; + if (false && config.electrumServers && config.electrumServers.length > 0) + - spent = electrumAddressApi.lookupOutpointTx(vout, voutIndex) + div(data-txid=tx.txid, class=(hiddenRow ? "d-none" : "")) .clearfix.mb-0.mb-sm-2.mb-md-0 a.xs-hidden.d-none.d-sm-inline.badge.card-highlight.border.text-decoration-none.fw-normal.me-2(data-bs-toggle="tooltip", title=`Output #${voutIndex.toLocaleString()}`, style="white-space: nowrap;") @@ -275,7 +279,23 @@ mixin outputValueDisplay(vout, voutIndex) span.d-none.d-sm-inline +darkBadge span(title=`Output Type: ${utils.outputTypeName(vout.scriptPubKey.type)}`, data-bs-toggle="tooltip") #{utils.outputTypeAbbreviation(vout.scriptPubKey.type)} - + if (spendings === undefined) + span.mt-1 + a(href=`./outpoint/${tx.txid}-${voutIndex}`) Outpoint Status + else if (spendings) + - var spending_vout = spendings[voutIndex]; + if (spending_vout && spending_vout.spender_txhash) + span.mt-1 spent by + a(href=`./tx/${spending_vout.spender_txhash}`) #{utils.ellipsizeMiddle(spending_vout.spender_txhash,9)} + if (spending_vout.spender_height > 0 && spending_vout.spender_height > spending_vout.height) + span #{spending_vout.spender_height - spending_vout.height} blocks later + else if (spending_vout.spender_height <= 0) + span in the mempool + else + span in the same block + else if (spendings === null && utxos[voutIndex] == null) + span.mt-1 spent by + a(href=`./outpoint/${txid}-${voutIndex}`) this transaction if (voutAddresses.length == 0) @@ -294,7 +314,6 @@ mixin outputValueDisplay(vout, voutIndex) +darkBadge span(title=`Output Type: ${utils.outputTypeName(vout.scriptPubKey.type)}`, data-bs-toggle="tooltip") #{utils.outputTypeAbbreviation(vout.scriptPubKey.type)} - span.ms-2.float-end +outputValueDisplay(vout, voutIndex)