diff --git a/web/cashtab/src/hooks/useWallet.js b/web/cashtab/src/hooks/useWallet.js --- a/web/cashtab/src/hooks/useWallet.js +++ b/web/cashtab/src/hooks/useWallet.js @@ -229,12 +229,9 @@ // Preserve bch-api for tx history for now, as this will take another stacked diff to migrate to chronik const txHistory = await getTxHistory(BCH, cashAddresses); - const chronikTxHistory = await getTxHistoryChronik( - chronik, - hash160AndAddressObjArray, - ); + const chronikTxHistory = await getTxHistoryChronik(chronik, wallet); console.log( - `chronikTxHistory as flattened array, sorted by blockheight and time first seen, with parse info`, + `chronikTxHistory as flattened array, sorted by blockheight and time first seen, with parse info, and partial legacy parse info`, chronikTxHistory, ); @@ -880,8 +877,7 @@ const txDetails = await chronik.tx(txid); // parse tx for notification - const hash160Array = getHashArrayFromWallet(wallet); - const parsedChronikTx = parseChronikTx(txDetails, hash160Array); + const parsedChronikTx = parseChronikTx(txDetails, wallet); if (parsedChronikTx.incoming) { if (parsedChronikTx.isEtokenTx) { try { diff --git a/web/cashtab/src/utils/__mocks__/chronikTxHistory.js b/web/cashtab/src/utils/__mocks__/chronikTxHistory.js --- a/web/cashtab/src/utils/__mocks__/chronikTxHistory.js +++ b/web/cashtab/src/utils/__mocks__/chronikTxHistory.js @@ -5522,11 +5522,51 @@ }, ]; -export const lambdaHash160s = [ - '58549b5b93428fac88e36617456cd99a411bd0eb', - '438a162355ef683062a7fde9d08dd720397aaee8', - '76458db0ed96fe9863fc1ccec9fa2cfab884b0f6', -]; +export const mockParseTxWallet = { + mnemonic: 'string', + name: 'string', + Path245: { + publicKey: 'string', + hash160: '58549b5b93428fac88e36617456cd99a411bd0eb', + cashAddress: 'string', + slpAddress: 'string', + fundingWif: 'string', + fundingAddress: 'string', + legacyAddress: 'string', + }, + Path145: { + publicKey: 'string', + hash160: '438a162355ef683062a7fde9d08dd720397aaee8', + cashAddress: 'string', + slpAddress: 'string', + fundingWif: 'string', + fundingAddress: 'string', + legacyAddress: 'string', + }, + Path1899: { + publicKey: 'string', + hash160: '76458db0ed96fe9863fc1ccec9fa2cfab884b0f6', + cashAddress: 'string', + slpAddress: 'string', + fundingWif: 'string', + fundingAddress: 'string', + legacyAddress: 'string', + }, + state: { + balances: { + totalBalanceInSatoshis: '55421422', + totalBalance: '554214.22', + }, + tokens: [], + slpBalancesAndUtxos: { + slpUtxos: [], + nonSlpUtxos: [], + tokens: [], + }, + parsedTxHistory: [], + utxos: [], + }, +}; export const lambdaIncomingXecTx = { txid: 'ac83faac54059c89c41dea4c3d6704e4f74fb82e4ad2fb948e640f1d19b760de', diff --git a/web/cashtab/src/utils/__tests__/chronik.test.js b/web/cashtab/src/utils/__tests__/chronik.test.js --- a/web/cashtab/src/utils/__tests__/chronik.test.js +++ b/web/cashtab/src/utils/__tests__/chronik.test.js @@ -36,7 +36,7 @@ mockSortedFlatTxHistoryWithUnconfirmed, mockFlatTxHistoryWithAllUnconfirmed, mockSortedFlatTxHistoryWithAllUnconfirmed, - lambdaHash160s, + mockParseTxWallet, lambdaIncomingXecTx, lambdaOutgoingXecTx, lambdaIncomingEtokenTx, @@ -210,24 +210,52 @@ }); it(`Successfully parses an incoming XEC tx`, () => { - expect(parseChronikTx(lambdaIncomingXecTx, lambdaHash160s)).toStrictEqual({ + expect( + parseChronikTx(lambdaIncomingXecTx, mockParseTxWallet), + ).toStrictEqual({ incoming: true, xecAmount: '42', originatingHash160: '4e532257c01b310b3b5c1fd947c79a72addf8523', isEtokenTx: false, + legacy: { + airdropFlag: false, + airdropTokenId: '', + amountReceived: '42', + amountSent: 0, + decryptionSuccess: false, + isCashtabMessage: false, + isEncryptedMessage: false, + opReturnMessage: '', + outgoingTx: false, + tokenTx: false, + }, }); }); it(`Successfully parses an outgoing XEC tx`, () => { - expect(parseChronikTx(lambdaOutgoingXecTx, lambdaHash160s)).toStrictEqual({ + expect( + parseChronikTx(lambdaOutgoingXecTx, mockParseTxWallet), + ).toStrictEqual({ incoming: false, xecAmount: '222', originatingHash160: '76458db0ed96fe9863fc1ccec9fa2cfab884b0f6', isEtokenTx: false, + legacy: { + airdropFlag: false, + airdropTokenId: '', + amountReceived: 0, + amountSent: '222', + decryptionSuccess: false, + isCashtabMessage: false, + isEncryptedMessage: false, + opReturnMessage: '', + outgoingTx: true, + tokenTx: false, + }, }); }); it(`Successfully parses an incoming eToken tx`, () => { expect( - parseChronikTx(lambdaIncomingEtokenTx, lambdaHash160s), + parseChronikTx(lambdaIncomingEtokenTx, mockParseTxWallet), ).toStrictEqual({ incoming: true, xecAmount: '5.46', @@ -240,11 +268,23 @@ txType: 'SEND', }, etokenAmount: '12', + legacy: { + airdropFlag: false, + airdropTokenId: '', + amountReceived: '5.46', + amountSent: 0, + decryptionSuccess: false, + isCashtabMessage: false, + isEncryptedMessage: false, + opReturnMessage: '', + outgoingTx: false, + tokenTx: true, + }, }); }); it(`Successfully parses an outgoing eToken tx`, () => { expect( - parseChronikTx(lambdaOutgoingEtokenTx, lambdaHash160s), + parseChronikTx(lambdaOutgoingEtokenTx, mockParseTxWallet), ).toStrictEqual({ incoming: false, xecAmount: '5.46', @@ -257,5 +297,17 @@ txType: 'SEND', }, etokenAmount: '17', + legacy: { + airdropFlag: false, + airdropTokenId: '', + amountReceived: 0, + amountSent: '5.46', + decryptionSuccess: false, + isCashtabMessage: false, + isEncryptedMessage: false, + opReturnMessage: '', + outgoingTx: true, + tokenTx: true, + }, }); }); diff --git a/web/cashtab/src/utils/chronik.js b/web/cashtab/src/utils/chronik.js --- a/web/cashtab/src/utils/chronik.js +++ b/web/cashtab/src/utils/chronik.js @@ -1,7 +1,14 @@ // Chronik methods import BigNumber from 'bignumber.js'; import { currency } from 'components/Common/Ticker'; -import { parseOpReturn } from 'utils/cashMethods'; +import { + parseOpReturn, + convertToEncryptStruct, + getHashArrayFromWallet, + getUtxoWif, +} from 'utils/cashMethods'; +import ecies from 'ecies-lite'; +import wif from 'wif'; // Return false if do not get a valid response export const getTokenStats = async (chronik, tokenId) => { @@ -416,7 +423,8 @@ }); }; -export const parseChronikTx = (tx, walletHash160s) => { +export const parseChronikTx = (tx, wallet) => { + const walletHash160s = getHashArrayFromWallet(wallet); const { inputs, outputs } = tx; // Assign defaults let incoming = true; @@ -425,6 +433,15 @@ let etokenAmount = new BigNumber(0); const isEtokenTx = 'slpTxData' in tx && typeof tx.slpTxData !== 'undefined'; + // Defining variables used in lines legacy parseTxData function from useBCH.js + let substring = ''; + let airdropFlag = false; + let airdropTokenId = ''; + let opReturnMessage = ''; + let isCashtabMessage = false; + let isEncryptedMessage = false; + let decryptionSuccess = false; + // Iterate over inputs to see if this is an incoming tx (incoming === true) for (let i = 0; i < inputs.length; i += 1) { const thisInput = inputs[i]; @@ -473,7 +490,121 @@ ) { let hex = thisOutputReceivedAtHash160; let parsedOpReturnArray = parseOpReturn(hex); - console.log(`parsedOpReturnArray`, parsedOpReturnArray); + + // Exactly copying lines 177-293 of useBCH.js + // Differences + // 1 - patched ecies not async error + // 2 - Removed if loop for tx being token, as this is handled elsewhere here + if (!parsedOpReturnArray) { + console.log( + 'useBCH.parsedTxData() error: parsed array is empty', + ); + break; + } + + let message = ''; + let txType = parsedOpReturnArray[0]; + + if (txType === currency.opReturn.appPrefixesHex.airdrop) { + // this is to facilitate special Cashtab-specific cases of airdrop txs, both with and without msgs + // The UI via Tx.js can check this airdropFlag attribute in the parsedTx object to conditionally render airdrop-specific formatting if it's true + airdropFlag = true; + // index 0 is drop prefix, 1 is the token Id, 2 is msg prefix, 3 is msg + airdropTokenId = parsedOpReturnArray[1]; + txType = parsedOpReturnArray[2]; + + // remove the first two elements of airdrop prefix and token id from array so the array parsing logic below can remain unchanged + parsedOpReturnArray.splice(0, 2); + // index 0 now becomes msg prefix, 1 becomes the msg + } + + if (txType === currency.opReturn.appPrefixesHex.cashtab) { + // this is a Cashtab message + try { + opReturnMessage = Buffer.from( + parsedOpReturnArray[1], + 'hex', + ); + isCashtabMessage = true; + } catch (err) { + // soft error if an unexpected or invalid cashtab hex is encountered + opReturnMessage = ''; + console.log( + 'useBCH.parsedTxData() error: invalid cashtab msg hex: ' + + parsedOpReturnArray[1], + ); + } + } else if ( + txType === currency.opReturn.appPrefixesHex.cashtabEncrypted + ) { + // this is an encrypted Cashtab message + let msgString = parsedOpReturnArray[1]; + let fundingWif, privateKeyObj, privateKeyBuff; + if ( + wallet && + wallet.state && + wallet.state.slpBalancesAndUtxos && + wallet.state.slpBalancesAndUtxos.nonSlpUtxos[0] + ) { + fundingWif = getUtxoWif( + wallet.state.slpBalancesAndUtxos.nonSlpUtxos[0], + wallet, + ); + privateKeyObj = wif.decode(fundingWif); + privateKeyBuff = privateKeyObj.privateKey; + if (!privateKeyBuff) { + throw new Error('Private key extraction error'); + } + } else { + break; + } + + let structData; + let decryptedMessage; + + try { + // Convert the hex encoded message to a buffer + const msgBuf = Buffer.from(msgString, 'hex'); + + // Convert the bufer into a structured object. + structData = convertToEncryptStruct(msgBuf); + + decryptedMessage = ecies.decrypt( + privateKeyBuff, + structData, + ); + decryptionSuccess = true; + } catch (err) { + console.log( + 'useBCH.parsedTxData() decryption error: ' + err, + ); + decryptedMessage = + 'Only the message recipient can view this'; + } + isCashtabMessage = true; + isEncryptedMessage = true; + opReturnMessage = decryptedMessage; + } else { + // this is an externally generated message + message = txType; // index 0 is the message content in this instance + + // if there are more than one part to the external message + const arrayLength = parsedOpReturnArray.length; + for (let i = 1; i < arrayLength; i++) { + message = message + parsedOpReturnArray[i]; + } + + try { + opReturnMessage = Buffer.from(message, 'hex'); + } catch (err) { + // soft error if an unexpected or invalid cashtab hex is encountered + opReturnMessage = ''; + console.log( + 'useBCH.parsedTxData() error: invalid external msg hex: ' + + substring, + ); + } + } } // Find amounts at your wallet's addresses for (let j = 0; j < walletHash160s.length; j += 1) { @@ -529,6 +660,9 @@ xecAmount = xecAmount.toString(); etokenAmount = etokenAmount.toString(); + // Convert opReturnMessage to string + opReturnMessage = Buffer.from(opReturnMessage).toString(); + // Return eToken specific fields if eToken tx if (isEtokenTx) { const { slpMeta } = tx.slpTxData; @@ -539,20 +673,61 @@ isEtokenTx, etokenAmount, slpMeta, + legacy: { + amountSent: incoming ? 0 : xecAmount, + amountReceived: incoming ? xecAmount : 0, + outgoingTx: !incoming, + tokenTx: true, + airdropFlag, + airdropTokenId, + opReturnMessage: '', + isCashtabMessage, + isEncryptedMessage, + decryptionSuccess, + }, }; } // Otherwise do not include these fields - return { incoming, xecAmount, originatingHash160, isEtokenTx }; + return { + incoming, + xecAmount, + originatingHash160, + isEtokenTx, + legacy: { + amountSent: incoming ? 0 : xecAmount, + amountReceived: incoming ? xecAmount : 0, + outgoingTx: !incoming, + tokenTx: false, + airdropFlag, + airdropTokenId, + opReturnMessage, + isCashtabMessage, + isEncryptedMessage, + decryptionSuccess, + }, + }; }; -export const getTxHistoryChronik = async ( - chronik, - hash160AndAddressObjArray, -) => { +export const getTxHistoryChronik = async (chronik, wallet) => { // Create array of promises to get chronik history for each address // Combine them all and sort by blockheight and firstSeen // Add all the info cashtab needs to make them useful + const hash160AndAddressObjArray = [ + { + address: wallet.Path145.cashAddress, + hash160: wallet.Path145.hash160, + }, + { + address: wallet.Path245.cashAddress, + hash160: wallet.Path245.hash160, + }, + { + address: wallet.Path1899.cashAddress, + hash160: wallet.Path1899.hash160, + }, + ]; + let txHistoryPromises = []; for (let i = 0; i < hash160AndAddressObjArray.length; i += 1) { const txHistoryPromise = returnGetTxHistoryChronikPromise( @@ -575,16 +750,11 @@ currency.txHistoryCount, ); - // Get hash160 array - const hash160array = []; - for (let i = 0; i < hash160AndAddressObjArray.length; i += 1) { - hash160array.push(hash160AndAddressObjArray[i].hash160); - } // Parse txs const parsedTxs = []; for (let i = 0; i < sortedTxHistoryArray.length; i += 1) { const sortedTx = sortedTxHistoryArray[i]; - sortedTx.parsed = parseChronikTx(sortedTx, hash160array); + sortedTx.parsed = parseChronikTx(sortedTx, wallet); parsedTxs.push(sortedTx); }