diff --git a/web/cashtab/src/components/Common/Ticker.js b/web/cashtab/src/components/Common/Ticker.js index 13dd79e8f..33d45fa5e 100644 --- a/web/cashtab/src/components/Common/Ticker.js +++ b/web/cashtab/src/components/Common/Ticker.js @@ -1,330 +1,341 @@ import mainLogo from '@assets/logo_primary.png'; import tokenLogo from '@assets/logo_secondary.png'; import cashaddr from 'ecashaddrjs'; import BigNumber from 'bignumber.js'; export const currency = { name: 'eCash', ticker: 'XEC', appUrl: 'cashtab.com', logo: mainLogo, legacyPrefix: 'bitcoincash', prefixes: ['ecash'], coingeckoId: 'ecash', defaultFee: 2.01, dustSats: 550, etokenSats: 546, cashDecimals: 2, blockExplorerUrl: 'https://explorer.bitcoinabc.org', tokenExplorerUrl: 'https://explorer.be.cash', blockExplorerUrlTestnet: 'https://texplorer.bitcoinabc.org', tokenName: 'eToken', tokenTicker: 'eToken', tokenIconSubmitApi: 'https://icons.etokens.cash/new', tokenLogo: tokenLogo, tokenPrefixes: ['etoken'], tokenIconsUrl: 'https://etoken-icons.s3.us-west-2.amazonaws.com', txHistoryCount: 5, hydrateUtxoBatchSize: 20, defaultSettings: { fiatCurrency: 'usd' }, notificationDurationShort: 3, notificationDurationLong: 5, newTokenDefaultUrl: 'https://cashtab.com/', opReturn: { opReturnPrefixHex: '6a', - opReturnPushDataHex: '04', opReturnAppPrefixLengthHex: '04', + opPushDataOne: '4c', appPrefixesHex: { eToken: '534c5000', cashtab: '00746162', }, }, settingsValidation: { fiatCurrency: [ 'usd', 'idr', 'krw', 'cny', 'zar', 'vnd', 'cad', 'nok', 'eur', 'gbp', 'jpy', 'try', 'rub', 'inr', 'brl', 'php', 'ils', 'clp', 'twd', 'hkd', 'bhd', 'sar', 'aud', 'nzd', ], }, fiatCurrencies: { usd: { name: 'US Dollar', symbol: '$', slug: 'usd' }, aud: { name: 'Australian Dollar', symbol: '$', slug: 'aud' }, bhd: { name: 'Bahraini Dinar', symbol: 'BD', slug: 'bhd' }, brl: { name: 'Brazilian Real', symbol: 'R$', slug: 'brl' }, gbp: { name: 'British Pound', symbol: '£', slug: 'gbp' }, cad: { name: 'Canadian Dollar', symbol: '$', slug: 'cad' }, clp: { name: 'Chilean Peso', symbol: '$', slug: 'clp' }, cny: { name: 'Chinese Yuan', symbol: '元', slug: 'cny' }, eur: { name: 'Euro', symbol: '€', slug: 'eur' }, hkd: { name: 'Hong Kong Dollar', symbol: 'HK$', slug: 'hkd' }, inr: { name: 'Indian Rupee', symbol: '₹', slug: 'inr' }, idr: { name: 'Indonesian Rupiah', symbol: 'Rp', slug: 'idr' }, ils: { name: 'Israeli Shekel', symbol: '₪', slug: 'ils' }, jpy: { name: 'Japanese Yen', symbol: '¥', slug: 'jpy' }, krw: { name: 'Korean Won', symbol: '₩', slug: 'krw' }, nzd: { name: 'New Zealand Dollar', symbol: '$', slug: 'nzd' }, nok: { name: 'Norwegian Krone', symbol: 'kr', slug: 'nok' }, php: { name: 'Philippine Peso', symbol: '₱', slug: 'php' }, rub: { name: 'Russian Ruble', symbol: 'р.', slug: 'rub' }, twd: { name: 'New Taiwan Dollar', symbol: 'NT$', slug: 'twd' }, sar: { name: 'Saudi Riyal', symbol: 'SAR', slug: 'sar' }, zar: { name: 'South African Rand', symbol: 'R', slug: 'zar' }, try: { name: 'Turkish Lira', symbol: '₺', slug: 'try' }, vnd: { name: 'Vietnamese đồng', symbol: 'đ', slug: 'vnd' }, }, }; -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) { // Note that this function validates prefix only // Check for prefix included in currency.prefixes array // For now, validation is handled by converting to bitcoincash: prefix and checksum // and relying on legacy validation methods of bitcoincash: prefix addresses // Also accept an address with no prefix, as some exchanges provide these for (let i = 0; i < currency.prefixes.length; i += 1) { // If the addressString being tested starts with an accepted prefix or no prefix at all if ( addressString.startsWith(currency.prefixes[i] + ':') || !addressString.includes(':') ) { return true; } } return false; } export function isValidTokenPrefix(addressString) { // Check for prefix included in currency.tokenPrefixes array // For now, validation is handled by converting to simpleledger: prefix and checksum // and relying on legacy validation methods of simpleledger: prefix addresses // For token addresses, do not accept an address with no prefix for (let i = 0; i < currency.tokenPrefixes.length; i += 1) { if (addressString.startsWith(currency.tokenPrefixes[i] + ':')) { return true; } } return false; } export function toLegacy(address) { let testedAddress; let legacyAddress; try { if (isValidCashPrefix(address)) { // Prefix-less addresses may be valid, but the cashaddr.decode function used below // will throw an error without a prefix. Hence, must ensure prefix to use that function. const hasPrefix = address.includes(':'); if (!hasPrefix) { testedAddress = currency.legacyPrefix + ':' + address; } else { testedAddress = address; } // Note: an `ecash:` checksum address with no prefix will not be validated by // parseAddress in Send.js // Only handle the case of prefixless address that is valid `bitcoincash:` address const { type, hash } = cashaddr.decode(testedAddress); legacyAddress = cashaddr.encode(currency.legacyPrefix, type, hash); } else { console.log(`Error: ${address} is not a cash address`); throw new Error( 'Address prefix is not a valid cash address with a prefix from the Ticker.prefixes array', ); } } catch (err) { return err; } return legacyAddress; } export function toLegacyArray(addressArray) { let cleanArray = []; // array of bch converted addresses to be returned try { if ( addressArray === null || addressArray === undefined || !addressArray.length || addressArray === '' ) { throw new Error('Invalid addressArray input'); } const arrayLength = addressArray.length; for (let i = 0; i < arrayLength; i++) { let testedAddress; let legacyAddress; let addressValueArr = addressArray[i].split(','); let address = addressValueArr[0]; let value = addressValueArr[1]; if (isValidCashPrefix(address)) { // Prefix-less addresses may be valid, but the cashaddr.decode function used below // will throw an error without a prefix. Hence, must ensure prefix to use that function. const hasPrefix = address.includes(':'); if (!hasPrefix) { testedAddress = currency.legacyPrefix + ':' + address; } else { testedAddress = address; } // Note: an `ecash:` checksum address with no prefix will not be validated by // parseAddress in Send.js // Only handle the case of prefixless address that is valid `bitcoincash:` address const { type, hash } = cashaddr.decode(testedAddress); legacyAddress = cashaddr.encode( currency.legacyPrefix, type, hash, ); let convertedArrayData = legacyAddress + ',' + value + '\n'; cleanArray.push(convertedArrayData); } else { console.log(`Error: ${address} is not a cash address`); throw new Error( 'Address prefix is not a valid cash address with a prefix from the Ticker.prefixes array', ); } } } catch (err) { return err; } return cleanArray; } export function parseAddress(BCH, addressString, isToken = false) { // Build return obj const addressInfo = { address: '', isValid: false, queryString: null, amount: null, }; // Parse address string for parameters const paramCheck = addressString.split('?'); let cleanAddress = paramCheck[0]; addressInfo.address = cleanAddress; // Validate address let isValidAddress; try { isValidAddress = BCH.Address.isCashAddress(cleanAddress); // Only accept addresses with ecash: prefix const { prefix } = cashaddr.decode(cleanAddress); // If the address does not have a valid prefix or token prefix if ( (!isToken && !currency.prefixes.includes(prefix)) || (isToken && !currency.tokenPrefixes.includes(prefix)) ) { // then it is not a valid destination address for XEC sends isValidAddress = false; } } catch (err) { isValidAddress = false; } addressInfo.isValid = isValidAddress; // Check for parameters // only the amount param is currently supported let queryString = null; let amount = null; if (paramCheck.length > 1) { queryString = paramCheck[1]; addressInfo.queryString = queryString; const addrParams = new URLSearchParams(queryString); if (addrParams.has('amount')) { // Amount in satoshis try { amount = new BigNumber(parseInt(addrParams.get('amount'))) .div(10 ** currency.cashDecimals) .toString(); } catch (err) { amount = null; } } } addressInfo.amount = amount; return addressInfo; } diff --git a/web/cashtab/src/components/Common/__mocks__/mockOpReturnParsedArray.js b/web/cashtab/src/components/Common/__mocks__/mockOpReturnParsedArray.js new file mode 100644 index 000000000..fa60e5640 --- /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 index 4dd66fe45..11499cf40 100644 --- a/web/cashtab/src/components/Common/__tests__/Ticker.test.js +++ b/web/cashtab/src/components/Common/__tests__/Ticker.test.js @@ -1,245 +1,226 @@ import { ValidationError } from 'ecashaddrjs'; import { isValidCashPrefix, isValidTokenPrefix, toLegacy, toLegacyArray, - isCashtabOutput, - isEtokenOutput, - extractCashtabMessage, - extractExternalMessage, - getETokenEncodingSubstring, - getCashtabEncodingSubstring, + parseOpReturn, + currency, } from '../Ticker'; import { validAddressArrayInput, validAddressArrayOutput, validLargeAddressArrayInput, 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( 'bitcoincash:qqd3qn4zazjhygk5a2vzw2gvqgqwempr4gjykk3wa0', ); expect(result).toStrictEqual(false); }); test('Correctly validates cash address with bitcoincash: checksum but no prefix', async () => { const result = isValidCashPrefix( 'qphpmfj0qn7znklqhrfn5dq7qh36l3vxavu346vqcl', ); expect(result).toStrictEqual(true); }); test('Correctly validates cash address with ecash: checksum but no prefix', async () => { const result = isValidCashPrefix( 'qqd3qn4zazjhygk5a2vzw2gvqgqwempr4gtfza25mc', ); expect(result).toStrictEqual(true); }); test('Correctly validates cash address with ecash: prefix', async () => { const result = isValidCashPrefix( 'ecash:qqd3qn4zazjhygk5a2vzw2gvqgqwempr4gtfza25mc', ); expect(result).toStrictEqual(true); }); test('Rejects token address with simpleledger: prefix', async () => { const result = isValidTokenPrefix( 'simpleledger:qpmytrdsakt0axrrlswvaj069nat3p9s7c8w5tu8gm', ); expect(result).toStrictEqual(false); }); test('Does not accept a valid token address without a prefix', async () => { const result = isValidTokenPrefix( 'qpmytrdsakt0axrrlswvaj069nat3p9s7c8w5tu8gm', ); expect(result).toStrictEqual(false); }); test('Correctly validates token address with etoken: prefix (prefix only, not checksum)', async () => { const result = isValidTokenPrefix( 'etoken:qpmytrdsakt0axrrlswvaj069nat3p9s7c8w5tu8gm', ); expect(result).toStrictEqual(true); }); test('Recognizes unaccepted token prefix (prefix only, not checksum)', async () => { const result = isValidTokenPrefix( 'wtftoken:qpmytrdsakt0axrrlswvaj069nat3p9s7c8w5tu8gm', ); expect(result).toStrictEqual(false); }); test('Knows that acceptable cash prefixes are not tokens', async () => { const result = isValidTokenPrefix( 'ecash:qqd3qn4zazjhygk5a2vzw2gvqgqwempr4gtfza25mc', ); expect(result).toStrictEqual(false); }); test('Address with unlisted prefix is invalid', async () => { const result = isValidCashPrefix( 'ecashdoge:qqd3qn4zazjhygk5a2vzw2gvqgqwempr4gtfza25mc', ); expect(result).toStrictEqual(false); }); test('toLegacy() converts a valid ecash: prefix address to a valid bitcoincash: prefix address', async () => { const result = toLegacy('ecash:qqd3qn4zazjhygk5a2vzw2gvqgqwempr4gtfza25mc'); expect(result).toStrictEqual( 'bitcoincash:qqd3qn4zazjhygk5a2vzw2gvqgqwempr4gjykk3wa0', ); }); test('toLegacy() accepts a valid BCH address with no prefix and returns with prefix', async () => { const result = toLegacy('qqd3qn4zazjhygk5a2vzw2gvqgqwempr4gjykk3wa0'); expect(result).toStrictEqual( 'bitcoincash:qqd3qn4zazjhygk5a2vzw2gvqgqwempr4gjykk3wa0', ); }); test('toLegacy throws error if input address has invalid checksum', async () => { const result = toLegacy('ecash:qqd3qn4zazjhygk5a2vzw2gvqgqwempr4gtfza25m'); expect(result).toStrictEqual( new ValidationError( 'Invalid checksum: ecash:qqd3qn4zazjhygk5a2vzw2gvqgqwempr4gtfza25m.', ), ); }); test('toLegacy throws error if input address has invalid prefix', async () => { const result = toLegacy( 'notecash:qqd3qn4zazjhygk5a2vzw2gvqgqwempr4gtfza25mc', ); expect(result).toStrictEqual( new Error( 'Address prefix is not a valid cash address with a prefix from the Ticker.prefixes array', ), ); }); test('toLegacyArray throws error if the addressArray input is null', async () => { const result = toLegacyArray(null); expect(result).toStrictEqual(new Error('Invalid addressArray input')); }); test('toLegacyArray throws error if the addressArray input is empty', async () => { const result = toLegacyArray([]); expect(result).toStrictEqual(new Error('Invalid addressArray input')); }); test('toLegacyArray throws error if the addressArray input is a number', async () => { const result = toLegacyArray(12345); expect(result).toStrictEqual(new Error('Invalid addressArray input')); }); test('toLegacyArray throws error if the addressArray input is undefined', async () => { const result = toLegacyArray(undefined); expect(result).toStrictEqual(new Error('Invalid addressArray input')); }); test('toLegacyArray successfully converts a standard sized valid addressArray input', async () => { const result = toLegacyArray(validAddressArrayInput); expect(result).toStrictEqual(validAddressArrayOutput); }); test('toLegacyArray successfully converts a large valid addressArray input', async () => { const result = toLegacyArray(validLargeAddressArrayInput); expect(result).toStrictEqual(validLargeAddressArrayOutput); }); test('toLegacyArray throws an error on an addressArray with invalid addresses', async () => { const result = toLegacyArray(invalidAddressArrayInput); expect(result).toStrictEqual( new ValidationError( 'Invalid checksum: ecash:qrqgwxrrrrrrrrrrrrrrrrrrrrrrrrr7zsvk.', ), ); }); -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 index 612225d31..4baea102e 100644 --- a/web/cashtab/src/hooks/useBCH.js +++ b/web/cashtab/src/hooks/useBCH.js @@ -1,1222 +1,1244 @@ import BigNumber from 'bignumber.js'; import { currency, isCashtabOutput, isEtokenOutput, extractCashtabMessage, extractExternalMessage, + parseOpReturn, } from '@components/Common/Ticker'; import { isValidTokenStats } from '@utils/validation'; import SlpWallet from 'minimal-slp-wallet'; import { toSmallestDenomination, fromSmallestDenomination, batchArray, flattenBatchedHydratedUtxos, isValidStoredWallet, checkNullUtxosForTokenStatus, confirmNonEtokenUtxos, } from '@utils/cashMethods'; import cashaddr from 'ecashaddrjs'; export default function useBCH() { const SEND_BCH_ERRORS = { INSUFFICIENT_FUNDS: 0, NETWORK_ERROR: 1, INSUFFICIENT_PRIORITY: 66, // ~insufficient fee DOUBLE_SPENDING: 18, MAX_UNCONFIRMED_TXS: 64, }; const getRestUrl = (apiIndex = 0) => { const apiString = process.env.REACT_APP_NETWORK === `mainnet` ? process.env.REACT_APP_BCHA_APIS : process.env.REACT_APP_BCHA_APIS_TEST; const apiArray = apiString.split(','); return apiArray[apiIndex]; }; const flattenTransactions = ( txHistory, txCount = currency.txHistoryCount, ) => { /* Convert txHistory, format [{address: '', transactions: [{height: '', tx_hash: ''}, ...{}]}, {}, {}] to flatTxHistory [{txid: '', blockheight: '', address: ''}] sorted by blockheight, newest transactions to oldest transactions */ let flatTxHistory = []; let includedTxids = []; for (let i = 0; i < txHistory.length; i += 1) { const { address, transactions } = txHistory[i]; for (let j = transactions.length - 1; j >= 0; j -= 1) { let flatTx = {}; flatTx.address = address; // If tx is unconfirmed, give arbitrarily high blockheight flatTx.height = transactions[j].height <= 0 ? 10000000 : transactions[j].height; flatTx.txid = transactions[j].tx_hash; // Only add this tx if the same transaction is not already in the array // This edge case can happen with older wallets, txs can be on multiple paths if (!includedTxids.includes(flatTx.txid)) { includedTxids.push(flatTx.txid); flatTxHistory.push(flatTx); } } } // Sort with most recent transaction at index 0 flatTxHistory.sort((a, b) => b.height - a.height); // Only return 10 return flatTxHistory.splice(0, txCount); }; const parseTxData = txData => { /* Desired output [ { txid: '', type: send, receive receivingAddress: '', quantity: amount bcha token: true/false tokenInfo: { tokenId: tokenQty: txType: mint, send, other } opReturnMessage: 'message extracted from asm' or '' } ] */ const parsedTxHistory = []; for (let i = 0; i < txData.length; i += 1) { const tx = txData[i]; const parsedTx = {}; // Move over info that does not need to be calculated parsedTx.txid = tx.txid; parsedTx.height = tx.height; let destinationAddress = tx.address; // If this tx had too many inputs to be parsed by getTxDataWithPassThrough, skip it // When this occurs, the tx will only have txid and height // So, it will not have 'vin' if (!Object.keys(tx).includes('vin')) { // Populate as a limited-info tx that can be expanded in a block explorer parsedTxHistory.push(parsedTx); continue; } parsedTx.confirmations = tx.confirmations; parsedTx.blocktime = tx.blocktime; let amountSent = 0; let amountReceived = 0; let opReturnMessage = ''; let isCashtabMessage = false; // Assume an incoming transaction let outgoingTx = false; let tokenTx = false; let substring = ''; // get the address of the sender for this tx and encode into eCash address let senderBchAddress = tx.vin[0].address; const { type, hash } = cashaddr.decode(senderBchAddress); const senderAddress = cashaddr.encode('ecash', type, hash); // If vin includes tx address, this is an outgoing tx // Note that with bch-input data, we do not have input amounts for (let j = 0; j < tx.vin.length; j += 1) { const thisInput = tx.vin[j]; if (thisInput.address === tx.address) { // This is an outgoing transaction outgoingTx = true; } } // Iterate over vout to find how much was sent or received for (let j = 0; j < tx.vout.length; j += 1) { const thisOutput = tx.vout[j]; // If there is no addresses object in the output, it's either an OP_RETURN msg or token tx if ( !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, ); } } continue; // skipping the remainder of tx data parsing logic in both token and OP_RETURN tx cases } if ( thisOutput.scriptPubKey.addresses && thisOutput.scriptPubKey.addresses[0] === tx.address ) { if (outgoingTx) { // This amount is change continue; } amountReceived += thisOutput.value; } else if (outgoingTx) { amountSent += thisOutput.value; // Assume there's only one destination address, i.e. it was sent by a Cashtab wallet destinationAddress = thisOutput.scriptPubKey.addresses[0]; } } // Construct parsedTx parsedTx.amountSent = amountSent; parsedTx.amountReceived = amountReceived; parsedTx.tokenTx = tokenTx; parsedTx.outgoingTx = outgoingTx; parsedTx.replyAddress = senderAddress; parsedTx.destinationAddress = destinationAddress; parsedTx.opReturnMessage = opReturnMessage; parsedTx.isCashtabMessage = isCashtabMessage; parsedTxHistory.push(parsedTx); } return parsedTxHistory; }; const getTxHistory = async (BCH, addresses) => { let txHistoryResponse; try { //console.log(`API Call: BCH.Electrumx.utxo(addresses)`); //console.log(addresses); txHistoryResponse = await BCH.Electrumx.transactions(addresses); //console.log(`BCH.Electrumx.transactions(addresses) succeeded`); //console.log(`txHistoryResponse`, txHistoryResponse); if (txHistoryResponse.success && txHistoryResponse.transactions) { return txHistoryResponse.transactions; } else { // eslint-disable-next-line no-throw-literal throw new Error('Error in getTxHistory'); } } catch (err) { console.log(`Error in BCH.Electrumx.transactions(addresses):`); console.log(err); return err; } }; const getTxDataWithPassThrough = async (BCH, flatTx) => { // necessary as BCH.RawTransactions.getTxData does not return address or blockheight let txDataWithPassThrough = {}; try { txDataWithPassThrough = await BCH.RawTransactions.getTxData( flatTx.txid, ); } catch (err) { console.log( `Error in BCH.RawTransactions.getTxData(${flatTx.txid})`, ); console.log(err); // Include txid if you don't get it from the attempted response txDataWithPassThrough.txid = flatTx.txid; } txDataWithPassThrough.height = flatTx.height; txDataWithPassThrough.address = flatTx.address; return txDataWithPassThrough; }; const getTxData = async (BCH, txHistory) => { // Flatten tx history let flatTxs = flattenTransactions(txHistory); // Build array of promises to get tx data for all 10 transactions let txDataPromises = []; for (let i = 0; i < flatTxs.length; i += 1) { const txDataPromise = await getTxDataWithPassThrough( BCH, flatTxs[i], ); txDataPromises.push(txDataPromise); } // Get txData for the 10 most recent transactions let txDataPromiseResponse; try { txDataPromiseResponse = await Promise.all(txDataPromises); const parsed = parseTxData(txDataPromiseResponse); return parsed; } catch (err) { console.log(`Error in Promise.all(txDataPromises):`); console.log(err); return err; } }; const parseTokenInfoForTxHistory = (BCH, parsedTx, tokenInfo) => { // Address at which the eToken was received const { destinationAddress } = parsedTx; // Here in cashtab, destinationAddress is in bitcoincash: format // In the API response of tokenInfo, this will be in simpleledger: format // So, must convert to simpleledger const receivingSlpAddress = BCH.SLP.Address.toSLPAddress(destinationAddress); const { transactionType, sendInputsFull, sendOutputsFull } = tokenInfo; const sendingTokenAddresses = []; // Scan over inputs to find out originating addresses for (let i = 0; i < sendInputsFull.length; i += 1) { const sendingAddress = sendInputsFull[i].address; sendingTokenAddresses.push(sendingAddress); } // Scan over outputs to find out how much was sent let qtySent = new BigNumber(0); let qtyReceived = new BigNumber(0); for (let i = 0; i < sendOutputsFull.length; i += 1) { if (sendingTokenAddresses.includes(sendOutputsFull[i].address)) { // token change and should be ignored, unless it's a genesis transaction // then this is the amount created if (transactionType === 'GENESIS') { qtyReceived = qtyReceived.plus( new BigNumber(sendOutputsFull[i].amount), ); } continue; } if (parsedTx.outgoingTx) { qtySent = qtySent.plus( new BigNumber(sendOutputsFull[i].amount), ); } else { // Only if this matches the receiving address if (sendOutputsFull[i].address === receivingSlpAddress) { qtyReceived = qtyReceived.plus( new BigNumber(sendOutputsFull[i].amount), ); } } } const cashtabTokenInfo = {}; cashtabTokenInfo.qtySent = qtySent.toString(); cashtabTokenInfo.qtyReceived = qtyReceived.toString(); cashtabTokenInfo.tokenId = tokenInfo.tokenIdHex; cashtabTokenInfo.tokenName = tokenInfo.tokenName; cashtabTokenInfo.tokenTicker = tokenInfo.tokenTicker; cashtabTokenInfo.transactionType = transactionType; return cashtabTokenInfo; }; const addTokenTxDataToSingleTx = async (BCH, parsedTx) => { // Accept one parsedTx // If it's not a token tx, just return it as is and do not parse for token data if (!parsedTx.tokenTx) { return parsedTx; } // If it could be a token tx, do an API call to get token info and return it let tokenData; try { tokenData = await BCH.SLP.Utils.txDetails(parsedTx.txid); } catch (err) { console.log( `Error in parsing BCH.SLP.Utils.txDetails(${parsedTx.txid})`, ); console.log(err); // This is not a token tx parsedTx.tokenTx = false; return parsedTx; } const { tokenInfo } = tokenData; parsedTx.tokenInfo = parseTokenInfoForTxHistory( BCH, parsedTx, tokenInfo, ); return parsedTx; }; const addTokenTxData = async (BCH, parsedTxs) => { // Collect all txids for token transactions into array of promises // Promise.all to get their tx history // Add a tokeninfo object to parsedTxs for token txs // Get txData for the 10 most recent transactions // Build array of promises to get tx data for all 10 transactions let tokenTxDataPromises = []; for (let i = 0; i < parsedTxs.length; i += 1) { const txDataPromise = await addTokenTxDataToSingleTx( BCH, parsedTxs[i], ); tokenTxDataPromises.push(txDataPromise); } let tokenTxDataPromiseResponse; try { tokenTxDataPromiseResponse = await Promise.all(tokenTxDataPromises); return tokenTxDataPromiseResponse; } catch (err) { console.log(`Error in Promise.all(tokenTxDataPromises):`); console.log(err); return err; } }; // Split out the BCH.Electrumx.utxo(addresses) call from the getSlpBalancesandUtxos function // If utxo set has not changed, you do not need to hydrate the utxo set // This drastically reduces calls to the API const getUtxos = async (BCH, addresses) => { let utxosResponse; try { //console.log(`API Call: BCH.Electrumx.utxo(addresses)`); //console.log(addresses); utxosResponse = await BCH.Electrumx.utxo(addresses); //console.log(`BCH.Electrumx.utxo(addresses) succeeded`); //console.log(`utxosResponse`, utxosResponse); return utxosResponse.utxos; } catch (err) { console.log(`Error in BCH.Electrumx.utxo(addresses):`); return err; } }; const getHydratedUtxoDetails = async (BCH, utxos) => { const hydrateUtxosPromises = []; for (let i = 0; i < utxos.length; i += 1) { let thisAddress = utxos[i].address; let theseUtxos = utxos[i].utxos; const batchedUtxos = batchArray( theseUtxos, currency.hydrateUtxoBatchSize, ); // Iterate over each utxo in this address field for (let j = 0; j < batchedUtxos.length; j += 1) { const utxoSetForThisPromise = [ { utxos: batchedUtxos[j], address: thisAddress }, ]; const thisPromise = BCH.SLP.Utils.hydrateUtxos( utxoSetForThisPromise, ); hydrateUtxosPromises.push(thisPromise); } } let hydratedUtxoDetails; try { hydratedUtxoDetails = await Promise.all(hydrateUtxosPromises); const flattenedBatchedHydratedUtxos = flattenBatchedHydratedUtxos(hydratedUtxoDetails); return flattenedBatchedHydratedUtxos; } catch (err) { console.log(`Error in Promise.all(hydrateUtxosPromises)`); console.log(err); return err; } }; const fetchTxDataForNullUtxos = async (BCH, nullUtxos) => { // Check nullUtxos. If they aren't eToken txs, count them console.log( `Null utxos found, checking OP_RETURN fields to confirm they are not eToken txs.`, ); const txids = []; for (let i = 0; i < nullUtxos.length; i += 1) { // Batch API call to get their OP_RETURN asm info txids.push(nullUtxos[i].tx_hash); } let nullUtxoTxData; try { nullUtxoTxData = await BCH.Electrumx.txData(txids); console.log(`nullUtxoTxData`, nullUtxoTxData.transactions); // Scan tx data for each utxo to confirm they are not eToken txs const txDataResults = nullUtxoTxData.transactions; const nonEtokenUtxos = checkNullUtxosForTokenStatus(txDataResults); return nonEtokenUtxos; } catch (err) { console.log(`Error in checkNullUtxosForTokenStatus(nullUtxos)`); console.log(`nullUtxos`, nullUtxos); // If error, ignore these utxos, will be updated next utxo set refresh return []; } }; const getSlpBalancesAndUtxos = async (BCH, hydratedUtxoDetails) => { let hydratedUtxos = []; for (let i = 0; i < hydratedUtxoDetails.slpUtxos.length; i += 1) { const hydratedUtxosAtAddress = hydratedUtxoDetails.slpUtxos[i]; for (let j = 0; j < hydratedUtxosAtAddress.utxos.length; j += 1) { const hydratedUtxo = hydratedUtxosAtAddress.utxos[j]; hydratedUtxo.address = hydratedUtxosAtAddress.address; hydratedUtxos.push(hydratedUtxo); } } //console.log(`hydratedUtxos`, hydratedUtxos); // WARNING // If you hit rate limits, your above utxos object will come back with `isValid` as null, but otherwise ok // You need to throw an error before setting nonSlpUtxos and slpUtxos in this case const nullUtxos = hydratedUtxos.filter(utxo => utxo.isValid === null); if (nullUtxos.length > 0) { console.log(`${nullUtxos.length} null utxos found!`); console.log('nullUtxos', nullUtxos); const nullNonEtokenUtxos = await fetchTxDataForNullUtxos( BCH, nullUtxos, ); // Set isValid === false for nullUtxos that are confirmed non-eToken hydratedUtxos = confirmNonEtokenUtxos( hydratedUtxos, nullNonEtokenUtxos, ); } // Prevent app from treating slpUtxos as nonSlpUtxos // Must enforce === false as api will occasionally return utxo.isValid === null // Do not classify any utxos that include token information as nonSlpUtxos const nonSlpUtxos = hydratedUtxos.filter( utxo => utxo.isValid === false && utxo.value !== currency.etokenSats && !utxo.tokenName, ); // To be included in slpUtxos, the utxo must // have utxo.isValid = true // If utxo has a utxo.tokenQty field, i.e. not a minting baton, then utxo.tokenQty !== '0' const slpUtxos = hydratedUtxos.filter( utxo => utxo.isValid && !(utxo.tokenQty === '0'), ); let tokensById = {}; slpUtxos.forEach(slpUtxo => { let token = tokensById[slpUtxo.tokenId]; if (token) { // Minting baton does nto have a slpUtxo.tokenQty type if (slpUtxo.tokenQty) { token.balance = token.balance.plus( new BigNumber(slpUtxo.tokenQty), ); } //token.hasBaton = slpUtxo.transactionType === "genesis"; if (slpUtxo.utxoType && !token.hasBaton) { token.hasBaton = slpUtxo.utxoType === 'minting-baton'; } // Examples of slpUtxo /* Genesis transaction: { address: "bitcoincash:qrhzv5t79e2afc3rdutcu0d3q20gl7ul3ue58whah6" decimals: 9 height: 617564 isValid: true satoshis: 546 tokenDocumentHash: "" tokenDocumentUrl: "developer.bitcoin.com" tokenId: "6c41f244676ecfcbe3b4fabee2c72c2dadf8d74f8849afabc8a549157db69199" tokenName: "PiticoLaunch" tokenTicker: "PTCL" tokenType: 1 tx_hash: "6c41f244676ecfcbe3b4fabee2c72c2dadf8d74f8849afabc8a549157db69199" tx_pos: 2 txid: "6c41f244676ecfcbe3b4fabee2c72c2dadf8d74f8849afabc8a549157db69199" utxoType: "minting-baton" value: 546 vout: 2 } Send transaction: { address: "bitcoincash:qrhzv5t79e2afc3rdutcu0d3q20gl7ul3ue58whah6" decimals: 9 height: 655115 isValid: true satoshis: 546 tokenDocumentHash: "" tokenDocumentUrl: "developer.bitcoin.com" tokenId: "6c41f244676ecfcbe3b4fabee2c72c2dadf8d74f8849afabc8a549157db69199" tokenName: "PiticoLaunch" tokenQty: 1.123456789 tokenTicker: "PTCL" tokenType: 1 transactionType: "send" tx_hash: "dea400f963bc9f51e010f88533010f8d1f82fc2bcc485ff8500c3a82b25abd9e" tx_pos: 1 txid: "dea400f963bc9f51e010f88533010f8d1f82fc2bcc485ff8500c3a82b25abd9e" utxoType: "token" value: 546 vout: 1 } */ } else { token = {}; token.info = slpUtxo; token.tokenId = slpUtxo.tokenId; if (slpUtxo.tokenQty) { token.balance = new BigNumber(slpUtxo.tokenQty); } else { token.balance = new BigNumber(0); } if (slpUtxo.utxoType) { token.hasBaton = slpUtxo.utxoType === 'minting-baton'; } else { token.hasBaton = false; } tokensById[slpUtxo.tokenId] = token; } }); const tokens = Object.values(tokensById); // console.log(`tokens`, tokens); return { tokens, nonSlpUtxos, slpUtxos, }; }; const calcFee = ( BCH, utxos, p2pkhOutputNumber = 2, satoshisPerByte = currency.defaultFee, ) => { const byteCount = BCH.BitcoinCash.getByteCount( { P2PKH: utxos.length }, { P2PKH: p2pkhOutputNumber }, ); const txFee = Math.ceil(satoshisPerByte * byteCount); return txFee; }; const createToken = async (BCH, wallet, feeInSatsPerByte, configObj) => { try { // Throw error if wallet does not have utxo set in state if (!isValidStoredWallet(wallet)) { const walletError = new Error(`Invalid wallet`); throw walletError; } const utxos = wallet.state.slpBalancesAndUtxos.nonSlpUtxos; const CREATION_ADDR = wallet.Path1899.cashAddress; const inputUtxos = []; let transactionBuilder; // instance of transaction builder if (process.env.REACT_APP_NETWORK === `mainnet`) transactionBuilder = new BCH.TransactionBuilder(); else transactionBuilder = new BCH.TransactionBuilder('testnet'); let originalAmount = new BigNumber(0); let txFee = 0; for (let i = 0; i < utxos.length; i++) { const utxo = utxos[i]; originalAmount = originalAmount.plus(new BigNumber(utxo.value)); const vout = utxo.vout; const txid = utxo.txid; // add input with txid and index of vout transactionBuilder.addInput(txid, vout); inputUtxos.push(utxo); txFee = calcFee(BCH, inputUtxos, 3, feeInSatsPerByte); if ( originalAmount .minus(new BigNumber(currency.etokenSats)) .minus(new BigNumber(txFee)) .gte(0) ) { break; } } // amount to send back to the remainder address. const remainder = originalAmount .minus(new BigNumber(currency.etokenSats)) .minus(new BigNumber(txFee)); if (remainder.lt(0)) { const error = new Error(`Insufficient funds`); error.code = SEND_BCH_ERRORS.INSUFFICIENT_FUNDS; throw error; } // Generate the OP_RETURN entry for an SLP GENESIS transaction. const script = BCH.SLP.TokenType1.generateGenesisOpReturn(configObj); // OP_RETURN needs to be the first output in the transaction. transactionBuilder.addOutput(script, 0); // add output w/ address and amount to send transactionBuilder.addOutput(CREATION_ADDR, currency.etokenSats); // Send change to own address if (remainder.gte(new BigNumber(currency.etokenSats))) { transactionBuilder.addOutput( CREATION_ADDR, parseInt(remainder), ); } // Sign the transactions with the HD node. for (let i = 0; i < inputUtxos.length; i++) { const utxo = inputUtxos[i]; transactionBuilder.sign( i, BCH.ECPair.fromWIF(utxo.wif), undefined, transactionBuilder.hashTypes.SIGHASH_ALL, utxo.value, ); } // build tx const tx = transactionBuilder.build(); // output rawhex const hex = tx.toHex(); // Broadcast transaction to the network const txidStr = await BCH.RawTransactions.sendRawTransaction([hex]); if (txidStr && txidStr[0]) { console.log(`${currency.ticker} txid`, txidStr[0]); } let link; if (process.env.REACT_APP_NETWORK === `mainnet`) { link = `${currency.tokenExplorerUrl}/tx/${txidStr}`; } else { link = `${currency.blockExplorerUrlTestnet}/tx/${txidStr}`; } //console.log(`link`, link); return link; } catch (err) { if (err.error === 'insufficient priority (code 66)') { err.code = SEND_BCH_ERRORS.INSUFFICIENT_PRIORITY; } else if (err.error === 'txn-mempool-conflict (code 18)') { err.code = SEND_BCH_ERRORS.DOUBLE_SPENDING; } else if (err.error === 'Network Error') { err.code = SEND_BCH_ERRORS.NETWORK_ERROR; } else if ( err.error === 'too-long-mempool-chain, too many unconfirmed ancestors [limit: 25] (code 64)' ) { err.code = SEND_BCH_ERRORS.MAX_UNCONFIRMED_TXS; } console.log(`error: `, err); throw err; } }; // No unit tests for this function as it is only an API wrapper // Return false if do not get a valid response const getTokenStats = async (BCH, tokenId) => { let tokenStats; try { tokenStats = await BCH.SLP.Utils.tokenStats(tokenId); if (isValidTokenStats(tokenStats)) { return tokenStats; } } catch (err) { console.log(`Error fetching token stats for tokenId ${tokenId}`); console.log(err); return false; } }; const sendToken = async ( BCH, wallet, slpBalancesAndUtxos, { tokenId, amount, tokenReceiverAddress }, ) => { // Handle error of user having no BCH if (slpBalancesAndUtxos.nonSlpUtxos.length === 0) { throw new Error( `You need some ${currency.ticker} to send ${currency.tokenTicker}`, ); } const largestBchUtxo = slpBalancesAndUtxos.nonSlpUtxos.reduce( (previous, current) => previous.value > current.value ? previous : current, ); const bchECPair = BCH.ECPair.fromWIF(largestBchUtxo.wif); const tokenUtxos = slpBalancesAndUtxos.slpUtxos.filter( (utxo, index) => { if ( utxo && // UTXO is associated with a token. utxo.tokenId === tokenId && // UTXO matches the token ID. utxo.utxoType === 'token' // UTXO is not a minting baton. ) { return true; } return false; }, ); if (tokenUtxos.length === 0) { throw new Error( 'No token UTXOs for the specified token could be found.', ); } // BEGIN transaction construction. // instance of transaction builder let transactionBuilder; if (process.env.REACT_APP_NETWORK === 'mainnet') { transactionBuilder = new BCH.TransactionBuilder(); } else transactionBuilder = new BCH.TransactionBuilder('testnet'); const originalAmount = largestBchUtxo.value; transactionBuilder.addInput( largestBchUtxo.tx_hash, largestBchUtxo.tx_pos, ); let finalTokenAmountSent = new BigNumber(0); let tokenAmountBeingSentToAddress = new BigNumber(amount); let tokenUtxosBeingSpent = []; for (let i = 0; i < tokenUtxos.length; i++) { finalTokenAmountSent = finalTokenAmountSent.plus( new BigNumber(tokenUtxos[i].tokenQty), ); transactionBuilder.addInput( tokenUtxos[i].tx_hash, tokenUtxos[i].tx_pos, ); tokenUtxosBeingSpent.push(tokenUtxos[i]); if (tokenAmountBeingSentToAddress.lte(finalTokenAmountSent)) { break; } } const slpSendObj = BCH.SLP.TokenType1.generateSendOpReturn( tokenUtxosBeingSpent, tokenAmountBeingSentToAddress.toString(), ); const slpData = slpSendObj.script; // Add OP_RETURN as first output. transactionBuilder.addOutput(slpData, 0); // Send dust transaction representing tokens being sent. transactionBuilder.addOutput( BCH.SLP.Address.toLegacyAddress(tokenReceiverAddress), currency.etokenSats, ); // Return any token change back to the sender. if (slpSendObj.outputs > 1) { // Change goes back to where slp utxo came from transactionBuilder.addOutput( BCH.SLP.Address.toLegacyAddress( tokenUtxosBeingSpent[0].address, ), currency.etokenSats, ); } // get byte count to calculate fee. paying 1 sat // Note: This may not be totally accurate. Just guessing on the byteCount size. const txFee = calcFee( BCH, tokenUtxosBeingSpent, 5, 1.1 * currency.defaultFee, ); // amount to send back to the sending address. It's the original amount - 1 sat/byte for tx size const remainder = originalAmount - txFee - currency.etokenSats * 2; if (remainder < 1) { throw new Error('Selected UTXO does not have enough satoshis'); } // Last output: send the BCH change back to the wallet. // Send it back from whence it came transactionBuilder.addOutput( BCH.Address.toLegacyAddress(largestBchUtxo.address), remainder, ); // Sign the transaction with the private key for the BCH UTXO paying the fees. let redeemScript; transactionBuilder.sign( 0, bchECPair, redeemScript, transactionBuilder.hashTypes.SIGHASH_ALL, originalAmount, ); // Sign each token UTXO being consumed. for (let i = 0; i < tokenUtxosBeingSpent.length; i++) { const thisUtxo = tokenUtxosBeingSpent[i]; const accounts = [wallet.Path245, wallet.Path145, wallet.Path1899]; const utxoEcPair = BCH.ECPair.fromWIF( accounts .filter(acc => acc.cashAddress === thisUtxo.address) .pop().fundingWif, ); transactionBuilder.sign( 1 + i, utxoEcPair, redeemScript, transactionBuilder.hashTypes.SIGHASH_ALL, thisUtxo.value, ); } // build tx const tx = transactionBuilder.build(); // output rawhex const hex = tx.toHex(); // console.log(`Transaction raw hex: `, hex); // END transaction construction. const txidStr = await BCH.RawTransactions.sendRawTransaction([hex]); if (txidStr && txidStr[0]) { console.log(`${currency.tokenTicker} txid`, txidStr[0]); } let link; if (process.env.REACT_APP_NETWORK === `mainnet`) { link = `${currency.blockExplorerUrl}/tx/${txidStr}`; } else { link = `${currency.blockExplorerUrlTestnet}/tx/${txidStr}`; } //console.log(`link`, link); return link; }; const signPkMessage = async (BCH, pk, message) => { try { let signature = await BCH.BitcoinCash.signMessageWithPrivKey( pk, message, ); return signature; } catch (err) { console.log(`useBCH.signPkMessage() error: `, err); throw err; } }; const sendXec = async ( BCH, wallet, utxos, feeInSatsPerByte, optionalOpReturnMsg, isOneToMany, destinationAddressAndValueArray, destinationAddress, sendAmount, ) => { try { let value = new BigNumber(0); if (isOneToMany) { // this is a one to many XEC transaction if ( !destinationAddressAndValueArray || !destinationAddressAndValueArray.length ) { throw new Error('Invalid destinationAddressAndValueArray'); } const arrayLength = destinationAddressAndValueArray.length; for (let i = 0; i < arrayLength; i++) { // add the total value being sent in this array of recipients value = BigNumber.sum( value, new BigNumber( destinationAddressAndValueArray[i].split(',')[1], ), ); } // If user is attempting to send an aggregate value that is less than minimum accepted by the backend if ( value.lt( new BigNumber( fromSmallestDenomination( currency.dustSats, ).toString(), ), ) ) { // Throw the same error given by the backend attempting to broadcast such a tx throw new Error('dust'); } } else { // this is a one to one XEC transaction then check sendAmount // note: one to many transactions won't be sending a single sendAmount if (!sendAmount) { return null; } value = new BigNumber(sendAmount); // If user is attempting to send less than minimum accepted by the backend if ( value.lt( new BigNumber( fromSmallestDenomination( currency.dustSats, ).toString(), ), ) ) { // Throw the same error given by the backend attempting to broadcast such a tx throw new Error('dust'); } } const inputUtxos = []; let transactionBuilder; // instance of transaction builder if (process.env.REACT_APP_NETWORK === `mainnet`) transactionBuilder = new BCH.TransactionBuilder(); else transactionBuilder = new BCH.TransactionBuilder('testnet'); const satoshisToSend = toSmallestDenomination(value); // Throw validation error if toSmallestDenomination returns false if (!satoshisToSend) { const error = new Error( `Invalid decimal places for send amount`, ); throw error; } // Start of building the OP_RETURN output. // only build the OP_RETURN output if the user supplied it if ( typeof optionalOpReturnMsg !== 'undefined' && optionalOpReturnMsg.trim() !== '' ) { const script = [ BCH.Script.opcodes.OP_RETURN, // 6a Buffer.from( currency.opReturn.appPrefixesHex.cashtab, 'hex', ), // 00746162 Buffer.from(optionalOpReturnMsg), ]; const data = BCH.Script.encode(script); transactionBuilder.addOutput(data, 0); } // End of building the OP_RETURN output. let originalAmount = new BigNumber(0); let txFee = 0; for (let i = 0; i < utxos.length; i++) { const utxo = utxos[i]; originalAmount = originalAmount.plus(utxo.value); const vout = utxo.vout; const txid = utxo.txid; // add input with txid and index of vout transactionBuilder.addInput(txid, vout); inputUtxos.push(utxo); txFee = calcFee(BCH, inputUtxos, 2, feeInSatsPerByte); if (originalAmount.minus(satoshisToSend).minus(txFee).gte(0)) { break; } } // Get change address from sending utxos // fall back to what is stored in wallet let REMAINDER_ADDR; // Validate address let isValidChangeAddress; try { REMAINDER_ADDR = inputUtxos[0].address; isValidChangeAddress = BCH.Address.isCashAddress(REMAINDER_ADDR); } catch (err) { isValidChangeAddress = false; } if (!isValidChangeAddress) { REMAINDER_ADDR = wallet.Path1899.cashAddress; } // amount to send back to the remainder address. const remainder = originalAmount.minus(satoshisToSend).minus(txFee); if (remainder.lt(0)) { const error = new Error(`Insufficient funds`); error.code = SEND_BCH_ERRORS.INSUFFICIENT_FUNDS; throw error; } if (isOneToMany) { // for one to many mode, add the multiple outputs from the array let arrayLength = destinationAddressAndValueArray.length; for (let i = 0; i < arrayLength; i++) { // add each send tx from the array as an output let outputAddress = destinationAddressAndValueArray[i].split(',')[0]; let outputValue = new BigNumber( destinationAddressAndValueArray[i].split(',')[1], ); transactionBuilder.addOutput( BCH.Address.toCashAddress(outputAddress), parseInt(toSmallestDenomination(outputValue)), ); } } else { // for one to one mode, add output w/ single address and amount to send transactionBuilder.addOutput( BCH.Address.toCashAddress(destinationAddress), parseInt(toSmallestDenomination(value)), ); } if (remainder.gte(new BigNumber(currency.dustSats))) { transactionBuilder.addOutput( REMAINDER_ADDR, parseInt(remainder), ); } // Sign the transactions with the HD node. for (let i = 0; i < inputUtxos.length; i++) { const utxo = inputUtxos[i]; transactionBuilder.sign( i, BCH.ECPair.fromWIF(utxo.wif), undefined, transactionBuilder.hashTypes.SIGHASH_ALL, utxo.value, ); } // build tx const tx = transactionBuilder.build(); // output rawhex const hex = tx.toHex(); // Broadcast transaction to the network const txidStr = await BCH.RawTransactions.sendRawTransaction([hex]); if (txidStr && txidStr[0]) { console.log(`${currency.ticker} txid`, txidStr[0]); } let link; if (process.env.REACT_APP_NETWORK === `mainnet`) { link = `${currency.blockExplorerUrl}/tx/${txidStr}`; } else { link = `${currency.blockExplorerUrlTestnet}/tx/${txidStr}`; } //console.log(`link`, link); return link; } catch (err) { if (err.error === 'insufficient priority (code 66)') { err.code = SEND_BCH_ERRORS.INSUFFICIENT_PRIORITY; } else if (err.error === 'txn-mempool-conflict (code 18)') { err.code = SEND_BCH_ERRORS.DOUBLE_SPENDING; } else if (err.error === 'Network Error') { err.code = SEND_BCH_ERRORS.NETWORK_ERROR; } else if ( err.error === 'too-long-mempool-chain, too many unconfirmed ancestors [limit: 25] (code 64)' ) { err.code = SEND_BCH_ERRORS.MAX_UNCONFIRMED_TXS; } console.log(`error: `, err); throw err; } }; const getBCH = (apiIndex = 0) => { let ConstructedSlpWallet; ConstructedSlpWallet = new SlpWallet('', { restURL: getRestUrl(apiIndex), }); return ConstructedSlpWallet.bchjs; }; return { getBCH, calcFee, getUtxos, getHydratedUtxoDetails, getSlpBalancesAndUtxos, getTxHistory, flattenTransactions, parseTxData, addTokenTxData, parseTokenInfoForTxHistory, getTxData, getRestUrl, signPkMessage, sendXec, sendToken, createToken, getTokenStats, }; }