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 cashaddr = require('ecashaddrjs'); | ||||
const BigNumber = require('bignumber.js'); | |||||
const { | const { | ||||
prepareStringForTelegramHTML, | prepareStringForTelegramHTML, | ||||
splitOverflowTgMsg, | splitOverflowTgMsg, | ||||
} = require('./telegram'); | } = require('./telegram'); | ||||
const { formatPrice, returnAddressPreview } = 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; | ||||
const miner = module.exports.getMinerFromCoinbase(coinbaseScript); | const miner = module.exports.getMinerFromCoinbase(coinbaseScript); | ||||
// Start with i=1 to skip Coinbase tx | // Start with i=1 to skip Coinbase tx | ||||
const parsedTxs = []; | const parsedTxs = []; | ||||
for (let i = 1; i < txs.length; i += 1) { | for (let i = 1; i < txs.length; i += 1) { | ||||
parsedTxs.push(module.exports.parseTx(txs[i])); | parsedTxs.push(module.exports.parseTx(txs[i])); | ||||
} | } | ||||
return { hash, height, miner, numTxs, parsedTxs }; | |||||
// Collect token info needed to parse token send txs | |||||
const tokenIds = new Set(); // we only need each tokenId once | |||||
for (let i = 0; i < parsedTxs.length; i += 1) { | |||||
const thisParsedTx = parsedTxs[i]; | |||||
if (thisParsedTx.tokenSendInfo) { | |||||
tokenIds.add(thisParsedTx.tokenSendInfo.tokenId); | |||||
} | |||||
} | |||||
return { hash, height, miner, numTxs, parsedTxs, tokenIds }; | |||||
}, | }, | ||||
getMinerFromCoinbase: function (coinbaseHexString) { | getMinerFromCoinbase: function (coinbaseHexString) { | ||||
const knownMiners = config.knownMiners; | const knownMiners = config.knownMiners; | ||||
let miner = 'unknown'; | let miner = 'unknown'; | ||||
// Iterate over known miners to find a match | // Iterate over known miners to find a match | ||||
for (let i = 0; i < knownMiners.length; i += 1) { | for (let i = 0; i < knownMiners.length; i += 1) { | ||||
const testedMiner = knownMiners[i]; | const testedMiner = knownMiners[i]; | ||||
const { coinbaseScript } = testedMiner; | const { coinbaseScript } = testedMiner; | ||||
Show All 10 Lines | parseTx: function (tx) { | ||||
*/ | */ | ||||
const { txid, inputs, outputs, size } = 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; | ||||
/* Token send parsing info | |||||
* | |||||
* Note that token send amounts received from chronik do not account for | |||||
* token decimals. Decimalized amounts require token genesisInfo | |||||
* decimals param to calculate | |||||
*/ | |||||
/* tokenSendInfo | |||||
* `false` for txs that are not etoken send txs | |||||
* an object containing info about the token send for token send txs | |||||
*/ | |||||
let tokenSendInfo = false; | |||||
let tokenSendingOutputScripts = new Set(); | |||||
let tokenReceivingOutputs = new Map(); | |||||
let tokenChangeOutputs = new Map(); | |||||
let undecimalizedTokenInputAmount = new BigNumber(0); | |||||
/* Collect xecSendInfo for all txs, since all txs are XEC sends | /* Collect xecSendInfo for all txs, since all txs are XEC sends | ||||
* You may later want to render xecSendInfo for tokenSends, appTxs, etc, | * 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 | * maybe on special conditions, e.g.a token send tx that also sends a bunch of xec | ||||
*/ | */ | ||||
// xecSend parsing variables | // xecSend parsing variables | ||||
let xecSendingOutputScripts = new Set(); | let xecSendingOutputScripts = new Set(); | ||||
let xecReceivingOutputs = new Map(); | let xecReceivingOutputs = new Map(); | ||||
let xecChangeOutputs = new Map(); | let xecChangeOutputs = new Map(); | ||||
let xecInputAmountSats = 0; | let xecInputAmountSats = 0; | ||||
let xecOutputAmountSats = 0; | let xecOutputAmountSats = 0; | ||||
if (tx.slpTxData !== null && typeof tx.slpTxData !== 'undefined') { | if (tx.slpTxData !== null && typeof tx.slpTxData !== 'undefined') { | ||||
isTokenTx = true; | isTokenTx = true; | ||||
// Determine if this is an etoken genesis tx | |||||
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; | ||||
} | } | ||||
// Determine if this is an etoken send tx | |||||
if ( | |||||
tx.slpTxData.slpMeta !== null && | |||||
typeof tx.slpTxData.slpMeta !== 'undefined' && | |||||
tx.slpTxData.slpMeta.txType === 'SEND' | |||||
) { | |||||
// Initialize tokenSendInfo as an object with the sent tokenId | |||||
tokenSendInfo = { tokenId: tx.slpTxData.slpMeta.tokenId }; | |||||
} | |||||
} | } | ||||
for (let i in inputs) { | for (let i in inputs) { | ||||
const thisInput = inputs[i]; | const thisInput = inputs[i]; | ||||
xecSendingOutputScripts.add(thisInput.outputScript); | xecSendingOutputScripts.add(thisInput.outputScript); | ||||
xecInputAmountSats += parseInt(thisInput.value); | xecInputAmountSats += parseInt(thisInput.value); | ||||
// The input that sent the token utxos will have key 'slpToken' | |||||
if (typeof thisInput.slpToken !== 'undefined') { | |||||
// Add amount to undecimalizedTokenInputAmount | |||||
undecimalizedTokenInputAmount = | |||||
undecimalizedTokenInputAmount.plus( | |||||
thisInput.slpToken.amount, | |||||
); | |||||
// Collect the input outputScripts to identify change output | |||||
tokenSendingOutputScripts.add(thisInput.outputScript); | |||||
} | |||||
} | } | ||||
// 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 = parseInt(thisOutput.value); | const value = parseInt(thisOutput.value); | ||||
xecOutputAmountSats += value; | xecOutputAmountSats += value; | ||||
// If this output script is the same as one of the sendingOutputScripts | // If this output script is the same as one of the sendingOutputScripts | ||||
Show All 24 Lines | parseTx: function (tx) { | ||||
config.opReturn.opReturnPrefix, | config.opReturn.opReturnPrefix, | ||||
) && | ) && | ||||
!isTokenTx | !isTokenTx | ||||
) { | ) { | ||||
opReturnInfo = module.exports.parseOpReturn( | opReturnInfo = module.exports.parseOpReturn( | ||||
thisOutput.outputScript, | thisOutput.outputScript, | ||||
); | ); | ||||
} | } | ||||
// For etoken send txs, parse outputs for tokenSendInfo object | |||||
if (typeof thisOutput.slpToken !== 'undefined') { | |||||
// Check output script to confirm does not match tokenSendingOutputScript | |||||
if (tokenSendingOutputScripts.has(thisOutput.outputScript)) { | |||||
// change | |||||
tokenChangeOutputs.set( | |||||
thisOutput.outputScript, | |||||
( | |||||
tokenChangeOutputs.get(thisOutput.outputScript) ?? | |||||
new BigNumber(0) | |||||
).plus(thisOutput.slpToken.amount), | |||||
); | |||||
} else { | |||||
/* This is the sent token qty | |||||
* | |||||
* Add outputScript and undecimalizedTokenReceivedAmount to map | |||||
* If this outputScript is already in tokenReceivingOutputs, increment undecimalizedTokenReceivedAmount | |||||
* note that thisOutput.slpToken.amount is a string so you do not want to add it | |||||
* BigNumber library is required for token calculations | |||||
*/ | |||||
tokenReceivingOutputs.set( | |||||
thisOutput.outputScript, | |||||
( | |||||
tokenReceivingOutputs.get( | |||||
thisOutput.outputScript, | |||||
) ?? new BigNumber(0) | |||||
).plus(thisOutput.slpToken.amount), | |||||
); | |||||
} | |||||
} | |||||
} | } | ||||
// Determine tx fee | // Determine tx fee | ||||
const txFee = xecInputAmountSats - xecOutputAmountSats; | const txFee = xecInputAmountSats - xecOutputAmountSats; | ||||
const satsPerByte = txFee / size; | const satsPerByte = txFee / size; | ||||
// If this is a token send tx, return token send parsing info and not 'false' for tokenSendInfo | |||||
if (tokenSendInfo) { | |||||
tokenSendInfo.tokenChangeOutputs = tokenChangeOutputs; | |||||
tokenSendInfo.tokenReceivingOutputs = tokenReceivingOutputs; | |||||
tokenSendInfo.tokenSendingOutputScripts = tokenSendingOutputScripts; | |||||
} | |||||
return { | return { | ||||
txid, | txid, | ||||
genesisInfo, | genesisInfo, | ||||
opReturnInfo, | opReturnInfo, | ||||
satsPerByte, | satsPerByte, | ||||
xecSendingOutputScripts, | xecSendingOutputScripts, | ||||
xecChangeOutputs, | xecChangeOutputs, | ||||
xecReceivingOutputs, | xecReceivingOutputs, | ||||
tokenSendInfo, | |||||
}; | }; | ||||
}, | }, | ||||
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 | ||||
▲ Show 20 Lines • Show All 123 Lines • ▼ Show 20 Lines | parseMemoOutputScript: function (memoHexStr) { | ||||
Object.keys(config.opReturn.memo).includes(actionCode) | Object.keys(config.opReturn.memo).includes(actionCode) | ||||
? config.opReturn.memo[actionCode] | ? config.opReturn.memo[actionCode] | ||||
: '' | : '' | ||||
}|${module.exports.hexOpReturnToUtf8(memoHexStr)}`; | }|${module.exports.hexOpReturnToUtf8(memoHexStr)}`; | ||||
} | } | ||||
return { app, msg }; | return { app, msg }; | ||||
} | } | ||||
}, | }, | ||||
getBlockTgMessage: function (parsedBlock, coingeckoPrices) { | getBlockTgMessage: function (parsedBlock, coingeckoPrices, tokenInfoMap) { | ||||
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 tokenSendTxTgMsgLines = []; | |||||
const opReturnTxTgMsgLines = []; | const opReturnTxTgMsgLines = []; | ||||
const xecSendTxTgMsgLines = []; | 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 { | const { | ||||
txid, | txid, | ||||
genesisInfo, | genesisInfo, | ||||
opReturnInfo, | opReturnInfo, | ||||
satsPerByte, | satsPerByte, | ||||
xecSendingOutputScripts, | xecSendingOutputScripts, | ||||
xecChangeOutputs, | xecChangeOutputs, | ||||
xecReceivingOutputs, | xecReceivingOutputs, | ||||
tokenSendInfo, | |||||
} = thisParsedTx; | } = 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 | ||||
Show All 12 Lines | getBlockTgMessage: function (parsedBlock, coingeckoPrices, tokenInfoMap) { | ||||
// 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; | ||||
} | } | ||||
// Only parse tokenSendInfo txs if you successfuly got tokenMapInfo from chronik | |||||
if (tokenSendInfo && tokenInfoMap) { | |||||
let { | |||||
tokenId, | |||||
tokenSendingOutputScripts, | |||||
tokenChangeOutputs, | |||||
tokenReceivingOutputs, | |||||
} = tokenSendInfo; | |||||
// Get token info from tokenInfoMap | |||||
const thisTokenInfo = tokenInfoMap.get(tokenId); | |||||
let { tokenTicker, tokenName, decimals } = thisTokenInfo; | |||||
// Note: tokenDocumentUrl and tokenDocumentHash are also available from thisTokenInfo | |||||
// Make sure tokenName does not contain telegram html escape characters | |||||
tokenName = prepareStringForTelegramHTML(tokenName); | |||||
// Make sure tokenName does not contain telegram html escape characters | |||||
tokenTicker = prepareStringForTelegramHTML(tokenTicker); | |||||
// Initialize tokenSendMsg | |||||
let tokenSendMsg; | |||||
// Parse token self-send txs | |||||
if (tokenReceivingOutputs.size === 0) { | |||||
// self send tx | |||||
let undecimalizedTokenChangeAmount = new BigNumber(0); | |||||
for (const tokenChangeAmount of tokenChangeOutputs.values()) { | |||||
undecimalizedTokenChangeAmount = | |||||
undecimalizedTokenChangeAmount.plus( | |||||
tokenChangeAmount, | |||||
); | |||||
} | |||||
// Calculate true tokenChangeAmount using decimals | |||||
// Use decimals to calculate the sent amount as string | |||||
const decimalizedTokenChangeAmount = new BigNumber( | |||||
undecimalizedTokenChangeAmount, | |||||
) | |||||
.shiftedBy(-1 * decimals) | |||||
.toString(); | |||||
// Self send tokenSendMsg | |||||
tokenSendMsg = `${tokenSendingOutputScripts.size} ${ | |||||
tokenSendingOutputScripts.size > 1 | |||||
? 'addresses' | |||||
: 'address' | |||||
} <a href="${ | |||||
config.blockExplorer | |||||
}/tx/${txid}">sent</a> ${decimalizedTokenChangeAmount} <a href="${ | |||||
config.blockExplorer | |||||
}/tx/${tokenId}">${tokenTicker}</a> to ${ | |||||
tokenSendingOutputScripts.size > 1 | |||||
? 'themselves' | |||||
: 'itself' | |||||
}`; | |||||
} else { | |||||
// Normal token send tx | |||||
let undecimalizedTokenReceivedAmount = new BigNumber(0); | |||||
for (const tokenReceivedAmount of tokenReceivingOutputs.values()) { | |||||
undecimalizedTokenReceivedAmount = | |||||
undecimalizedTokenReceivedAmount.plus( | |||||
tokenReceivedAmount, | |||||
); | |||||
} | |||||
// Calculate true tokenReceivedAmount using decimals | |||||
// Use decimals to calculate the received amount as string | |||||
const decimalizedTokenReceivedAmount = new BigNumber( | |||||
undecimalizedTokenReceivedAmount, | |||||
) | |||||
.shiftedBy(-1 * decimals) | |||||
.toString(); | |||||
tokenSendMsg = `${returnAddressPreview( | |||||
cashaddr.encodeOutputScript( | |||||
tokenSendingOutputScripts.values().next().value, | |||||
), | |||||
)} <a href="${ | |||||
config.blockExplorer | |||||
}/tx/${txid}">sent</a> ${decimalizedTokenReceivedAmount.toLocaleString( | |||||
'en-US', | |||||
{ | |||||
minimumFractionDigits: decimals, | |||||
}, | |||||
)} <a href="${ | |||||
config.blockExplorer | |||||
}/tx/${tokenId}">${tokenTicker}</a> to ${returnAddressPreview( | |||||
cashaddr.encodeOutputScript( | |||||
tokenReceivingOutputs.keys().next().value, | |||||
), | |||||
)}${ | |||||
tokenReceivingOutputs.size > 1 | |||||
? ` and ${tokenReceivingOutputs.size - 1} others` | |||||
: '' | |||||
}`; | |||||
} | |||||
tokenSendTxTgMsgLines.push(tokenSendMsg); | |||||
// This parsed tx has a tg msg line. Move on to the next one. | |||||
continue; | |||||
} | |||||
// Txs not parsed above are parsed as xec send txs | // Txs not parsed above are parsed as xec send txs | ||||
/* We do the totalSatsSent calculation here in getBlockTgMsg and not above in parseTx | /* We do the totalSatsSent calculation here in getBlockTgMsg and not above in parseTx | ||||
* as it is only necessary to do for rendered txs | * as it is only necessary to do for rendered txs | ||||
*/ | */ | ||||
let totalSatsSent = 0; | let totalSatsSent = 0; | ||||
for (const satoshis of xecReceivingOutputs.values()) { | for (const satoshis of xecReceivingOutputs.values()) { | ||||
totalSatsSent += satoshis; | totalSatsSent += satoshis; | ||||
▲ Show 20 Lines • Show All 92 Lines • ▼ Show 20 Lines | getBlockTgMessage: function (parsedBlock, coingeckoPrices, tokenInfoMap) { | ||||
`${genesisTxTgMsgLines.length} new eToken${ | `${genesisTxTgMsgLines.length} new eToken${ | ||||
genesisTxTgMsgLines.length > 1 ? `s` : '' | genesisTxTgMsgLines.length > 1 ? `s` : '' | ||||
} created:`, | } created:`, | ||||
); | ); | ||||
tgMsg = tgMsg.concat(genesisTxTgMsgLines); | tgMsg = tgMsg.concat(genesisTxTgMsgLines); | ||||
} | } | ||||
// eToken Send txs | |||||
if (tokenSendTxTgMsgLines.length > 0) { | |||||
// Line break for new section | |||||
tgMsg.push(''); | |||||
// 1 eToken send tx: | |||||
// or | |||||
// <n> eToken send txs: | |||||
tgMsg.push( | |||||
`${tokenSendTxTgMsgLines.length} eToken send tx${ | |||||
tokenSendTxTgMsgLines.length > 1 ? `s` : '' | |||||
}`, | |||||
); | |||||
tgMsg = tgMsg.concat(tokenSendTxTgMsgLines); | |||||
} | |||||
// OP_RETURN txs | // OP_RETURN txs | ||||
if (opReturnTxTgMsgLines.length > 0) { | if (opReturnTxTgMsgLines.length > 0) { | ||||
// Line break for new section | // Line break for new section | ||||
tgMsg.push(''); | tgMsg.push(''); | ||||
// App txs: | // App txs: | ||||
// or | // or | ||||
// App tx: | // App tx: | ||||
Show All 28 Lines |