diff --git a/apps/ecash-herald/src/parse.js b/apps/ecash-herald/src/parse.js index 5badc3127..702ef73db 100644 --- a/apps/ecash-herald/src/parse.js +++ b/apps/ecash-herald/src/parse.js @@ -1,1574 +1,1591 @@ // Copyright (c) 2023 The Bitcoin developers // Distributed under the MIT software license, see the accompanying // file COPYING or http://www.opensource.org/licenses/mit-license.php. 'use strict'; const config = require('../config'); const opReturn = require('../constants/op_return'); const { consumeNextPush } = require('ecash-script'); const knownMinersJson = require('../constants/miners'); const { jsonReviver, bigNumberAmountToLocaleString } = require('../src/utils'); const miners = JSON.parse(JSON.stringify(knownMinersJson), jsonReviver); const cashaddr = require('ecashaddrjs'); const BigNumber = require('bignumber.js'); const { prepareStringForTelegramHTML, splitOverflowTgMsg, } = require('./telegram'); const { formatPrice, satsToFormattedValue, returnAddressPreview, } = require('./utils'); module.exports = { parseBlock: function (chronikBlockResponse) { const { blockInfo, txs } = chronikBlockResponse; const { hash } = blockInfo; const { height, numTxs } = blockInfo; // Parse coinbase string const coinbaseTx = txs[0]; const miner = module.exports.getMinerFromCoinbaseTx(coinbaseTx, miners); // Start with i=1 to skip Coinbase tx const parsedTxs = []; for (let i = 1; i < txs.length; i += 1) { parsedTxs.push(module.exports.parseTx(txs[i])); } // Collect token info needed to parse token send txs const tokenIds = new Set(); // we only need each tokenId once // Collect outputScripts seen in this block to parse for balance let outputScripts = new Set(); for (let i = 0; i < parsedTxs.length; i += 1) { const thisParsedTx = parsedTxs[i]; if (thisParsedTx.tokenSendInfo) { tokenIds.add(thisParsedTx.tokenSendInfo.tokenId); } // Some OP_RETURN txs also have token IDs we need to parse // SWaP txs, (TODO: airdrop txs) if ( thisParsedTx.opReturnInfo && thisParsedTx.opReturnInfo.tokenId ) { tokenIds.add(thisParsedTx.opReturnInfo.tokenId); } const { xecSendingOutputScripts, xecReceivingOutputs } = thisParsedTx; // Only add the first sending and receiving output script, // As you will only render balance emojis for these outputScripts.add(xecSendingOutputScripts.values().next().value); // For receiving outputScripts, add the first that is not OP_RETURN // So, get an array of the outputScripts first const xecReceivingOutputScriptsArray = Array.from( xecReceivingOutputs.keys(), ); for (let j = 0; j < xecReceivingOutputScriptsArray.length; j += 1) { if ( !xecReceivingOutputScriptsArray[j].startsWith( opReturn.opReturnPrefix, ) ) { outputScripts.add(xecReceivingOutputScriptsArray[j]); // Exit loop after you've added the first non-OP_RETURN outputScript break; } } } return { hash, height, miner, numTxs, parsedTxs, tokenIds, outputScripts, }; }, getMinerFromCoinbaseTx: function (coinbaseTx, knownMiners) { // get coinbase inputScript const testedCoinbaseScript = coinbaseTx.inputs[0].inputScript; // When you find the miner, minerInfo will come from knownMiners let minerInfo = false; // First, check outputScripts for a known miner const { outputs } = coinbaseTx; for (let i = 0; i < outputs.length; i += 1) { const thisOutputScript = outputs[i].outputScript; if (knownMiners.has(thisOutputScript)) { minerInfo = knownMiners.get(thisOutputScript); break; } } if (!minerInfo) { // If you still haven't found minerInfo, test by known pattern of coinbase script // Possibly a known miner is using a new address knownMiners.forEach(knownMinerInfo => { const { coinbaseHexFragment } = knownMinerInfo; if (testedCoinbaseScript.includes(coinbaseHexFragment)) { minerInfo = knownMinerInfo; } }); } // At this point, if you haven't found the miner, you won't if (!minerInfo) { return 'unknown'; } // If you have found the miner, parse coinbase hex for additional info switch (minerInfo.miner) { // This is available for ViaBTC and CK Pool // Use a switch statement to easily support adding future miners case 'ViaBTC': // Intentional fall-through so ViaBTC and CKPool have same parsing // es-lint ignore no-fallthrough case 'CK Pool': { /* For ViaBTC, the interesting info is between '/' characters * i.e. /Mined by 260786/ * In ascii, these are encoded with '2f' */ const infoHexParts = testedCoinbaseScript.split('2f'); // Because the characters before and after the info we are looking for could also // contain '2f', we need to find the right part // The right part is the one that comes immediately after coinbaseHexFragment let infoAscii = ''; for (let i = 0; i < infoHexParts.length; i += 1) { if ( infoHexParts[i].includes(minerInfo.coinbaseHexFragment) ) { // We want the next one, if it exists if (i + 1 < infoHexParts.length) { infoAscii = Buffer.from( infoHexParts[i + 1], 'hex', ).toString('ascii'); } break; } } if (infoAscii === 'mined by IceBerg') { // CK Pool, mined by IceBerg // If this is IceBerg, identify uniquely // Iceberg is probably a solo miner using CK Pool software return `IceBerg`; } // Return your improved 'miner' info // ViaBTC, Mined by 260786 if (infoAscii.length === 0) { // If you did not find anything interesting, just return the miner return minerInfo.miner; } return `${minerInfo.miner}, ${infoAscii}`; } default: { // Unless the miner has specific parsing rules defined above, no additional info is available return minerInfo.miner; } } }, parseTx: function (tx) { /* Parse an eCash tx as returned by chronik for newsworthy information * returns * { txid, genesisInfo, opReturnInfo } */ const { txid, inputs, outputs } = tx; let isTokenTx = false; let genesisInfo = 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); // tokenBurn parsing variables let tokenBurnInfo = false; let undecimalizedTokenBurnAmount = new BigNumber(0); /* 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') { isTokenTx = true; // Determine if this is an etoken genesis tx if ( tx.slpTxData.slpMeta !== null && typeof tx.slpTxData.slpMeta !== 'undefined' && tx.slpTxData.genesisInfo !== null && typeof tx.slpTxData.genesisInfo !== 'undefined' && tx.slpTxData.slpMeta.txType === 'GENESIS' ) { 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) { const thisInput = inputs[i]; xecSendingOutputScripts.add(thisInput.outputScript); 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); } if (typeof thisInput.slpBurn !== 'undefined') { undecimalizedTokenBurnAmount = undecimalizedTokenBurnAmount.plus( new BigNumber(thisInput.slpBurn.token.amount), ); } } // Iterate over outputs to check for OP_RETURN msgs for (let i = 0; i < outputs.length; i += 1) { const thisOutput = outputs[i]; 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 if ( thisOutput.outputScript.startsWith(opReturn.opReturnPrefix) && !isTokenTx ) { opReturnInfo = module.exports.parseOpReturn( thisOutput.outputScript.slice(2), ); } // 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 const txFee = xecInputAmountSats - xecOutputAmountSats; // 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; } // If this is a token burn tx, return token burn parsing info and not 'false' for tokenBurnInfo // Check to make sure undecimalizedTokenBurnAmount is not zero // Some txs e.g. ff314cae5d5daeabe44225f855bd54c7d07737b8458a8e8a49fc581025ee0a57 give // an slpBurn field in chronik, even though not even a token tx if (undecimalizedTokenBurnAmount.gt(0)) { tokenBurnInfo = { undecimalizedTokenBurnAmount: undecimalizedTokenBurnAmount.toString(), }; } return { txid, genesisInfo, opReturnInfo, txFee, xecSendingOutputScripts, xecChangeOutputs, xecReceivingOutputs, tokenSendInfo, tokenBurnInfo, }; }, /** * * @param {string} opReturnHex an OP_RETURN outputScript with '6a' removed * @returns {object} {app, msg} an object with app and msg params used to generate msg */ parseOpReturn: function (opReturnHex) { // Initialize required vars let app; let msg; let tokenId = false; // Get array of pushes let stack = { remainingHex: opReturnHex }; let stackArray = []; while (stack.remainingHex.length > 0) { const thisPush = consumeNextPush(stack); if (thisPush !== '') { // You may have an empty push in the middle of a complicated tx for some reason // Mb some libraries erroneously create these // e.g. https://explorer.e.cash/tx/70c2842e1b2c7eb49ee69cdecf2d6f3cd783c307c4cbeef80f176159c5891484 // has 4c000100 for last characters. 4c00 is just nothing. // But you want to know 00 and have the correct array index stackArray.push(thisPush); } } // Get the protocolIdentifier, the first push const protocolIdentifier = stackArray[0]; // Test for memo // Memo prefixes are special in that they are two bytes instead of the usual four // Also, memo has many prefixes, in that the action is also encoded in these two bytes if ( protocolIdentifier.startsWith(opReturn.memo.prefix) && protocolIdentifier.length === 4 ) { // If the protocol identifier is two bytes long (4 characters), parse for memo tx // For now, send the same info to this function that it currently parses // TODO parseMemoOutputScript needs to be refactored to use ecash-script return module.exports.parseMemoOutputScript(stackArray); } // Test for other known apps with known msg processing methods switch (protocolIdentifier) { case opReturn.opReserved: { // Parse for empp OP_RETURN // Spec https://github.com/Bitcoin-ABC/bitcoin-abc/blob/master/chronik/bitcoinsuite-slp/src/empp/mod.rs return module.exports.parseMultipushStack(stackArray); } case opReturn.knownApps.airdrop.prefix: { app = opReturn.knownApps.airdrop.app; // Initialize msg as empty string. Need tokenId info to complete. msg = ''; // Airdrop tx has structure // // Cashtab allows sending a cashtab msg with an airdrop // These look like // tokenId = stackArray[1]; break; } case opReturn.knownApps.cashtabMsg.prefix: { app = opReturn.knownApps.cashtabMsg.app; // For a Cashtab msg, the next push on the stack is the Cashtab msg // Cashtab msgs use utf8 encoding msg = prepareStringForTelegramHTML( Buffer.from(stackArray[1], 'hex').toString('utf8'), ); break; } case opReturn.knownApps.cashtabMsgEncrypted.prefix: { app = opReturn.knownApps.cashtabMsgEncrypted.app; // For an encrypted cashtab msg, you can't parse and display the msg msg = ''; // You will add info about the tx when you build the msg break; } case opReturn.knownApps.fusionLegacy.prefix: case opReturn.knownApps.fusion.prefix: { /** * Cash Fusion tx * * https://github.com/cashshuffle/spec/blob/master/CASHFUSION.md */ app = opReturn.knownApps.fusion.app; // The session hash is not particularly interesting to users // Provide tx info in telegram prep function msg = ''; break; } case opReturn.knownApps.swap.prefix: { // Swap txs require special parsing that should be done in getSwapTgMsg // We may need to get info about a token ID before we can // create a good msg app = opReturn.knownApps.swap.app; msg = ''; if (stackArray[1] === '01' && stackArray[2] === '01') { // If this is a signal for buy or sell of a token, save the token id // Ref https://github.com/vinarmani/swap-protocol/blob/master/swap-protocol-spec.md // A buy or sell signal tx will have '01' at stackArray[1] and stackArray[2] and // token id at stackArray[3] tokenId = stackArray[3]; } break; } default: { /** * If you don't recognize protocolIdentifier, just translate with ASCII * Will be easy to spot these msgs in the bot and add special parsing rules * */ app = 'unknown'; msg = prepareStringForTelegramHTML( Buffer.from(stackArray.join(''), 'hex').toString('ascii'), ); break; } } return { app, msg, stackArray, tokenId }; }, /** * Parse an empp stack for a simplified slp v2 description * TODO expand for parsing other types of empp txs as specs or examples are known * @param {array} emppStackArray an array containing a hex string for every push of this memo OP_RETURN outputScript * @returns {object} {app, msg} used to compose a useful telegram msg describing the transaction */ parseMultipushStack: function (emppStackArray) { // Note that an empp push may not necessarily include traditionally parsed pushes // i.e. consumeNextPush({remainingHex:}) may throw an error // For example, SLPv2 txs do not include a push for their prefix // So, parsing empp txs will require specific rules depending on the type of tx let msgs = []; // Start at i=1 because emppStackArray[0] is OP_RESERVED for (let i = 1; i < emppStackArray.length; i += 1) { if ( emppStackArray[i].slice(0, 8) === opReturn.knownApps.slp2.prefix ) { // Parse string for slp v2 const thisMsg = module.exports.parseSlpTwo( emppStackArray[i].slice(8), ); msgs.push(`${opReturn.knownApps.slp2.app}:${thisMsg}`); } else { // Since we don't know any spec or parsing rules for other types of EMPP pushes, // Just add an ASCII decode of the whole thing if you see one msgs.push( `${'Unknown App:'}${Buffer.from( emppStackArray[i], 'hex', ).toString('ascii')}`, ); } // Do not parse any other empp (haven't seen any in the wild, no existing specs to follow) } if (msgs.length > 0) { return { app: 'EMPP', msg: msgs.join('|') }; } }, /** * Stub method to parse slp two empps * @param {string} slpTwoPush a string of hex characters in an empp tx representing an slp2 push * @returns {string} For now, just the section type, if token type is correct */ parseSlpTwo: function (slpTwoPush) { // Parse an empp push hex string with the SLP protocol identifier removed per SLP v2 spec // https://ecashbuilders.notion.site/SLPv2-a862a4130877448387373b9e6a93dd97 let msg = ''; // 1.3: Read token type // For now, this can only be 00. If not 00, unknown const tokenType = slpTwoPush.slice(0, 2); if (tokenType !== '00') { msg += 'Unknown token type|'; } // 1.4: Read section type // Note: these are encoded with push data, so you can use ecash-script let stack = { remainingHex: slpTwoPush.slice(2) }; const sectionType = Buffer.from(consumeNextPush(stack), 'hex').toString( 'utf8', ); msg += sectionType; // Stop here for now // The rest of the parsing rules get quite complicated and should be handled in a dedicated library // or indexer return msg; }, /** * Parse a stackArray according to OP_RETURN rules to convert to a useful tg msg * @param {Array} stackArray an array containing a hex string for every push of this memo OP_RETURN outputScript * @returns {string} A useful string to describe this tx in a telegram msg */ parseMemoOutputScript: function (stackArray) { let app = opReturn.memo.app; let msg = ''; // Get the action code from stackArray[0] // For memo txs, this will be the last 2 characters of this initial push const actionCode = stackArray[0].slice(-2); if (Object.keys(opReturn.memo).includes(actionCode)) { // If you parse for this action code, include its description in the tg msg msg += opReturn.memo[actionCode]; // Include a formatting spacer in between action code and newsworthy info msg += '|'; } switch (actionCode) { case '01': // Set name (1-217 bytes) case '02': // Post memo (1-217 bytes) case '05': // Set profile text (1-217 bytes) case '0d': // Topic Follow (1-214 bytes) case '0e': // Topic Unfollow (1-214 bytes) // Action codes with only 1 push after the protocol identifier // that is utf8 encoded // Include decoded utf8 msg // Make sure the OP_RETURN msg does not contain telegram html escape characters msg += prepareStringForTelegramHTML( Buffer.from(stackArray[1], 'hex').toString('utf8'), ); break; case '03': /** * 03 - Reply to memo * (32 bytes) * (1-184 bytes) */ // The tx hash is in hex, not utf8 encoded // For now, we don't have much to do with this txid in a telegram bot // Link to the liked or reposted memo // Do not remove tg escape characters as you want this to parse msg += `memo`; // Include a formatting spacer msg += '|'; // Add the reply msg += prepareStringForTelegramHTML( Buffer.from(stackArray[2], 'hex').toString('utf8'), ); break; case '04': /** * 04 - Like / tip memo (32 bytes) */ // Link to the liked or reposted memo msg += `memo`; break; case '0b': { // 0b - Repost memo (32 bytes) (0-184 bytes) // Link to the liked or reposted memo msg += `memo`; // Include a formatting spacer msg += '|'; // Add the msg msg += prepareStringForTelegramHTML( Buffer.from(stackArray[2], 'hex').toString('utf8'), ); break; } case '06': case '07': case '16': case '17': { /** * Follow user - 06
(20 bytes) * Unfollow user - 07
(20 bytes) * Mute user - 16
(20 bytes) * Unmute user - 17
(20 bytes) */ // The address is a hex-encoded hash160 // all memo addresses are p2pkh const address = cashaddr.encode( 'ecash', 'P2PKH', stackArray[1], ); // Link to the address in the msg msg += `${returnAddressPreview(address)}`; break; } case '0a': { // 01 - Set profile picture // (1-217 bytes) // url is utf8 encoded stack[1] const url = Buffer.from(stackArray[1], 'hex').toString('utf8'); // Link to it msg += `[img]`; break; } case '0c': { /** * 0c - Post Topic Message * (1-214 bytes) * (1-[214-len(topic_name)] bytes) */ // Add the topic msg += prepareStringForTelegramHTML( Buffer.from(stackArray[1], 'hex').toString('utf8'), ); // Add a format spacer msg += '|'; // Add the topic msg msg += prepareStringForTelegramHTML( Buffer.from(stackArray[2], 'hex').toString('utf8'), ); break; } case '10': { /** * 10 - Create Poll * (1 byte) * (1 byte) * (1-209 bytes) * */ // You only need the question here msg += prepareStringForTelegramHTML( Buffer.from(stackArray[3], 'hex').toString('utf8'), ); break; } case '13': { /** * 13 Add poll option * (32 bytes) *