diff --git a/web/cashtab/src/components/Common/Ticker.js b/web/cashtab/src/components/Common/Ticker.js --- a/web/cashtab/src/components/Common/Ticker.js +++ b/web/cashtab/src/components/Common/Ticker.js @@ -32,8 +32,8 @@ newTokenDefaultUrl: 'https://cashtab.com/', opReturn: { opReturnPrefixHex: '6a', - opReturnPushDataHex: '04', opReturnAppPrefixLengthHex: '04', + opPushDataOne: '4c', appPrefixesHex: { eToken: '534c5000', cashtab: '00746162', @@ -95,53 +95,64 @@ }, }; -export function getETokenEncodingSubstring() { - let encodingStr = - currency.opReturn.opReturnPrefixHex + // 6a - currency.opReturn.opReturnAppPrefixLengthHex + // 04 - currency.opReturn.appPrefixesHex.eToken; // 534c5000 - - return encodingStr; -} - -export function getCashtabEncodingSubstring() { - let encodingStr = - currency.opReturn.opReturnPrefixHex + // 6a - currency.opReturn.opReturnAppPrefixLengthHex + // 04 - currency.opReturn.appPrefixesHex.cashtab; // 00746162 - - return encodingStr; -} - -export function isCashtabOutput(hexStr) { - if (!hexStr || typeof hexStr !== 'string') { +export function parseOpReturn(hexStr) { + if ( + !hexStr || + typeof hexStr !== 'string' || + hexStr.substring(0, 2) !== currency.opReturn.opReturnPrefixHex + ) { return false; } - return hexStr.startsWith(getCashtabEncodingSubstring()); -} -export function isEtokenOutput(hexStr) { - if (!hexStr || typeof hexStr !== 'string') { - return false; - } - return hexStr.startsWith(getETokenEncodingSubstring()); -} + hexStr = hexStr.slice(2); // remove the first byte i.e. 6a + + /* + * @Return: resultArray is structured as follows: + * resultArray[0] is the transaction type i.e. eToken prefix, cashtab prefix, external message itself if unrecognized prefix + * resultArray[1] is the actual cashtab message or the 2nd part of an external message + * resultArray[2 - n] are the additional messages for future protcols + */ + let resultArray = []; + let message = ''; + let hexStrLength = hexStr.length; + + for (let i = 0; hexStrLength !== 0; i++) { + // part 1: check the preceding byte value for the subsequent message + let byteValue = hexStr.substring(0, 2); + let msgByteSize = 0; + if (byteValue === currency.opReturn.opPushDataOne) { + // if this byte is 4c then the next byte is the message byte size - retrieve the message byte size only + msgByteSize = parseInt(hexStr.substring(2, 4), 16); // hex base 16 to decimal base 10 + hexStr = hexStr.slice(4); // strip the 4c + message byte size info + } else { + // take the byte as the message byte size + msgByteSize = parseInt(hexStr.substring(0, 2), 16); // hex base 16 to decimal base 10 + hexStr = hexStr.slice(2); // strip the message byte size info + } -export function extractCashtabMessage(hexSubstring) { - if (!hexSubstring || typeof hexSubstring !== 'string') { - return ''; - } - let substring = hexSubstring.replace(getCashtabEncodingSubstring(), ''); // remove the cashtab encoding - substring = substring.slice(2); // remove the 2 bytes indicating the size of the next element on the stack e.g. a0 -> 160 bytes - return substring; -} + // part 2: parse the subsequent message based on bytesize + const msgCharLength = 2 * msgByteSize; + message = hexStr.substring(0, msgCharLength); + if (i === 0 && message === currency.opReturn.appPrefixesHex.eToken) { + // add the extracted eToken prefix to array then exit loop + resultArray[i] = currency.opReturn.appPrefixesHex.eToken; + break; + } else if ( + i === 0 && + message === currency.opReturn.appPrefixesHex.cashtab + ) { + // add the extracted Cashtab prefix to array + resultArray[i] = currency.opReturn.appPrefixesHex.cashtab; + } else { + // this is either an external message or a subsequent cashtab message loop to extract the message + resultArray[i] = message; + } -export function extractExternalMessage(hexSubstring) { - if (!hexSubstring || typeof hexSubstring !== 'string') { - return ''; + // strip out the parsed message + hexStr = hexStr.slice(msgCharLength); + hexStrLength = hexStr.length; } - let substring = hexSubstring.slice(4); // remove the preceding OP_RETURN prefixes - return substring; + return resultArray; } export function isValidCashPrefix(addressString) { diff --git a/web/cashtab/src/components/Common/__mocks__/mockOpReturnParsedArray.js b/web/cashtab/src/components/Common/__mocks__/mockOpReturnParsedArray.js new file mode 100644 --- /dev/null +++ b/web/cashtab/src/components/Common/__mocks__/mockOpReturnParsedArray.js @@ -0,0 +1,52 @@ +import { currency } from '@components/Common/Ticker'; +export const shortCashtabMessageInputHex = '6a04007461620461736466'; +export const longCashtabMessageInputHex = + '6a04007461624ca054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e'; +export const shortExternalMessageInputHex = + '6a1173686f727420656c65637472756d313132'; +export const longExternalMessageInputHex = + '6a4cdb54657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d6940'; +export const shortSegmentedExternalMessageInputHex = + '6a1173686f727420656c65637472756d3131321173686f727420656c65637472756d3131321173686f727420656c65637472756d313132'; +export const longSegmentedExternalMessageInputHex = + '6a4cdb54657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69404cdb54657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d6940'; +export const mixedSegmentedExternalMessageInputHex = + '6a4cdb54657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69404cdb54657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69401173686f727420656c65637472756d313132'; +export const eTokenInputHex = + '6a04534c500001010453454e4420f9eabf94edec18e91f518c6b1e22cc47a7464d005f04a06e65f70be7755c94bc0800000000000000c80800000000000024b8'; + +export const mockParsedShortCashtabMessageArray = ['00746162', '61736466']; + +export const mockParsedLongCashtabMessageArray = [ + '00746162', + '54657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e', +]; + +export const mockParsedShortExternalMessageArray = [ + '73686f727420656c65637472756d313132', +]; + +export const mockParsedLongExternalMessageArray = [ + '54657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d6940', +]; + +export const mockParsedShortSegmentedExternalMessageArray = [ + '73686f727420656c65637472756d313132', + '73686f727420656c65637472756d313132', + '73686f727420656c65637472756d313132', +]; + +export const mockParsedLongSegmentedExternalMessageArray = [ + '54657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d6940', + '54657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d6940', +]; + +export const mockParsedMixedSegmentedExternalMessageArray = [ + '54657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d6940', + '54657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d69742054657374696e672074686520323535206c696d6940', + '73686f727420656c65637472756d313132', +]; + +export const mockParsedETokenOutputArray = [ + currency.opReturn.appPrefixesHex.eToken, +]; diff --git a/web/cashtab/src/components/Common/__tests__/Ticker.test.js b/web/cashtab/src/components/Common/__tests__/Ticker.test.js --- a/web/cashtab/src/components/Common/__tests__/Ticker.test.js +++ b/web/cashtab/src/components/Common/__tests__/Ticker.test.js @@ -4,12 +4,8 @@ isValidTokenPrefix, toLegacy, toLegacyArray, - isCashtabOutput, - isEtokenOutput, - extractCashtabMessage, - extractExternalMessage, - getETokenEncodingSubstring, - getCashtabEncodingSubstring, + parseOpReturn, + currency, } from '../Ticker'; import { validAddressArrayInput, @@ -18,6 +14,24 @@ validLargeAddressArrayOutput, invalidAddressArrayInput, } from '../__mocks__/mockAddressArray'; +import { + shortCashtabMessageInputHex, + longCashtabMessageInputHex, + shortExternalMessageInputHex, + longExternalMessageInputHex, + shortSegmentedExternalMessageInputHex, + longSegmentedExternalMessageInputHex, + mixedSegmentedExternalMessageInputHex, + mockParsedShortCashtabMessageArray, + mockParsedLongCashtabMessageArray, + mockParsedShortExternalMessageArray, + mockParsedLongExternalMessageArray, + mockParsedShortSegmentedExternalMessageArray, + mockParsedLongSegmentedExternalMessageArray, + mockParsedMixedSegmentedExternalMessageArray, + eTokenInputHex, + mockParsedETokenOutputArray, +} from '../__mocks__/mockOpReturnParsedArray'; test('Rejects cash address with bitcoincash: prefix', async () => { const result = isValidCashPrefix( @@ -171,75 +185,42 @@ ); }); -test('getCashtabEncodingSubstring() returns the appropriate substring for cashtab message outputs', async () => { - const result = getCashtabEncodingSubstring(); - expect(result).toStrictEqual('6a0400746162'); -}); - -test('getETokenEncodingSubstring() returns the appropriate substring for eToken outputs', async () => { - const result = getETokenEncodingSubstring(); - expect(result).toStrictEqual('6a04534c5000'); -}); - -test('isCashtabOutput() correctly validates a cashtab message output hex', async () => { - const result = isCashtabOutput('6a04007461620b63617368746162756c6172'); - expect(result).toStrictEqual(true); -}); - -test('isCashtabOutput() correctly invalidates an external message output hex', async () => { - const result = isCashtabOutput('6a0c7069616e6f74656e6e697332'); - expect(result).toStrictEqual(false); -}); - -test('isCashtabOutput() correctly handles null input', async () => { - const result = isCashtabOutput(null); - expect(result).toStrictEqual(false); -}); - -test('isCashtabOutput() correctly handles non-string input', async () => { - const result = isCashtabOutput(7623723323); - expect(result).toStrictEqual(false); +test('parseOpReturn() successfully parses a short cashtab message', async () => { + const result = parseOpReturn(shortCashtabMessageInputHex); + expect(result).toStrictEqual(mockParsedShortCashtabMessageArray); }); -test('isCashtabOutput() correctly invalidates an external message output hex', async () => { - const result = isCashtabOutput( - '6a202731afddf3b83747943f0e650b938ea0670dcae2e08c415f53bd4c6acfd15e09', - ); - expect(result).toStrictEqual(false); +test('parseOpReturn() successfully parses a long cashtab message where an additional PUSHDATA1 is present', async () => { + const result = parseOpReturn(longCashtabMessageInputHex); + expect(result).toStrictEqual(mockParsedLongCashtabMessageArray); }); -test('isEtokenOutput() correctly validates an eToken output hex', async () => { - const result = isEtokenOutput( - '6a04534c500001010453454e442069b8431ddecf775393b1b36aa1d0ddcd7b342f1157b9671a03747378ed35ea0d08000000000000012c080000000000002008', - ); - expect(result).toStrictEqual(true); +test('parseOpReturn() successfully parses a short external message', async () => { + const result = parseOpReturn(shortExternalMessageInputHex); + expect(result).toStrictEqual(mockParsedShortExternalMessageArray); }); -test('isEtokenOutput() correctly invalidates an eToken output hex', async () => { - const result = isEtokenOutput( - '5434c500001010453454e442069b8431ddecf775393b1b36aa1d0ddcd7b342f1157b9671a03747378ed35ea0d08000000000000012c080000000000002008', - ); - expect(result).toStrictEqual(false); +test('parseOpReturn() successfully parses a long external message where an additional PUSHDATA1 is present', async () => { + const result = parseOpReturn(longExternalMessageInputHex); + expect(result).toStrictEqual(mockParsedLongExternalMessageArray); }); -test('isEtokenOutput() correctly handles null input', async () => { - const result = isEtokenOutput(null); - expect(result).toStrictEqual(false); +test('parseOpReturn() successfully parses an external message that is segmented into separate short parts', async () => { + const result = parseOpReturn(shortSegmentedExternalMessageInputHex); + expect(result).toStrictEqual(mockParsedShortSegmentedExternalMessageArray); }); -test('isEtokenOutput() correctly handles non-string input', async () => { - const result = isEtokenOutput(7623723323); - expect(result).toStrictEqual(false); +test('parseOpReturn() successfully parses an external message that is segmented into separate long parts', async () => { + const result = parseOpReturn(longSegmentedExternalMessageInputHex); + expect(result).toStrictEqual(mockParsedLongSegmentedExternalMessageArray); }); -test('extractCashtabMessage() correctly extracts a Cashtab message', async () => { - const result = extractCashtabMessage( - '6a04007461620b63617368746162756c6172', - ); - expect(result).toStrictEqual('63617368746162756c6172'); +test('parseOpReturn() successfully parses an external message that is segmented into separate long and short parts', async () => { + const result = parseOpReturn(mixedSegmentedExternalMessageInputHex); + expect(result).toStrictEqual(mockParsedMixedSegmentedExternalMessageArray); }); -test('extractExternalMessage() correctly extracts an external message', async () => { - const result = extractExternalMessage('6a0d62696e676f656c65637472756d'); - expect(result).toStrictEqual('62696e676f656c65637472756d'); +test('parseOpReturn() successfully parses an eToken output', async () => { + const result = parseOpReturn(eTokenInputHex); + expect(result).toStrictEqual(mockParsedETokenOutputArray); }); diff --git a/web/cashtab/src/hooks/useBCH.js b/web/cashtab/src/hooks/useBCH.js --- a/web/cashtab/src/hooks/useBCH.js +++ b/web/cashtab/src/hooks/useBCH.js @@ -5,6 +5,7 @@ isEtokenOutput, extractCashtabMessage, extractExternalMessage, + parseOpReturn, } from '@components/Common/Ticker'; import { isValidTokenStats } from '@utils/validation'; import SlpWallet from 'minimal-slp-wallet'; @@ -152,34 +153,55 @@ !Object.keys(thisOutput.scriptPubKey).includes('addresses') ) { let hex = thisOutput.scriptPubKey.hex; + let parsedOpReturnArray = parseOpReturn(hex); - if (isEtokenOutput(hex)) { + if (!parsedOpReturnArray) { + console.log( + 'useBCH.parsedTxData() error: parsed array is empty', + ); + break; + } + + let message = ''; + let txType = parsedOpReturnArray[0]; + if (txType === currency.opReturn.appPrefixesHex.eToken) { // this is an eToken transaction tokenTx = true; - } else if (isCashtabOutput(hex)) { - // this is a cashtab.com generated message + } else if ( + txType === currency.opReturn.appPrefixesHex.cashtab + ) { + // this is a Cashtab message try { - substring = extractCashtabMessage(hex); - opReturnMessage = Buffer.from(substring, 'hex'); + 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.parsedTxHistory() error: invalid cashtab msg hex: ' + - substring, + 'useBCH.parsedTxData() error: invalid cashtab msg hex: ' + + parsedOpReturnArray[1], ); } } 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 { - substring = extractExternalMessage(hex); - opReturnMessage = Buffer.from(substring, 'hex'); + opReturnMessage = Buffer.from(message, 'hex'); } catch (err) { // soft error if an unexpected or invalid cashtab hex is encountered opReturnMessage = ''; console.log( - 'useBCH.parsedTxHistory() error: invalid external msg hex: ' + + 'useBCH.parsedTxData() error: invalid external msg hex: ' + substring, ); }