Changeset View
Changeset View
Standalone View
Standalone View
apps/ecash-herald/src/parse.js
// Copyright (c) 2023 The Bitcoin developers | // Copyright (c) 2023 The Bitcoin developers | ||||
// Distributed under the MIT software license, see the accompanying | // Distributed under the MIT software license, see the accompanying | ||||
// file COPYING or http://www.opensource.org/licenses/mit-license.php. | // file COPYING or http://www.opensource.org/licenses/mit-license.php. | ||||
'use strict'; | 'use strict'; | ||||
const config = require('../config'); | const config = require('../config'); | ||||
const cashaddr = require('ecashaddrjs'); | |||||
const { prepareStringForTelegramHTML } = require('./telegram'); | const { prepareStringForTelegramHTML } = require('./telegram'); | ||||
const { formatPrice } = require('./utils'); | const { formatPrice, returnAddressPreview } = require('./utils'); | ||||
module.exports = { | module.exports = { | ||||
parseBlock: function (chronikBlockResponse) { | parseBlock: function (chronikBlockResponse) { | ||||
const { blockInfo, txs } = chronikBlockResponse; | const { blockInfo, txs } = chronikBlockResponse; | ||||
const { hash } = blockInfo; | const { hash } = blockInfo; | ||||
const { height, numTxs } = blockInfo; | const { height, numTxs } = blockInfo; | ||||
// Parse coinbase string | // Parse coinbase string | ||||
const coinbaseScript = txs[0].inputs[0].inputScript; | const coinbaseScript = txs[0].inputs[0].inputScript; | ||||
Show All 20 Lines | getMinerFromCoinbase: function (coinbaseHexString) { | ||||
return miner; | return miner; | ||||
}, | }, | ||||
parseTx: function (tx) { | parseTx: function (tx) { | ||||
/* Parse an eCash tx as returned by chronik for newsworthy information | /* Parse an eCash tx as returned by chronik for newsworthy information | ||||
* returns | * returns | ||||
* { txid, genesisInfo, opReturnInfo } | * { txid, genesisInfo, opReturnInfo } | ||||
*/ | */ | ||||
const { txid, outputs } = tx; | const { txid, inputs, outputs, size } = tx; | ||||
let isTokenTx = false; | let isTokenTx = false; | ||||
let genesisInfo = false; | let genesisInfo = false; | ||||
let opReturnInfo = false; | let opReturnInfo = false; | ||||
/* Collect xecSendInfo for all txs, since all txs are XEC sends | |||||
* You may later want to render xecSendInfo for tokenSends, appTxs, etc, | |||||
* maybe on special conditions, e.g.a token send tx that also sends a bunch of xec | |||||
*/ | |||||
// xecSend parsing variables | |||||
let xecSendingOutputScripts = new Set(); | |||||
let xecReceivingOutputs = new Map(); | |||||
let xecChangeOutputs = new Map(); | |||||
let xecInputAmountSats = 0; | |||||
let xecOutputAmountSats = 0; | |||||
if (tx.slpTxData !== null && typeof tx.slpTxData !== 'undefined') { | if (tx.slpTxData !== null && typeof tx.slpTxData !== 'undefined') { | ||||
isTokenTx = true; | isTokenTx = true; | ||||
if ( | if ( | ||||
tx.slpTxData.slpMeta !== null && | tx.slpTxData.slpMeta !== null && | ||||
typeof tx.slpTxData.slpMeta !== 'undefined' && | typeof tx.slpTxData.slpMeta !== 'undefined' && | ||||
tx.slpTxData.genesisInfo !== null && | tx.slpTxData.genesisInfo !== null && | ||||
typeof tx.slpTxData.genesisInfo !== 'undefined' && | typeof tx.slpTxData.genesisInfo !== 'undefined' && | ||||
tx.slpTxData.slpMeta.txType === 'GENESIS' | tx.slpTxData.slpMeta.txType === 'GENESIS' | ||||
) { | ) { | ||||
genesisInfo = tx.slpTxData.genesisInfo; | genesisInfo = tx.slpTxData.genesisInfo; | ||||
} | } | ||||
} | } | ||||
for (let i in inputs) { | |||||
const thisInput = inputs[i]; | |||||
xecSendingOutputScripts.add(thisInput.outputScript); | |||||
xecInputAmountSats += parseInt(thisInput.value); | |||||
} | |||||
// Iterate over outputs to check for OP_RETURN msgs | // Iterate over outputs to check for OP_RETURN msgs | ||||
for (let i = 0; i < outputs.length; i += 1) { | for (let i = 0; i < outputs.length; i += 1) { | ||||
const thisOutput = outputs[i]; | const thisOutput = outputs[i]; | ||||
const { value } = thisOutput; | const value = parseInt(thisOutput.value); | ||||
xecOutputAmountSats += value; | |||||
// If this output script is the same as one of the sendingOutputScripts | |||||
if (xecSendingOutputScripts.has(thisOutput.outputScript)) { | |||||
// Then this XEC amount is change | |||||
// Add outputScript and value to map | |||||
// If this outputScript is already in xecChangeOutputs, increment its value | |||||
xecChangeOutputs.set( | |||||
thisOutput.outputScript, | |||||
(xecChangeOutputs.get(thisOutput.outputScript) ?? 0) + | |||||
value, | |||||
); | |||||
} else { | |||||
// Add an xecReceivingOutput | |||||
// Add outputScript and value to map | |||||
// If this outputScript is already in xecReceivingOutputs, increment its value | |||||
xecReceivingOutputs.set( | |||||
thisOutput.outputScript, | |||||
(xecReceivingOutputs.get(thisOutput.outputScript) ?? 0) + | |||||
value, | |||||
); | |||||
} | |||||
// Don't parse OP_RETURN values of etoken txs, this info is available from chronik | // Don't parse OP_RETURN values of etoken txs, this info is available from chronik | ||||
if (value === '0' && !isTokenTx) { | if ( | ||||
const { outputScript } = thisOutput; | thisOutput.outputScript.startsWith( | ||||
opReturnInfo = module.exports.parseOpReturn(outputScript); | config.opReturn.opReturnPrefix, | ||||
) && | |||||
!isTokenTx | |||||
) { | |||||
opReturnInfo = module.exports.parseOpReturn( | |||||
thisOutput.outputScript, | |||||
); | |||||
} | } | ||||
} | } | ||||
return { txid, genesisInfo, opReturnInfo }; | // Determine tx fee | ||||
const txFee = xecInputAmountSats - xecOutputAmountSats; | |||||
const satsPerByte = txFee / size; | |||||
return { | |||||
txid, | |||||
genesisInfo, | |||||
opReturnInfo, | |||||
satsPerByte, | |||||
xecSendingOutputScripts, | |||||
xecChangeOutputs, | |||||
xecReceivingOutputs, | |||||
}; | |||||
}, | }, | ||||
parseOpReturn: function (outputScript) { | parseOpReturn: function (outputScript) { | ||||
// Initialize required vars | // Initialize required vars | ||||
let app; | let app; | ||||
let msg; | let msg; | ||||
// Determine if this is an OP_RETURN field | // Determine if this is an OP_RETURN field | ||||
const isOpReturn = | const isOpReturn = | ||||
▲ Show 20 Lines • Show All 129 Lines • ▼ Show 20 Lines | module.exports = { | ||||
}, | }, | ||||
getBlockTgMessage: function (parsedBlock, coingeckoPrices) { | getBlockTgMessage: function (parsedBlock, coingeckoPrices) { | ||||
const { hash, height, miner, numTxs, parsedTxs } = parsedBlock; | const { hash, height, miner, numTxs, parsedTxs } = parsedBlock; | ||||
// Define newsworthy types of txs in parsedTxs | // Define newsworthy types of txs in parsedTxs | ||||
// These arrays will be used to present txs in batches by type | // These arrays will be used to present txs in batches by type | ||||
const genesisTxTgMsgLines = []; | const genesisTxTgMsgLines = []; | ||||
const opReturnTxTgMsgLines = []; | const opReturnTxTgMsgLines = []; | ||||
const xecSendTxTgMsgLines = []; | |||||
// Iterate over parsedTxs to find anything newsworthy | // Iterate over parsedTxs to find anything newsworthy | ||||
for (let i = 0; i < parsedTxs.length; i += 1) { | for (let i = 0; i < parsedTxs.length; i += 1) { | ||||
const thisParsedTx = parsedTxs[i]; | const thisParsedTx = parsedTxs[i]; | ||||
const { txid, genesisInfo, opReturnInfo } = thisParsedTx; | const { | ||||
txid, | |||||
genesisInfo, | |||||
opReturnInfo, | |||||
satsPerByte, | |||||
xecSendingOutputScripts, | |||||
xecChangeOutputs, | |||||
xecReceivingOutputs, | |||||
} = thisParsedTx; | |||||
if (genesisInfo) { | if (genesisInfo) { | ||||
// The txid of a genesis tx is the tokenId | // The txid of a genesis tx is the tokenId | ||||
const tokenId = txid; | const tokenId = txid; | ||||
let { tokenTicker, tokenName, tokenDocumentUrl } = genesisInfo; | let { tokenTicker, tokenName, tokenDocumentUrl } = genesisInfo; | ||||
// Make sure tokenName does not contain telegram html escape characters | // Make sure tokenName does not contain telegram html escape characters | ||||
tokenName = prepareStringForTelegramHTML(tokenName); | tokenName = prepareStringForTelegramHTML(tokenName); | ||||
// Make sure tokenName does not contain telegram html escape characters | // Make sure tokenName does not contain telegram html escape characters | ||||
tokenTicker = prepareStringForTelegramHTML(tokenTicker); | tokenTicker = prepareStringForTelegramHTML(tokenTicker); | ||||
Show All 11 Lines | getBlockTgMessage: function (parsedBlock, coingeckoPrices) { | ||||
// Make sure the OP_RETURN msg does not contain telegram html escape characters | // Make sure the OP_RETURN msg does not contain telegram html escape characters | ||||
msg = prepareStringForTelegramHTML(msg); | msg = prepareStringForTelegramHTML(msg); | ||||
opReturnTxTgMsgLines.push( | opReturnTxTgMsgLines.push( | ||||
`<a href="${config.blockExplorer}/tx/${txid}">${app}:</a> ${msg}`, | `<a href="${config.blockExplorer}/tx/${txid}">${app}:</a> ${msg}`, | ||||
); | ); | ||||
// This parsed tx has a tg msg line. Move on to the next one. | // This parsed tx has a tg msg line. Move on to the next one. | ||||
continue; | continue; | ||||
} | } | ||||
// Txs not parsed above are parsed as xec send txs | |||||
/* We do the totalSatsSent calculation here in getBlockTgMsg and not above in parseTx | |||||
* as it is only necessary to do for rendered txs | |||||
*/ | |||||
let totalSatsSent = 0; | |||||
for (const satoshis of xecReceivingOutputs.values()) { | |||||
totalSatsSent += satoshis; | |||||
} | |||||
// Convert sats to XEC. Round as decimals will not be rendered in msgs. | |||||
const totalXecSent = parseFloat((totalSatsSent / 100).toFixed(0)); | |||||
// Clone xecReceivingOutputs so that you don't modify unit test mocks | |||||
let xecReceivingAddressOutputs = new Map(xecReceivingOutputs); | |||||
// Throw out OP_RETURN outputs for txs parsed as XEC send txs | |||||
xecReceivingAddressOutputs.forEach((value, key, map) => { | |||||
if (key.startsWith(config.opReturn.opReturnPrefix)) { | |||||
map.delete(key); | |||||
} | |||||
}); | |||||
let xecSendMsg; | |||||
if (xecReceivingAddressOutputs.size === 0) { | |||||
// self send tx | |||||
let changeAmountSats = 0; | |||||
for (const satoshis of xecChangeOutputs.values()) { | |||||
changeAmountSats += satoshis; | |||||
} | |||||
// Convert sats to XEC. | |||||
const changeAmountXec = parseFloat(changeAmountSats / 100); | |||||
xecSendMsg = `${xecSendingOutputScripts.size} ${ | |||||
xecSendingOutputScripts.size > 1 ? 'addresses' : 'address' | |||||
} <a href="${ | |||||
config.blockExplorer | |||||
}/tx/${txid}">sent</a> ${changeAmountXec} XEC to ${ | |||||
xecSendingOutputScripts.size > 1 ? 'themselves' : 'itself' | |||||
}`; | |||||
} else { | |||||
xecSendMsg = `${returnAddressPreview( | |||||
cashaddr.encodeOutputScript( | |||||
xecSendingOutputScripts.values().next().value, | |||||
), | |||||
)} <a href="${ | |||||
config.blockExplorer | |||||
}/tx/${txid}">sent</a> ${totalXecSent.toLocaleString('en-US', { | |||||
minimumFractionDigits: 0, | |||||
})} XEC to ${ | |||||
xecReceivingAddressOutputs.keys().next().value === | |||||
xecSendingOutputScripts.values().next().value | |||||
? 'itself' | |||||
: returnAddressPreview( | |||||
cashaddr.encodeOutputScript( | |||||
xecReceivingAddressOutputs.keys().next() | |||||
.value, | |||||
), | |||||
) | |||||
}${ | |||||
xecReceivingAddressOutputs.size > 1 | |||||
? ` and ${xecReceivingAddressOutputs.size - 1} others` | |||||
: '' | |||||
} | ${satsPerByte.toFixed(2)} sats per byte`; | |||||
} | |||||
xecSendTxTgMsgLines.push(xecSendMsg); | |||||
} | } | ||||
// Build up message as an array, with each line as an entry | // Build up message as an array, with each line as an entry | ||||
let tgMsg = []; | let tgMsg = []; | ||||
// Header | // Header | ||||
// <height> | <numTxs> | <miner> | // <height> | <numTxs> | <miner> | ||||
tgMsg.push( | tgMsg.push( | ||||
▲ Show 20 Lines • Show All 43 Lines • ▼ Show 20 Lines | getBlockTgMessage: function (parsedBlock, coingeckoPrices) { | ||||
tgMsg.push(`App tx${opReturnTxTgMsgLines.length > 1 ? `s` : ''}:`); | tgMsg.push(`App tx${opReturnTxTgMsgLines.length > 1 ? `s` : ''}:`); | ||||
// <appName> : <parsedAppData> | // <appName> : <parsedAppData> | ||||
// alias: newlyregisteredalias | // alias: newlyregisteredalias | ||||
// Cashtab Msg: This is a Cashtab Msg | // Cashtab Msg: This is a Cashtab Msg | ||||
tgMsg = tgMsg.concat(opReturnTxTgMsgLines); | tgMsg = tgMsg.concat(opReturnTxTgMsgLines); | ||||
} | } | ||||
// XEC txs | |||||
if (xecSendTxTgMsgLines.length > 0) { | |||||
// Line break for new section | |||||
tgMsg.push(''); | |||||
// App txs: | |||||
// or | |||||
// App tx: | |||||
tgMsg.push( | |||||
`${xecSendTxTgMsgLines.length} eCash tx${ | |||||
xecSendTxTgMsgLines.length > 1 ? `s` : '' | |||||
}:`, | |||||
); | |||||
tgMsg = tgMsg.concat(xecSendTxTgMsgLines); | |||||
} | |||||
// Join array with newLine char, \n | // Join array with newLine char, \n | ||||
return tgMsg.join('\n'); | return tgMsg.join('\n'); | ||||
}, | }, | ||||
}; | }; |