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 @@ -30,6 +30,15 @@ notificationDurationShort: 3, notificationDurationLong: 5, newTokenDefaultUrl: 'https://cashtab.com/', + opReturn: { + opReturnPrefixHex: '6a', + opReturnPushDataHex: '04', + opReturnAppPrefixLengthHex: '04', + appPrefixesHex: { + eToken: '534c5000', + cashtab: '00746162', + }, + }, settingsValidation: { fiatCurrency: [ 'usd', @@ -74,6 +83,55 @@ }, }; +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') { + return false; + } + return hexStr.startsWith(getCashtabEncodingSubstring()); +} + +export function isEtokenOutput(hexStr) { + if (!hexStr || typeof hexStr !== 'string') { + return false; + } + return hexStr.startsWith(getETokenEncodingSubstring()); +} + +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; +} + +export function extractExternalMessage(hexSubstring) { + if (!hexSubstring || typeof hexSubstring !== 'string') { + return ''; + } + let substring = hexSubstring.slice(4); // remove the preceding OP_RETURN prefixes + return substring; +} + export function isValidCashPrefix(addressString) { // Note that this function validates prefix only // Check for prefix included in currency.prefixes array 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 @@ -1,5 +1,15 @@ import { ValidationError } from 'ecashaddrjs'; -import { isValidCashPrefix, isValidTokenPrefix, toLegacy } from '../Ticker'; +import { + isValidCashPrefix, + isValidTokenPrefix, + toLegacy, + isCashtabOutput, + isEtokenOutput, + extractCashtabMessage, + extractExternalMessage, + getETokenEncodingSubstring, + getCashtabEncodingSubstring, +} from '../Ticker'; test('Rejects cash address with bitcoincash: prefix', async () => { const result = isValidCashPrefix( @@ -106,3 +116,76 @@ ), ); }); + +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('isCashtabOutput() correctly invalidates an external message output hex', async () => { + const result = isCashtabOutput( + '6a202731afddf3b83747943f0e650b938ea0670dcae2e08c415f53bd4c6acfd15e09', + ); + expect(result).toStrictEqual(false); +}); + +test('isEtokenOutput() correctly validates an eToken output hex', async () => { + const result = isEtokenOutput( + '6a04534c500001010453454e442069b8431ddecf775393b1b36aa1d0ddcd7b342f1157b9671a03747378ed35ea0d08000000000000012c080000000000002008', + ); + expect(result).toStrictEqual(true); +}); + +test('isEtokenOutput() correctly invalidates an eToken output hex', async () => { + const result = isEtokenOutput( + '5434c500001010453454e442069b8431ddecf775393b1b36aa1d0ddcd7b342f1157b9671a03747378ed35ea0d08000000000000012c080000000000002008', + ); + expect(result).toStrictEqual(false); +}); + +test('isEtokenOutput() correctly handles null input', async () => { + const result = isEtokenOutput(null); + expect(result).toStrictEqual(false); +}); + +test('isEtokenOutput() correctly handles non-string input', async () => { + const result = isEtokenOutput(7623723323); + expect(result).toStrictEqual(false); +}); + +test('extractCashtabMessage() correctly extracts a Cashtab message', async () => { + const result = extractCashtabMessage( + '6a04007461620b63617368746162756c6172', + ); + expect(result).toStrictEqual('63617368746162756c6172'); +}); + +test('extractExternalMessage() correctly extracts an external message', async () => { + const result = extractExternalMessage('6a0d62696e676f656c65637472756d'); + expect(result).toStrictEqual('62696e676f656c65637472756d'); +}); diff --git a/web/cashtab/src/components/Send/__tests__/__snapshots__/Send.test.js.snap b/web/cashtab/src/components/Send/__tests__/__snapshots__/Send.test.js.snap --- a/web/cashtab/src/components/Send/__tests__/__snapshots__/Send.test.js.snap +++ b/web/cashtab/src/components/Send/__tests__/__snapshots__/Send.test.js.snap @@ -388,7 +388,7 @@ ,
Signatures
, @@ -826,7 +826,7 @@ ,
Signatures
, @@ -1272,7 +1272,7 @@ ,
Signatures
, @@ -1710,7 +1710,7 @@ ,
Signatures
, @@ -2148,7 +2148,7 @@ ,
Signatures
, diff --git a/web/cashtab/src/components/Wallet/Tx.js b/web/cashtab/src/components/Wallet/Tx.js --- a/web/cashtab/src/components/Wallet/Tx.js +++ b/web/cashtab/src/components/Wallet/Tx.js @@ -31,16 +31,15 @@ font-size: 0.8rem; } `; -const OpReturnType = styled.div` +const OpReturnType = styled.span` text-align: left; width: 300%; - max-height: 130px; + max-height: 170px; padding: 3px; - padding-left: 14px; - padding-right: 25px; + margin: auto; word-break: break-word; - overflow: hidden; - text-overflow: ellipsis; + padding-left: 13px; + padding-right: 30px; `; const SentLabel = styled.span` font-weight: bold; @@ -50,6 +49,18 @@ font-weight: bold; color: ${props => props.theme.primary} !important; `; +const CashtabMessageLabel = styled.span` + text-align: left; + font-weight: bold; + color: ${props => props.theme.primary} !important; + white-space: nowrap; +`; +const MessageLabel = styled.span` + text-align: left; + font-weight: bold; + color: ${props => props.theme.secondary} !important; + white-space: nowrap; +`; const TxIcon = styled.div` svg { width: 32px; @@ -379,7 +390,15 @@ <>
- Message: + {data.isCashtabMessage ? ( + + Cashtab Message + + ) : ( + + External Message + + )}
{data.opReturnMessage ? Buffer.from( diff --git a/web/cashtab/src/components/Wallet/__tests__/__snapshots__/Wallet.test.js.snap b/web/cashtab/src/components/Wallet/__tests__/__snapshots__/Wallet.test.js.snap --- a/web/cashtab/src/components/Wallet/__tests__/__snapshots__/Wallet.test.js.snap +++ b/web/cashtab/src/components/Wallet/__tests__/__snapshots__/Wallet.test.js.snap @@ -3,7 +3,7 @@ exports[`Wallet with BCH balances 1`] = ` Array [
,
0 @@ -119,16 +119,16 @@
,
XEC
eToken @@ -140,7 +140,7 @@ exports[`Wallet with BCH balances and tokens 1`] = ` Array [
,
0 @@ -256,16 +256,16 @@
,
XEC
eToken @@ -277,14 +277,14 @@ exports[`Wallet with BCH balances and tokens and state field 1`] = ` Array [
0.06 XEC
,
$ NaN @@ -375,16 +375,16 @@
,
XEC
eToken @@ -396,7 +396,7 @@ exports[`Wallet without BCH balance 1`] = ` Array [
,
0 @@ -512,16 +512,16 @@
,
XEC
eToken diff --git a/web/cashtab/src/hooks/__mocks__/mockParsedTxs.js b/web/cashtab/src/hooks/__mocks__/mockParsedTxs.js --- a/web/cashtab/src/hooks/__mocks__/mockParsedTxs.js +++ b/web/cashtab/src/hooks/__mocks__/mockParsedTxs.js @@ -9,6 +9,7 @@ 'bitcoincash:qphpmfj0qn7znklqhrfn5dq7qh36l3vxavu346vqcl', height: 674993, outgoingTx: true, + isCashtabMessage: false, opReturnMessage: '', tokenTx: false, txid: '089f2188d5771a7de0589def2b8d6c1a1f33f45b6de26d9a0ef32782f019ecf1', @@ -25,6 +26,7 @@ 'bitcoincash:qpmytrdsakt0axrrlswvaj069nat3p9s7ct4lsf8k9', height: 672077, outgoingTx: false, + isCashtabMessage: false, opReturnMessage: '', tokenTx: false, txid: '42d39fbe068a40fe691f987b22fdf04b80f94d71d2fec20a58125e7b1a06d2a9', @@ -41,6 +43,7 @@ 'bitcoincash:qzj5zu6fgg8v2we82gh76xnrk9njcregluzgaztm45', height: 674444, outgoingTx: true, + isCashtabMessage: false, opReturnMessage: '', tokenTx: true, txid: 'ffe3a7500dbcc98021ad581c98d9947054d1950a7f3416664715066d3d20ad72', @@ -56,6 +59,7 @@ 'bitcoincash:qpmytrdsakt0axrrlswvaj069nat3p9s7ct4lsf8k9', height: 674143, outgoingTx: false, + isCashtabMessage: false, opReturnMessage: '', tokenTx: true, txid: '618d0dd8c0c5fa5a34c6515c865dd72bb76f8311cd6ee9aef153bab20dabc0e6', @@ -69,9 +73,10 @@ confirmations: 59, destinationAddress: undefined, height: undefined, - opReturnMessage: new Buffer('testing message 12'), + opReturnMessage: new Buffer('bingoelectrum'), outgoingTx: false, tokenTx: false, + isCashtabMessage: false, txid: 'dd35690b0cefd24dcc08acba8694ecd49293f365a81372cb66c8f1c1291d97c5', }, ]; @@ -83,9 +88,10 @@ confirmations: 70, destinationAddress: undefined, height: undefined, - opReturnMessage: new Buffer('testing message 13'), + opReturnMessage: new Buffer('cashtabular'), outgoingTx: false, tokenTx: false, + isCashtabMessage: true, txid: '5adc33b5c0509b31c6da359177b19467c443bdc4dd37c283c0f87244c0ad63af', }, ]; diff --git a/web/cashtab/src/hooks/__mocks__/mockTxDataWithPassthrough.js b/web/cashtab/src/hooks/__mocks__/mockTxDataWithPassthrough.js --- a/web/cashtab/src/hooks/__mocks__/mockTxDataWithPassthrough.js +++ b/web/cashtab/src/hooks/__mocks__/mockTxDataWithPassthrough.js @@ -789,8 +789,8 @@ value: 0, n: 0, scriptPubKey: { - asm: 'OP_RETURN 621 74657374696e67206d657373616765203132', - hex: '6a026d021274657374696e67206d657373616765203132', + asm: 'OP_RETURN 62696e676f656c65637472756d', + hex: '6a0d62696e676f656c65637472756d', type: 'nulldata', }, }, @@ -840,10 +840,11 @@ value: 0, n: 0, scriptPubKey: { - asm: 'OP_RETURN 621 74657374696e67206d657373616765203133', - hex: '6a026d021274657374696e67206d657373616765203133', + asm: 'OP_RETURN 1650553856 63617368746162756c6172', + hex: '6a04007461620b63617368746162756c6172', type: 'nulldata', }, + value: '0', }, { value: 0.000045, 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 @@ -1,5 +1,11 @@ import BigNumber from 'bignumber.js'; -import { currency } from '@components/Common/Ticker'; +import { + currency, + isCashtabOutput, + isEtokenOutput, + extractCashtabMessage, + extractExternalMessage, +} from '@components/Common/Ticker'; import { isValidTokenStats } from '@utils/validation'; import SlpWallet from 'minimal-slp-wallet'; import { @@ -30,16 +36,6 @@ return apiArray[apiIndex]; }; - // filter out prefixes for OP_RETURN encoded messages - // Note: only for use with encoded message strings - const removeOpReturnPrefixes = asmStr => { - if (asmStr.includes(' 621')) { - //strip out the 621 (6d02) prefix if exists - asmStr = asmStr.replace(' 621', ''); - } - return asmStr; - }; - const flattenTransactions = ( txHistory, txCount = currency.txHistoryCount, @@ -126,9 +122,11 @@ let amountSent = 0; let amountReceived = 0; let opReturnMessage = ''; + let isCashtabMessage = false; // Assume an incoming transaction let outgoingTx = false; let tokenTx = false; + let substring = ''; // If vin includes tx address, this is an outgoing tx // Note that with bch-input data, we do not have input amounts @@ -147,19 +145,37 @@ if ( !Object.keys(thisOutput.scriptPubKey).includes('addresses') ) { - let asm = thisOutput.scriptPubKey.asm; - if (asm.includes('OP_RETURN 5262419')) { - // assume this is an eToken tx for now - // future diffs will add additional NFT parsing logic in this segment + let hex = thisOutput.scriptPubKey.hex; + + if (isEtokenOutput(hex)) { + // this is an eToken transaction tokenTx = true; + } else if (isCashtabOutput(hex)) { + // this is a cashtab.com generated message + try { + substring = extractCashtabMessage(hex); + opReturnMessage = Buffer.from(substring, '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, + ); + } } else { - // if this is not an eToken tx and does not contain addresses, then assume encoded message - asm = removeOpReturnPrefixes(asm); - let msgBody = asm.substr(asm.indexOf(' ') + 1); // extract everything after the OP_RETURN opcode + // this is an externally generated message try { - opReturnMessage = Buffer.from(msgBody, 'hex'); + substring = extractExternalMessage(hex); + opReturnMessage = Buffer.from(substring, 'hex'); } catch (err) { + // soft error if an unexpected or invalid cashtab hex is encountered opReturnMessage = ''; + console.log( + 'useBCH.parsedTxHistory() error: invalid external msg hex: ' + + substring, + ); } } continue; // skipping the remainder of tx data parsing logic in both token and OP_RETURN tx cases @@ -186,7 +202,7 @@ parsedTx.outgoingTx = outgoingTx; parsedTx.destinationAddress = destinationAddress; parsedTx.opReturnMessage = opReturnMessage; - + parsedTx.isCashtabMessage = isCashtabMessage; parsedTxHistory.push(parsedTx); } return parsedTxHistory; @@ -989,8 +1005,11 @@ optionalOpReturnMsg.trim() !== '' ) { const script = [ - BCH.Script.opcodes.OP_RETURN, - Buffer.from('6d02', 'hex'), + BCH.Script.opcodes.OP_RETURN, // 6a + Buffer.from( + currency.opReturn.appPrefixesHex.cashtab, + 'hex', + ), // 00746162 Buffer.from(optionalOpReturnMsg), ]; const data = BCH.Script.encode(script);