diff --git a/web/cashtab/src/components/Common/Ticker.js b/web/cashtab/src/components/Common/Ticker.js index 6de78aaa7..fa5bbc825 100644 --- a/web/cashtab/src/components/Common/Ticker.js +++ b/web/cashtab/src/components/Common/Ticker.js @@ -1,202 +1,136 @@ import mainLogo from '@assets/logo_primary.png'; import tokenLogo from '@assets/logo_secondary.png'; import BigNumber from 'bignumber.js'; export const currency = { name: 'eCash', ticker: 'XEC', 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: 10, xecApiBatchSize: 20, defaultSettings: { fiatCurrency: 'usd' }, notificationDurationShort: 3, notificationDurationLong: 5, newTokenDefaultUrl: 'https://cashtab.com/', opReturn: { opReturnPrefixHex: '6a', opReturnAppPrefixLengthHex: '04', opPushDataOne: '4c', appPrefixesHex: { eToken: '534c5000', cashtab: '00746162', cashtabEncrypted: '65746162', }, encryptedMsgCharLimit: 94, unencryptedMsgCharLimit: 160, }, 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 parseOpReturn(hexStr) { - if ( - !hexStr || - typeof hexStr !== 'string' || - hexStr.substring(0, 2) !== currency.opReturn.opReturnPrefixHex - ) { - return false; - } - - 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 - } - - // 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 if ( - i === 0 && - message === currency.opReturn.appPrefixesHex.cashtabEncrypted - ) { - // add the Cashtab encryption prefix to array - resultArray[i] = currency.opReturn.appPrefixesHex.cashtabEncrypted; - } else { - // this is either an external message or a subsequent cashtab message loop to extract the message - resultArray[i] = message; - } - - // strip out the parsed message - hexStr = hexStr.slice(msgCharLength); - hexStrLength = hexStr.length; - } - return resultArray; -} - export function parseAddressForParams(addressString) { // Build return obj const addressInfo = { address: '', queryString: null, amount: null, }; // Parse address string for parameters const paramCheck = addressString.split('?'); let cleanAddress = paramCheck[0]; addressInfo.address = cleanAddress; // 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/__tests__/Ticker.test.js b/web/cashtab/src/components/Common/__tests__/Ticker.test.js deleted file mode 100644 index 50493d58e..000000000 --- a/web/cashtab/src/components/Common/__tests__/Ticker.test.js +++ /dev/null @@ -1,60 +0,0 @@ -import { parseOpReturn } from '../Ticker'; - -import { - shortCashtabMessageInputHex, - longCashtabMessageInputHex, - shortExternalMessageInputHex, - longExternalMessageInputHex, - shortSegmentedExternalMessageInputHex, - longSegmentedExternalMessageInputHex, - mixedSegmentedExternalMessageInputHex, - mockParsedShortCashtabMessageArray, - mockParsedLongCashtabMessageArray, - mockParsedShortExternalMessageArray, - mockParsedLongExternalMessageArray, - mockParsedShortSegmentedExternalMessageArray, - mockParsedLongSegmentedExternalMessageArray, - mockParsedMixedSegmentedExternalMessageArray, - eTokenInputHex, - mockParsedETokenOutputArray, -} from '../__mocks__/mockOpReturnParsedArray'; - -test('parseOpReturn() successfully parses a short cashtab message', async () => { - const result = parseOpReturn(shortCashtabMessageInputHex); - expect(result).toStrictEqual(mockParsedShortCashtabMessageArray); -}); - -test('parseOpReturn() successfully parses a long cashtab message where an additional PUSHDATA1 is present', async () => { - const result = parseOpReturn(longCashtabMessageInputHex); - expect(result).toStrictEqual(mockParsedLongCashtabMessageArray); -}); - -test('parseOpReturn() successfully parses a short external message', async () => { - const result = parseOpReturn(shortExternalMessageInputHex); - expect(result).toStrictEqual(mockParsedShortExternalMessageArray); -}); - -test('parseOpReturn() successfully parses a long external message where an additional PUSHDATA1 is present', async () => { - const result = parseOpReturn(longExternalMessageInputHex); - expect(result).toStrictEqual(mockParsedLongExternalMessageArray); -}); - -test('parseOpReturn() successfully parses an external message that is segmented into separate short parts', async () => { - const result = parseOpReturn(shortSegmentedExternalMessageInputHex); - expect(result).toStrictEqual(mockParsedShortSegmentedExternalMessageArray); -}); - -test('parseOpReturn() successfully parses an external message that is segmented into separate long parts', async () => { - const result = parseOpReturn(longSegmentedExternalMessageInputHex); - expect(result).toStrictEqual(mockParsedLongSegmentedExternalMessageArray); -}); - -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('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 92e9ad422..55c8f197c 100644 --- a/web/cashtab/src/hooks/useBCH.js +++ b/web/cashtab/src/hooks/useBCH.js @@ -1,1466 +1,1467 @@ import BigNumber from 'bignumber.js'; -import { currency, parseOpReturn } from '@components/Common/Ticker'; +import { currency } from '@components/Common/Ticker'; import { isValidTokenStats } from '@utils/validation'; import SlpWallet from 'minimal-slp-wallet'; import { toSmallestDenomination, fromSmallestDenomination, batchArray, flattenBatchedHydratedUtxos, isValidStoredWallet, checkNullUtxosForTokenStatus, confirmNonEtokenUtxos, convertToEncryptStruct, getPublicKey, + parseOpReturn, } from '@utils/cashMethods'; import cashaddr from 'ecashaddrjs'; import ecies from 'ecies-lite'; import wif from 'wif'; 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 = async (BCH, txData, publicKeys, wallet) => { /* 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 there was an error in getting the tx data from api, 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; let isEncryptedMessage = false; let decryptionSuccess = false; // Assume an incoming transaction let outgoingTx = false; let tokenTx = false; let substring = ''; // If vin's scriptSig contains one of the publicKeys of this wallet // This is an outgoing tx for (let j = 0; j < tx.vin.length; j += 1) { // Since Cashtab only concerns with utxos of Path145, Path245 and Path1899 addresses, // which are hashes of thier public keys. We can safely assume that Cashtab can only // consumes utxos of type 'pubkeyhash' // Therefore, only tx with vin's scriptSig of type 'pubkeyhash' can potentially be an outgoing tx. // any other scriptSig type indicates that the tx is incoming. try { const thisInputScriptSig = tx.vin[j].scriptSig; let inputPubKey = undefined; const inputType = BCH.Script.classifyInput( BCH.Script.decode( Buffer.from(thisInputScriptSig.hex, 'hex'), ), ); if (inputType === 'pubkeyhash') { inputPubKey = thisInputScriptSig.hex.substring( thisInputScriptSig.hex.length - 66, ); } publicKeys.forEach(pubKey => { if (pubKey === inputPubKey) { // This is an outgoing transaction outgoingTx = true; } }); if (outgoingTx === true) break; } catch (err) { console.log( "useBCH.parsedTxHistory() error: in trying to classify Input' scriptSig", ); } } // 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 (!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 ( txType === currency.opReturn.appPrefixesHex.cashtab ) { // this is a Cashtab message try { opReturnMessage = Buffer.from( parsedOpReturnArray[1], 'hex', ); isCashtabMessage = true; } catch (err) { // soft error if an unexpected or invalid cashtab hex is encountered opReturnMessage = ''; console.log( 'useBCH.parsedTxData() error: invalid cashtab msg hex: ' + parsedOpReturnArray[1], ); } } else if ( txType === currency.opReturn.appPrefixesHex.cashtabEncrypted ) { // this is an encrypted Cashtab message let msgString = parsedOpReturnArray[1]; let fundingWif, privateKeyObj, privateKeyBuff; if ( wallet && wallet.state && wallet.state.slpBalancesAndUtxos && wallet.state.slpBalancesAndUtxos.nonSlpUtxos[0] ) { fundingWif = wallet.state.slpBalancesAndUtxos.nonSlpUtxos[0] .wif; privateKeyObj = wif.decode(fundingWif); privateKeyBuff = privateKeyObj.privateKey; if (!privateKeyBuff) { throw new Error('Private key extraction error'); } } else { break; } let structData; let decryptedMessage; try { // Convert the hex encoded message to a buffer const msgBuf = Buffer.from(msgString, 'hex'); // Convert the bufer into a structured object. structData = convertToEncryptStruct(msgBuf); decryptedMessage = await ecies.decrypt( privateKeyBuff, structData, ); decryptionSuccess = true; } catch (err) { console.log( 'useBCH.parsedTxData() decryption error: ' + err, ); decryptedMessage = 'Only the message recipient can view this'; } isCashtabMessage = true; isEncryptedMessage = true; opReturnMessage = decryptedMessage; } else { // this is an externally generated message message = txType; // index 0 is the message content in this instance // if there are more than one part to the external message const arrayLength = parsedOpReturnArray.length; for (let i = 1; i < arrayLength; i++) { message = message + parsedOpReturnArray[i]; } try { opReturnMessage = Buffer.from(message, 'hex'); } catch (err) { // soft error if an unexpected or invalid cashtab hex is encountered opReturnMessage = ''; console.log( 'useBCH.parsedTxData() error: invalid external msg hex: ' + substring, ); } } 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]; } } // If the tx is incoming and have a message attached // get the address of the sender for this tx and encode into eCash address let senderAddress = null; if (!outgoingTx && opReturnMessage !== '') { const firstVin = tx.vin[0]; try { // get the tx that generated the first vin of this tx const firstVinTxData = await BCH.RawTransactions.getRawTransaction( firstVin.txid, true, ); // extract the address of the tx output let senderBchAddress = firstVinTxData.vout[firstVin.vout].scriptPubKey .addresses[0]; const { type, hash } = cashaddr.decode(senderBchAddress); senderAddress = cashaddr.encode('ecash', type, hash); } catch (err) { console.log( `Error in BCH.RawTransactions.getRawTransaction(${firstVin.txid}, true)`, ); } } // 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; parsedTx.isEncryptedMessage = isEncryptedMessage; parsedTx.decryptionSuccess = decryptionSuccess; 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.getRawTransaction does not return address or blockheight let txDataWithPassThrough = {}; try { txDataWithPassThrough = await BCH.RawTransactions.getRawTransaction( flatTx.txid, true, ); } catch (err) { console.log( `Error in BCH.RawTransactions.getRawTransaction(${flatTx.txid}, true)`, ); 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, publicKeys, wallet) => { // 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( BCH, txDataPromiseResponse, publicKeys, wallet, ); 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.xecApiBatchSize, ); // 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 fetchTxDataPromise = (BCH, txidBatch) => { return new Promise((resolve, reject) => { BCH.Electrumx.txData(txidBatch).then( result => { resolve(result); }, err => { reject(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); } // segment the txids array into chunks under the api limit const batchedTxids = batchArray(txids, currency.xecApiBatchSize); // build an array of promises let txDataPromises = []; // loop through each batch of 20 txids for (let j = 0; j < batchedTxids.length; j += 1) { const txidsForThisPromise = batchedTxids[j]; // build the promise for the api call with the 20 txids in current batch const thisTxDataPromise = fetchTxDataPromise( BCH, txidsForThisPromise, ); txDataPromises.push(thisTxDataPromise); } try { const nullUtxoTxData = await Promise.all(txDataPromises); // Scan tx data for each utxo to confirm they are not eToken txs let thisTxDataResult; let nonEtokenUtxos = []; for (let k = 0; k < nullUtxoTxData.length; k += 1) { thisTxDataResult = nullUtxoTxData[k].transactions; nonEtokenUtxos = nonEtokenUtxos.concat( checkNullUtxosForTokenStatus(thisTxDataResult), ); } return nonEtokenUtxos; } catch (err) { console.log( `Error in checkNullUtxosForTokenStatus(nullUtxos)` + err, ); 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 => { 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 getRecipientPublicKey = async (BCH, recipientAddress) => { let recipientPubKey; try { recipientPubKey = await getPublicKey(BCH, recipientAddress); } catch (err) { console.log(`useBCH.getRecipientPublicKey() error: ` + err); throw err; } return recipientPubKey; }; const handleEncryptedOpReturn = async ( BCH, destinationAddress, optionalOpReturnMsg, ) => { let recipientPubKey, encryptedEj; try { recipientPubKey = await getRecipientPublicKey( BCH, destinationAddress, ); } catch (err) { console.log(`useBCH.handleEncryptedOpReturn() error: ` + err); throw err; } if (recipientPubKey === 'not found') { // if the API can't find a pub key, it is due to the wallet having no outbound tx throw new Error( 'Cannot send an encrypted message to a wallet with no outgoing transactions', ); } try { const pubKeyBuf = Buffer.from(recipientPubKey, 'hex'); const bufferedFile = Buffer.from(optionalOpReturnMsg); const structuredEj = await ecies.encrypt(pubKeyBuf, bufferedFile); // Serialize the encrypted data object encryptedEj = Buffer.concat([ structuredEj.epk, structuredEj.iv, structuredEj.ct, structuredEj.mac, ]); } catch (err) { console.log(`useBCH.handleEncryptedOpReturn() error: ` + err); throw err; } return encryptedEj; }; const sendXec = async ( BCH, wallet, utxos, feeInSatsPerByte, optionalOpReturnMsg, isOneToMany, destinationAddressAndValueArray, destinationAddress, sendAmount, encryptionFlag, ) => { 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; } let script; // Start of building the OP_RETURN output. // only build the OP_RETURN output if the user supplied it if ( optionalOpReturnMsg && typeof optionalOpReturnMsg !== 'undefined' && optionalOpReturnMsg.trim() !== '' ) { if (encryptionFlag) { // if the user has opted to encrypt this message let encryptedEj; try { encryptedEj = await handleEncryptedOpReturn( BCH, destinationAddress, optionalOpReturnMsg, ); } catch (err) { console.log(`useBCH.sendXec() encryption error.`); throw err; } // build the OP_RETURN script with the encryption prefix script = [ BCH.Script.opcodes.OP_RETURN, // 6a Buffer.from( currency.opReturn.appPrefixesHex.cashtabEncrypted, 'hex', ), // 65746162 Buffer.from(encryptedEj), ]; } else { // this is an un-encrypted message 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, handleEncryptedOpReturn, getRecipientPublicKey, }; } diff --git a/web/cashtab/src/components/Common/__mocks__/mockOpReturnParsedArray.js b/web/cashtab/src/utils/__mocks__/mockOpReturnParsedArray.js similarity index 100% rename from web/cashtab/src/components/Common/__mocks__/mockOpReturnParsedArray.js rename to web/cashtab/src/utils/__mocks__/mockOpReturnParsedArray.js diff --git a/web/cashtab/src/utils/__tests__/cashMethods.test.js b/web/cashtab/src/utils/__tests__/cashMethods.test.js index 6b5763161..942c6267c 100644 --- a/web/cashtab/src/utils/__tests__/cashMethods.test.js +++ b/web/cashtab/src/utils/__tests__/cashMethods.test.js @@ -1,282 +1,348 @@ import { ValidationError } from 'ecashaddrjs'; import { fromSmallestDenomination, batchArray, flattenBatchedHydratedUtxos, loadStoredWallet, isValidStoredWallet, fromLegacyDecimals, convertToEcashPrefix, checkNullUtxosForTokenStatus, confirmNonEtokenUtxos, isLegacyMigrationRequired, toLegacyCash, toLegacyToken, toLegacyCashArray, + parseOpReturn, } from '@utils/cashMethods'; import { unbatchedArray, arrayBatchedByThree, } from '../__mocks__/mockBatchedArrays'; import { validAddressArrayInput, validAddressArrayInputMixedPrefixes, validAddressArrayOutput, validLargeAddressArrayInput, validLargeAddressArrayOutput, invalidAddressArrayInput, } from '../__mocks__/mockAddressArray'; import { unflattenedHydrateUtxosResponse, flattenedHydrateUtxosResponse, } from '../__mocks__/flattenBatchedHydratedUtxosMocks'; import { cachedUtxos, utxosLoadedFromCache, } from '../__mocks__/mockCachedUtxos'; import { validStoredWallet, invalidStoredWallet, } from '../__mocks__/mockStoredWallets'; import { mockTxDataResults, mockNonEtokenUtxos, mockTxDataResultsWithEtoken, mockHydratedUtxosWithNullValues, mockHydratedUtxosWithNullValuesSetToFalse, } from '../__mocks__/nullUtxoMocks'; import { missingPath1899Wallet, missingPublicKeyInPath1899Wallet, missingPublicKeyInPath145Wallet, missingPublicKeyInPath245Wallet, notLegacyWallet, } from '../__mocks__/mockLegacyWallets'; +import { + shortCashtabMessageInputHex, + longCashtabMessageInputHex, + shortExternalMessageInputHex, + longExternalMessageInputHex, + shortSegmentedExternalMessageInputHex, + longSegmentedExternalMessageInputHex, + mixedSegmentedExternalMessageInputHex, + mockParsedShortCashtabMessageArray, + mockParsedLongCashtabMessageArray, + mockParsedShortExternalMessageArray, + mockParsedLongExternalMessageArray, + mockParsedShortSegmentedExternalMessageArray, + mockParsedLongSegmentedExternalMessageArray, + mockParsedMixedSegmentedExternalMessageArray, + eTokenInputHex, + mockParsedETokenOutputArray, +} from '../__mocks__/mockOpReturnParsedArray'; + describe('Correctly executes cash utility functions', () => { it(`Correctly converts smallest base unit to smallest decimal for cashDecimals = 2`, () => { expect(fromSmallestDenomination(1, 2)).toBe(0.01); }); it(`Correctly converts largest base unit to smallest decimal for cashDecimals = 2`, () => { expect(fromSmallestDenomination(1000000012345678, 2)).toBe( 10000000123456.78, ); }); it(`Correctly converts smallest base unit to smallest decimal for cashDecimals = 8`, () => { expect(fromSmallestDenomination(1, 8)).toBe(0.00000001); }); it(`Correctly converts largest base unit to smallest decimal for cashDecimals = 8`, () => { expect(fromSmallestDenomination(1000000012345678, 8)).toBe( 10000000.12345678, ); }); it(`Correctly converts an array of length 10 to an array of 4 arrays, each with max length 3`, () => { expect(batchArray(unbatchedArray, 3)).toStrictEqual( arrayBatchedByThree, ); }); it(`If array length is less than batch size, return original array as first and only element of new array`, () => { expect(batchArray(unbatchedArray, 20)).toStrictEqual([unbatchedArray]); }); it(`Flattens hydrateUtxos from Promise.all() response into array that can be parsed by getSlpBalancesAndUtxos`, () => { expect( flattenBatchedHydratedUtxos(unflattenedHydrateUtxosResponse), ).toStrictEqual(flattenedHydrateUtxosResponse); }); it(`Accepts a cachedWalletState that has not preserved BigNumber object types, and returns the same wallet state with BigNumber type re-instituted`, () => { expect(loadStoredWallet(cachedUtxos)).toStrictEqual( utxosLoadedFromCache, ); }); it(`Recognizes a stored wallet as valid if it has all required fields`, () => { expect(isValidStoredWallet(validStoredWallet)).toBe(true); }); it(`Recognizes a stored wallet as invalid if it is missing required fields`, () => { expect(isValidStoredWallet(invalidStoredWallet)).toBe(false); }); it(`Converts a legacy BCH amount to an XEC amount`, () => { expect(fromLegacyDecimals(0.00000546, 2)).toStrictEqual(5.46); }); it(`Leaves a legacy BCH amount unchanged if currency.cashDecimals is 8`, () => { expect(fromLegacyDecimals(0.00000546, 8)).toStrictEqual(0.00000546); }); it(`convertToEcashPrefix converts a bitcoincash: prefixed address to an ecash: prefixed address`, () => { expect( convertToEcashPrefix( 'bitcoincash:qz2708636snqhsxu8wnlka78h6fdp77ar5ulhz04hr', ), ).toBe('ecash:qz2708636snqhsxu8wnlka78h6fdp77ar59jrf5035'); }); it(`convertToEcashPrefix returns an ecash: prefix address unchanged`, () => { expect( convertToEcashPrefix( 'ecash:qz2708636snqhsxu8wnlka78h6fdp77ar59jrf5035', ), ).toBe('ecash:qz2708636snqhsxu8wnlka78h6fdp77ar59jrf5035'); }); it(`toLegacyToken returns an etoken: prefix address as simpleledger:`, () => { expect( toLegacyToken('etoken:qz2708636snqhsxu8wnlka78h6fdp77ar5tv2tzg4r'), ).toBe('simpleledger:qz2708636snqhsxu8wnlka78h6fdp77ar5syue64fa'); }); it(`toLegacyToken returns an prefixless valid etoken address in simpleledger: format with prefix`, () => { expect( toLegacyToken('qz2708636snqhsxu8wnlka78h6fdp77ar5tv2tzg4r'), ).toBe('simpleledger:qz2708636snqhsxu8wnlka78h6fdp77ar5syue64fa'); }); it(`Correctly parses utxo vout tx data to confirm the transactions are not eToken txs`, () => { expect(checkNullUtxosForTokenStatus(mockTxDataResults)).toStrictEqual( mockNonEtokenUtxos, ); }); it(`Correctly parses utxo vout tx data and screens out an eToken by asm field`, () => { expect( checkNullUtxosForTokenStatus(mockTxDataResultsWithEtoken), ).toStrictEqual([]); }); it(`Changes isValid from 'null' to 'false' for confirmed nonEtokenUtxos`, () => { expect( confirmNonEtokenUtxos( mockHydratedUtxosWithNullValues, mockNonEtokenUtxos, ), ).toStrictEqual(mockHydratedUtxosWithNullValuesSetToFalse); }); it(`Recognizes a wallet with missing Path1889 is a Legacy Wallet and requires migration`, () => { expect(isLegacyMigrationRequired(missingPath1899Wallet)).toBe(true); }); it(`Recognizes a wallet with missing PublicKey in Path1889 is a Legacy Wallet and requires migration`, () => { expect( isLegacyMigrationRequired(missingPublicKeyInPath1899Wallet), ).toBe(true); }); it(`Recognizes a wallet with missing PublicKey in Path145 is a Legacy Wallet and requires migration`, () => { expect(isLegacyMigrationRequired(missingPublicKeyInPath145Wallet)).toBe( true, ); }); it(`Recognizes a wallet with missing PublicKey in Path245 is a Legacy Wallet and requires migration`, () => { expect(isLegacyMigrationRequired(missingPublicKeyInPath245Wallet)).toBe( true, ); }); it(`Recognizes a latest, current wallet that does not require migration`, () => { expect(isLegacyMigrationRequired(notLegacyWallet)).toBe(false); }); test('toLegacyCash() converts a valid ecash: prefix address to a valid bitcoincash: prefix address', async () => { const result = toLegacyCash( 'ecash:qqd3qn4zazjhygk5a2vzw2gvqgqwempr4gtfza25mc', ); expect(result).toStrictEqual( 'bitcoincash:qqd3qn4zazjhygk5a2vzw2gvqgqwempr4gjykk3wa0', ); }); test('toLegacyCash() converts a valid ecash: prefixless address to a valid bitcoincash: prefix address', async () => { const result = toLegacyCash( 'qqd3qn4zazjhygk5a2vzw2gvqgqwempr4gtfza25mc', ); expect(result).toStrictEqual( 'bitcoincash:qqd3qn4zazjhygk5a2vzw2gvqgqwempr4gjykk3wa0', ); }); test('toLegacyCash throws error if input address has invalid checksum', async () => { const result = toLegacyCash( 'ecash:qqd3qn4zazjhygk5a2vzw2gvqgqwempr4gtfza25m', ); expect(result).toStrictEqual( new Error( 'ecash:qqd3qn4zazjhygk5a2vzw2gvqgqwempr4gtfza25m is not a valid ecash address', ), ); }); test('toLegacyCash() throws error with input of etoken: address', async () => { const result = toLegacyCash( 'etoken:qqd3qn4zazjhygk5a2vzw2gvqgqwempr4g9htlunl0', ); expect(result).toStrictEqual( new Error( 'etoken:qqd3qn4zazjhygk5a2vzw2gvqgqwempr4g9htlunl0 is not a valid ecash address', ), ); }); test('toLegacyCash() throws error with input of legacy address', async () => { const result = toLegacyCash('13U6nDrkRsC3Eb1pxPhNY8XJ5W9zdp6rNk'); expect(result).toStrictEqual( new Error( '13U6nDrkRsC3Eb1pxPhNY8XJ5W9zdp6rNk is not a valid ecash address', ), ); }); test('toLegacyCash() throws error with input of bitcoincash: address', async () => { const result = toLegacyCash( 'bitcoincash:qqd3qn4zazjhygk5a2vzw2gvqgqwempr4gjykk3wa0', ); expect(result).toStrictEqual( new Error( 'bitcoincash:qqd3qn4zazjhygk5a2vzw2gvqgqwempr4gjykk3wa0 is not a valid ecash address', ), ); }); test('toLegacyCashArray throws error if the addressArray input is null', async () => { const result = toLegacyCashArray(null); expect(result).toStrictEqual(new Error('Invalid addressArray input')); }); test('toLegacyCashArray throws error if the addressArray input is empty', async () => { const result = toLegacyCashArray([]); expect(result).toStrictEqual(new Error('Invalid addressArray input')); }); test('toLegacyCashArray throws error if the addressArray input is a number', async () => { const result = toLegacyCashArray(12345); expect(result).toStrictEqual(new Error('Invalid addressArray input')); }); test('toLegacyCashArray throws error if the addressArray input is undefined', async () => { const result = toLegacyCashArray(undefined); expect(result).toStrictEqual(new Error('Invalid addressArray input')); }); test('toLegacyCashArray successfully converts a standard sized valid addressArray input', async () => { const result = toLegacyCashArray(validAddressArrayInput); expect(result).toStrictEqual(validAddressArrayOutput); }); test('toLegacyCashArray successfully converts a standard sized valid addressArray input including prefixless ecash addresses', async () => { const result = toLegacyCashArray(validAddressArrayInputMixedPrefixes); expect(result).toStrictEqual(validAddressArrayOutput); }); test('toLegacyCashArray successfully converts a large valid addressArray input', async () => { const result = toLegacyCashArray(validLargeAddressArrayInput); expect(result).toStrictEqual(validLargeAddressArrayOutput); }); test('toLegacyCashArray throws an error on an addressArray with invalid addresses', async () => { const result = toLegacyCashArray(invalidAddressArrayInput); expect(result).toStrictEqual( new Error( 'ecash:qrqgwxrrrrrrrrrrrrrrrrrrrrrrrrr7zsvk is not a valid ecash address', ), ); }); + + test('parseOpReturn() successfully parses a short cashtab message', async () => { + const result = parseOpReturn(shortCashtabMessageInputHex); + expect(result).toStrictEqual(mockParsedShortCashtabMessageArray); + }); + + test('parseOpReturn() successfully parses a long cashtab message where an additional PUSHDATA1 is present', async () => { + const result = parseOpReturn(longCashtabMessageInputHex); + expect(result).toStrictEqual(mockParsedLongCashtabMessageArray); + }); + + test('parseOpReturn() successfully parses a short external message', async () => { + const result = parseOpReturn(shortExternalMessageInputHex); + expect(result).toStrictEqual(mockParsedShortExternalMessageArray); + }); + + test('parseOpReturn() successfully parses a long external message where an additional PUSHDATA1 is present', async () => { + const result = parseOpReturn(longExternalMessageInputHex); + expect(result).toStrictEqual(mockParsedLongExternalMessageArray); + }); + + test('parseOpReturn() successfully parses an external message that is segmented into separate short parts', async () => { + const result = parseOpReturn(shortSegmentedExternalMessageInputHex); + expect(result).toStrictEqual( + mockParsedShortSegmentedExternalMessageArray, + ); + }); + + test('parseOpReturn() successfully parses an external message that is segmented into separate long parts', async () => { + const result = parseOpReturn(longSegmentedExternalMessageInputHex); + expect(result).toStrictEqual( + mockParsedLongSegmentedExternalMessageArray, + ); + }); + + 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('parseOpReturn() successfully parses an eToken output', async () => { + const result = parseOpReturn(eTokenInputHex); + expect(result).toStrictEqual(mockParsedETokenOutputArray); + }); }); diff --git a/web/cashtab/src/utils/cashMethods.js b/web/cashtab/src/utils/cashMethods.js index ef64b9230..09cda54d7 100644 --- a/web/cashtab/src/utils/cashMethods.js +++ b/web/cashtab/src/utils/cashMethods.js @@ -1,423 +1,489 @@ import { currency } from '@components/Common/Ticker'; import { isValidXecAddress, isValidEtokenAddress } from '@utils/validation'; import BigNumber from 'bignumber.js'; import cashaddr from 'ecashaddrjs'; +export function parseOpReturn(hexStr) { + if ( + !hexStr || + typeof hexStr !== 'string' || + hexStr.substring(0, 2) !== currency.opReturn.opReturnPrefixHex + ) { + return false; + } + + 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 + } + + // 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 if ( + i === 0 && + message === currency.opReturn.appPrefixesHex.cashtabEncrypted + ) { + // add the Cashtab encryption prefix to array + resultArray[i] = currency.opReturn.appPrefixesHex.cashtabEncrypted; + } else { + // this is either an external message or a subsequent cashtab message loop to extract the message + resultArray[i] = message; + } + + // strip out the parsed message + hexStr = hexStr.slice(msgCharLength); + hexStrLength = hexStr.length; + } + return resultArray; +} + export const fromLegacyDecimals = ( amount, cashDecimals = currency.cashDecimals, ) => { // Input 0.00000546 BCH // Output 5.46 XEC or 0.00000546 BCH, depending on currency.cashDecimals const amountBig = new BigNumber(amount); const conversionFactor = new BigNumber(10 ** (8 - cashDecimals)); const amountSmallestDenomination = amountBig .times(conversionFactor) .toNumber(); return amountSmallestDenomination; }; export const fromSmallestDenomination = ( amount, cashDecimals = currency.cashDecimals, ) => { const amountBig = new BigNumber(amount); const multiplier = new BigNumber(10 ** (-1 * cashDecimals)); const amountInBaseUnits = amountBig.times(multiplier); return amountInBaseUnits.toNumber(); }; export const toSmallestDenomination = ( sendAmount, cashDecimals = currency.cashDecimals, ) => { // Replace the BCH.toSatoshi method with an equivalent function that works for arbitrary decimal places // Example, for an 8 decimal place currency like Bitcoin // Input: a BigNumber of the amount of Bitcoin to be sent // Output: a BigNumber of the amount of satoshis to be sent, or false if input is invalid // Validate // Input should be a BigNumber with no more decimal places than cashDecimals const isValidSendAmount = BigNumber.isBigNumber(sendAmount) && sendAmount.dp() <= cashDecimals; if (!isValidSendAmount) { return false; } const conversionFactor = new BigNumber(10 ** cashDecimals); const sendAmountSmallestDenomination = sendAmount.times(conversionFactor); return sendAmountSmallestDenomination; }; export const batchArray = (inputArray, batchSize) => { // take an array of n elements, return an array of arrays each of length batchSize const batchedArray = []; for (let i = 0; i < inputArray.length; i += batchSize) { const tempArray = inputArray.slice(i, i + batchSize); batchedArray.push(tempArray); } return batchedArray; }; export const flattenBatchedHydratedUtxos = batchedHydratedUtxoDetails => { // Return same result as if only the bulk API call were made // to do this, just need to move all utxos under one slpUtxos /* given [ { slpUtxos: [ { utxos: [], address: '', } ], }, { slpUtxos: [ { utxos: [], address: '', } ], } ] return [ { slpUtxos: [ { utxos: [], address: '' }, { utxos: [], address: '' }, ] } */ const flattenedBatchedHydratedUtxos = { slpUtxos: [] }; for (let i = 0; i < batchedHydratedUtxoDetails.length; i += 1) { const theseSlpUtxos = batchedHydratedUtxoDetails[i].slpUtxos[0]; flattenedBatchedHydratedUtxos.slpUtxos.push(theseSlpUtxos); } return flattenedBatchedHydratedUtxos; }; export const loadStoredWallet = walletStateFromStorage => { // Accept cached tokens array that does not save BigNumber type of BigNumbers // Return array with BigNumbers converted // See BigNumber.js api for how to create a BigNumber object from an object // https://mikemcl.github.io/bignumber.js/ const liveWalletState = walletStateFromStorage; const { slpBalancesAndUtxos, tokens } = liveWalletState; for (let i = 0; i < tokens.length; i += 1) { const thisTokenBalance = tokens[i].balance; thisTokenBalance._isBigNumber = true; tokens[i].balance = new BigNumber(thisTokenBalance); } // Also confirm balance is correct // Necessary step in case currency.decimals changed since last startup const balancesRebased = normalizeBalance(slpBalancesAndUtxos); liveWalletState.balances = balancesRebased; return liveWalletState; }; export const normalizeBalance = slpBalancesAndUtxos => { const totalBalanceInSatoshis = slpBalancesAndUtxos.nonSlpUtxos.reduce( (previousBalance, utxo) => previousBalance + utxo.value, 0, ); return { totalBalanceInSatoshis, totalBalance: fromSmallestDenomination(totalBalanceInSatoshis), }; }; export const isValidStoredWallet = walletStateFromStorage => { return ( typeof walletStateFromStorage === 'object' && 'state' in walletStateFromStorage && typeof walletStateFromStorage.state === 'object' && 'balances' in walletStateFromStorage.state && 'utxos' in walletStateFromStorage.state && 'hydratedUtxoDetails' in walletStateFromStorage.state && 'slpBalancesAndUtxos' in walletStateFromStorage.state && 'tokens' in walletStateFromStorage.state ); }; export const getWalletState = wallet => { if (!wallet || !wallet.state) { return { balances: { totalBalance: 0, totalBalanceInSatoshis: 0 }, hydratedUtxoDetails: {}, tokens: [], slpBalancesAndUtxos: {}, parsedTxHistory: [], utxos: [], }; } return wallet.state; }; export function convertToEcashPrefix(bitcoincashPrefixedAddress) { // 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 = bitcoincashPrefixedAddress.includes(':'); if (hasPrefix) { // Is it bitcoincash: or simpleledger: const { type, hash, prefix } = cashaddr.decode( bitcoincashPrefixedAddress, ); let newPrefix; if (prefix === 'bitcoincash') { newPrefix = 'ecash'; } else if (prefix === 'simpleledger') { newPrefix = 'etoken'; } else { return bitcoincashPrefixedAddress; } const convertedAddress = cashaddr.encode(newPrefix, type, hash); return convertedAddress; } else { return bitcoincashPrefixedAddress; } } export function toLegacyCash(addr) { // Confirm input is a valid ecash address const isValidInput = isValidXecAddress(addr); if (!isValidInput) { return new Error(`${addr} is not a valid ecash address`); } // Check for ecash: prefix const isPrefixedXecAddress = addr.slice(0, 6) === 'ecash:'; // If no prefix, assume it is checksummed for an ecash: prefix const testedXecAddr = isPrefixedXecAddress ? addr : `ecash:${addr}`; let legacyCashAddress; try { const { type, hash } = cashaddr.decode(testedXecAddr); legacyCashAddress = cashaddr.encode(currency.legacyPrefix, type, hash); } catch (err) { return err; } return legacyCashAddress; } export function toLegacyCashArray(addressArray) { let cleanArray = []; // array of bch converted addresses to be returned if ( addressArray === null || addressArray === undefined || !addressArray.length || addressArray === '' ) { return new Error('Invalid addressArray input'); } const arrayLength = addressArray.length; for (let i = 0; i < arrayLength; i++) { let addressValueArr = addressArray[i].split(','); let address = addressValueArr[0]; let value = addressValueArr[1]; // NB that toLegacyCash() includes address validation; will throw error for invalid address input const legacyAddress = toLegacyCash(address); if (legacyAddress instanceof Error) { return legacyAddress; } let convertedArrayData = legacyAddress + ',' + value + '\n'; cleanArray.push(convertedArrayData); } return cleanArray; } export function toLegacyToken(addr) { // Confirm input is a valid ecash address const isValidInput = isValidEtokenAddress(addr); if (!isValidInput) { return new Error(`${addr} is not a valid etoken address`); } // Check for ecash: prefix const isPrefixedEtokenAddress = addr.slice(0, 7) === 'etoken:'; // If no prefix, assume it is checksummed for an ecash: prefix const testedEtokenAddr = isPrefixedEtokenAddress ? addr : `etoken:${addr}`; let legacyTokenAddress; try { const { type, hash } = cashaddr.decode(testedEtokenAddr); legacyTokenAddress = cashaddr.encode('simpleledger', type, hash); } catch (err) { return err; } return legacyTokenAddress; } export const confirmNonEtokenUtxos = (hydratedUtxos, nonEtokenUtxos) => { // scan through hydratedUtxoDetails for (let i = 0; i < hydratedUtxos.length; i += 1) { // Find utxos with txids matching nonEtokenUtxos if (nonEtokenUtxos.includes(hydratedUtxos[i].txid)) { // Confirm that such utxos are not eToken utxos hydratedUtxos[i].isValid = false; } } return hydratedUtxos; }; export const checkNullUtxosForTokenStatus = txDataResults => { const nonEtokenUtxos = []; for (let j = 0; j < txDataResults.length; j += 1) { const thisUtxoTxid = txDataResults[j].txid; const thisUtxoVout = txDataResults[j].details.vout; // Iterate over outputs for (let k = 0; k < thisUtxoVout.length; k += 1) { const thisOutput = thisUtxoVout[k]; if (thisOutput.scriptPubKey.type === 'nulldata') { const asmOutput = thisOutput.scriptPubKey.asm; if (asmOutput.includes('OP_RETURN 5262419')) { // then it's an eToken tx that has not been properly validated // Do not include it in nonEtokenUtxos // App will ignore it until SLPDB is able to validate it /* console.log( `utxo ${thisUtxoTxid} requires further eToken validation, ignoring`, );*/ } else { // Otherwise it's just an OP_RETURN tx that SLPDB has some issue with // It should still be in the user's utxo set // Include it in nonEtokenUtxos /* console.log( `utxo ${thisUtxoTxid} is not an eToken tx, adding to nonSlpUtxos`, ); */ nonEtokenUtxos.push(thisUtxoTxid); } } } } return nonEtokenUtxos; }; /* Converts a serialized buffer containing encrypted data into an object * that can be interpreted by the ecies-lite library. * * For reference on the parsing logic in this function refer to the link below on the segment of * ecies-lite's encryption function where the encKey, macKey, iv and cipher are sliced and concatenated * https://github.com/tibetty/ecies-lite/blob/8fd97e80b443422269d0223ead55802378521679/index.js#L46-L55 * * A similar PSF implmentation can also be found at: * https://github.com/Permissionless-Software-Foundation/bch-encrypt-lib/blob/master/lib/encryption.js * * For more detailed overview on the ecies encryption scheme, see https://cryptobook.nakov.com/asymmetric-key-ciphers/ecies-public-key-encryption */ export const convertToEncryptStruct = encryptionBuffer => { // based on ecies-lite's encryption logic, the encryption buffer is concatenated as follows: // [ epk + iv + ct + mac ] whereby: // - The first 32 or 64 chars of the encryptionBuffer is the epk // - Both iv and ct params are 16 chars each, hence their combined substring is 32 chars from the end of the epk string // - within this combined iv/ct substring, the first 16 chars is the iv param, and ct param being the later half // - The mac param is appended to the end of the encryption buffer // validate input buffer if (!encryptionBuffer) { throw new Error( 'cashmethods.convertToEncryptStruct() error: input must be a buffer', ); } try { // variable tracking the starting char position for string extraction purposes let startOfBuf = 0; // *** epk param extraction *** // The first char of the encryptionBuffer indicates the type of the public key // If the first char is 4, then the public key is 64 chars // If the first char is 3 or 2, then the public key is 32 chars // Otherwise this is not a valid encryption buffer compatible with the ecies-lite library let publicKey; switch (encryptionBuffer[0]) { case 4: publicKey = encryptionBuffer.slice(0, 65); // extract first 64 chars as public key break; case 3: case 2: publicKey = encryptionBuffer.slice(0, 33); // extract first 32 chars as public key break; default: throw new Error(`Invalid type: ${encryptionBuffer[0]}`); } // *** iv and ct param extraction *** startOfBuf += publicKey.length; // sets the starting char position to the end of the public key (epk) in order to extract subsequent iv and ct substrings const encryptionTagLength = 32; // the length of the encryption tag (i.e. mac param) computed from each block of ciphertext, and is used to verify no one has tampered with the encrypted data const ivCtSubstring = encryptionBuffer.slice( startOfBuf, encryptionBuffer.length - encryptionTagLength, ); // extract the substring containing both iv and ct params, which is after the public key but before the mac param i.e. the 'encryption tag' const ivbufParam = ivCtSubstring.slice(0, 16); // extract the first 16 chars of substring as the iv param const ctbufParam = ivCtSubstring.slice(16); // extract the last 16 chars as substring the ct param // *** mac param extraction *** const macParam = encryptionBuffer.slice( encryptionBuffer.length - encryptionTagLength, encryptionBuffer.length, ); // extract the mac param appended to the end of the buffer return { iv: ivbufParam, epk: publicKey, ct: ctbufParam, mac: macParam, }; } catch (err) { console.error(`useBCH.convertToEncryptStruct() error: `, err); throw err; } }; export const getPublicKey = async (BCH, address) => { try { const publicKey = await BCH.encryption.getPubKey(address); return publicKey.publicKey; } catch (err) { if (err['error'] === 'No transaction history.') { throw new Error( 'Cannot send an encrypted message to a wallet with no outgoing transactions', ); } else { throw err; } } }; export const isLegacyMigrationRequired = wallet => { // If the wallet does not have Path1899, // Or each Path1899, Path145, Path245 does not have a public key // Then it requires migration if ( !wallet.Path1899 || !wallet.Path1899.publicKey || !wallet.Path145.publicKey || !wallet.Path245.publicKey ) { return true; } return false; };