diff --git a/web/cashtab/src/hooks/useWallet.js b/web/cashtab/src/hooks/useWallet.js index dfa547f8b..027c8e2f9 100644 --- a/web/cashtab/src/hooks/useWallet.js +++ b/web/cashtab/src/hooks/useWallet.js @@ -1,1457 +1,1461 @@ import { useState, useEffect } from 'react'; import usePrevious from 'hooks/usePrevious'; import useInterval from './useInterval'; import useBCH from 'hooks/useBCH'; import BigNumber from 'bignumber.js'; import { loadStoredWallet, isValidStoredWallet, isLegacyMigrationRequired, getHashArrayFromWallet, checkWalletForTokenInfo, isActiveWebsocket, getWalletBalanceFromUtxos, } from 'utils/cashMethods'; import { isValidCashtabSettings, isValidCashtabCache, isValidContactList, parseInvalidSettingsForMigration, } from 'utils/validation'; import localforage from 'localforage'; import { currency } from 'components/Common/Ticker'; import { xecReceivedNotification, xecReceivedNotificationWebsocket, eTokenReceivedNotification, } from 'components/Common/Notifications'; import { getUtxosChronik, organizeUtxosByType, getPreliminaryTokensArray, finalizeTokensArray, finalizeSlpUtxos, getTxHistoryChronik, parseChronikTx, } from 'utils/chronik'; import { ChronikClient } from 'chronik-client'; // For XEC, eCash chain: const chronik = new ChronikClient(currency.chronikUrl); const useWallet = () => { const [walletRefreshInterval, setWalletRefreshInterval] = useState( currency.websocketDisconnectedRefreshInterval, ); const [wallet, setWallet] = useState(false); const [chronikWebsocket, setChronikWebsocket] = useState(null); const [contactList, setContactList] = useState([{}]); const [cashtabSettings, setCashtabSettings] = useState(false); const [cashtabCache, setCashtabCache] = useState( currency.defaultCashtabCache, ); const [fiatPrice, setFiatPrice] = useState(null); const [apiError, setApiError] = useState(false); const [checkFiatInterval, setCheckFiatInterval] = useState(null); const [hasUpdated, setHasUpdated] = useState(false); const { getBCH, getTxHistory, getTxData, addTokenTxData } = useBCH(); const [loading, setLoading] = useState(true); const [apiIndex, setApiIndex] = useState(0); const [BCH, setBCH] = useState(getBCH(apiIndex)); const { balances, tokens } = isValidStoredWallet(wallet) ? wallet.state : { balances: {}, tokens: [], utxos: null, }; const previousBalances = usePrevious(balances); const previousTokens = usePrevious(tokens); // If you catch API errors, call this function const tryNextAPI = () => { let currentApiIndex = apiIndex; // How many APIs do you have? const apiString = process.env.REACT_APP_BCHA_APIS; const apiArray = apiString.split(','); console.log(`You have ${apiArray.length} APIs to choose from`); console.log(`Current selection: ${apiIndex}`); // If only one, exit if (apiArray.length === 0) { console.log( `There are no backup APIs, you are stuck with this error`, ); return; } else if (currentApiIndex < apiArray.length - 1) { currentApiIndex += 1; console.log( `Incrementing API index from ${apiIndex} to ${currentApiIndex}`, ); } else { // Otherwise use the first option again console.log(`Retrying first API index`); currentApiIndex = 0; } //return setApiIndex(currentApiIndex); console.log(`Setting Api Index to ${currentApiIndex}`); setApiIndex(currentApiIndex); return setBCH(getBCH(currentApiIndex)); // If you have more than one, use the next one // If you are at the "end" of the array, use the first one }; const deriveAccount = async (BCH, { masterHDNode, path }) => { const node = BCH.HDNode.derivePath(masterHDNode, path); const publicKey = BCH.HDNode.toPublicKey(node).toString('hex'); const cashAddress = BCH.HDNode.toCashAddress(node); const hash160 = BCH.Address.toHash160(cashAddress); const slpAddress = BCH.SLP.Address.toSLPAddress(cashAddress); return { publicKey, hash160, cashAddress, slpAddress, fundingWif: BCH.HDNode.toWIF(node), fundingAddress: BCH.SLP.Address.toSLPAddress(cashAddress), legacyAddress: BCH.SLP.Address.toLegacyAddress(cashAddress), }; }; const loadWalletFromStorageOnStartup = async setWallet => { // get wallet object from localforage const wallet = await getWallet(); // If wallet object in storage is valid, use it to set state on startup if (isValidStoredWallet(wallet)) { // Convert all the token balance figures to big numbers const liveWalletState = loadStoredWallet(wallet.state); wallet.state = liveWalletState; setWallet(wallet); return setLoading(false); } console.log(`Active wallet is not valid, loading params from API`); // Loading will remain true until API calls populate this legacy wallet setWallet(wallet); }; const update = async ({ wallet }) => { //console.log(`tick()`); //console.time("update"); // Check if walletRefreshInterval is set to 10, i.e. this was called by websocket tx detection // If walletRefreshInterval is 10, set it back to the usual refresh rate if (walletRefreshInterval === 10) { setWalletRefreshInterval( currency.websocketConnectedRefreshInterval, ); } try { if (!wallet) { return; } const cashAddresses = [ wallet.Path245.cashAddress, wallet.Path145.cashAddress, wallet.Path1899.cashAddress, ]; const publicKeys = [ wallet.Path145.publicKey, wallet.Path245.publicKey, wallet.Path1899.publicKey, ]; /* This strange data structure is necessary because chronik requires the hash160 of an address to tell you what utxos are at that address */ const hash160AndAddressObjArray = [ { address: wallet.Path145.cashAddress, hash160: wallet.Path145.hash160, }, { address: wallet.Path245.cashAddress, hash160: wallet.Path245.hash160, }, { address: wallet.Path1899.cashAddress, hash160: wallet.Path1899.hash160, }, ]; // Check that server is live try { await BCH.Blockchain.getBlockCount(); } catch (err) { console.log( `Error in BCH.Blockchain.getBlockCount, the full node is likely down`, err, ); throw new Error(`Node unavailable`); } const chronikUtxos = await getUtxosChronik( chronik, hash160AndAddressObjArray, ); const { preliminarySlpUtxos, nonSlpUtxos } = organizeUtxosByType(chronikUtxos); const preliminaryTokensArray = getPreliminaryTokensArray(preliminarySlpUtxos); const { finalTokenArray, updatedTokenInfoById, newTokensToCache } = await finalizeTokensArray( chronik, preliminaryTokensArray, cashtabCache.tokenInfoById, ); // If you have more token info now, write this to local storage if (newTokensToCache) { writeTokenInfoByIdToCache(updatedTokenInfoById); // Update the tokenInfoById key in cashtabCache setCashtabCache({ ...cashtabCache, tokenInfoById: updatedTokenInfoById, }); } const finalizedSlpUtxos = finalizeSlpUtxos( preliminarySlpUtxos, updatedTokenInfoById, ); // Preserve bch-api for tx history for now, as this will take another stacked diff to migrate to chronik const txHistory = await getTxHistory(BCH, cashAddresses); - const chronikTxHistory = await getTxHistoryChronik(chronik, wallet); + const chronikTxHistory = await getTxHistoryChronik( + chronik, + BCH, + wallet, + ); console.log( `chronikTxHistory as flattened array, sorted by blockheight and time first seen, with parse info, and partial legacy parse info`, chronikTxHistory, ); // public keys are used to determined if a tx is incoming outgoing const parsedTxHistory = await getTxData( BCH, txHistory, publicKeys, wallet, ); const parsedWithTokens = await addTokenTxData(BCH, parsedTxHistory); const newState = { balances: getWalletBalanceFromUtxos(nonSlpUtxos), tokens: finalTokenArray, slpBalancesAndUtxos: { slpUtxos: finalizedSlpUtxos, nonSlpUtxos, tokens, }, parsedTxHistory: parsedWithTokens, utxos: chronikUtxos, }; // Set wallet with new state field wallet.state = newState; setWallet(wallet); // Write this state to indexedDb using localForage writeWalletState(wallet, newState); // If everything executed correctly, remove apiError setApiError(false); } catch (error) { console.log(`Error in update({wallet})`); console.log(error); // Set this in state so that transactions are disabled until the issue is resolved setApiError(true); //console.timeEnd("update"); // Try another endpoint console.log(`Trying next API...`); tryNextAPI(); } //console.timeEnd("update"); }; const getActiveWalletFromLocalForage = async () => { let wallet; try { wallet = await localforage.getItem('wallet'); } catch (err) { console.log(`Error in getActiveWalletFromLocalForage`, err); wallet = null; } return wallet; }; const getContactListFromLocalForage = async () => { let contactListArray = []; try { contactListArray = await localforage.getItem('contactList'); } catch (err) { console.log('Error in getContactListFromLocalForage', err); contactListArray = null; } return contactListArray; }; const updateContactListInLocalForage = async contactListArray => { let updateSuccess = true; try { await localforage.setItem('contactList', contactListArray); } catch (err) { console.log('Error in updateContactListInLocalForage', err); updateSuccess = false; } return updateSuccess; }; const getWallet = async () => { let wallet; let existingWallet; try { existingWallet = await getActiveWalletFromLocalForage(); // existing wallet will be // 1 - the 'wallet' value from localForage, if it exists // 2 - false if it does not exist in localForage // 3 - null if error // If the wallet does not have Path1899, add it // or each Path1899, Path145, Path245 does not have a public key, add them if (existingWallet) { if (isLegacyMigrationRequired(existingWallet)) { console.log( `Wallet does not have Path1899 or does not have public key`, ); existingWallet = await migrateLegacyWallet( BCH, existingWallet, ); } } // If not in localforage then existingWallet = false, check localstorage if (!existingWallet) { console.log(`no existing wallet, checking local storage`); existingWallet = JSON.parse( window.localStorage.getItem('wallet'), ); console.log(`existingWallet from localStorage`, existingWallet); // If you find it here, move it to indexedDb if (existingWallet !== null) { wallet = await getWalletDetails(existingWallet); await localforage.setItem('wallet', wallet); return wallet; } } } catch (err) { console.log(`Error in getWallet()`, err); /* Error here implies problem interacting with localForage or localStorage API Have not seen this error in testing In this case, you still want to return 'wallet' using the logic below based on the determination of 'existingWallet' from the logic above */ } if (existingWallet === null || !existingWallet) { wallet = await getWalletDetails(existingWallet); await localforage.setItem('wallet', wallet); } else { wallet = existingWallet; } return wallet; }; const migrateLegacyWallet = async (BCH, wallet) => { console.log(`migrateLegacyWallet`); console.log(`legacyWallet`, wallet); const NETWORK = process.env.REACT_APP_NETWORK; const mnemonic = wallet.mnemonic; const rootSeedBuffer = await BCH.Mnemonic.toSeed(mnemonic); let masterHDNode; if (NETWORK === `mainnet`) { masterHDNode = BCH.HDNode.fromSeed(rootSeedBuffer); } else { masterHDNode = BCH.HDNode.fromSeed(rootSeedBuffer, 'testnet'); } const Path245 = await deriveAccount(BCH, { masterHDNode, path: "m/44'/245'/0'/0/0", }); const Path145 = await deriveAccount(BCH, { masterHDNode, path: "m/44'/145'/0'/0/0", }); const Path1899 = await deriveAccount(BCH, { masterHDNode, path: "m/44'/1899'/0'/0/0", }); wallet.Path245 = Path245; wallet.Path145 = Path145; wallet.Path1899 = Path1899; try { await localforage.setItem('wallet', wallet); } catch (err) { console.log( `Error setting wallet to wallet indexedDb in migrateLegacyWallet()`, ); console.log(err); } return wallet; }; const writeTokenInfoByIdToCache = async tokenInfoById => { console.log(`writeTokenInfoByIdToCache`); const cashtabCache = currency.defaultCashtabCache; cashtabCache.tokenInfoById = tokenInfoById; try { await localforage.setItem('cashtabCache', cashtabCache); console.log(`cashtabCache successfully updated`); } catch (err) { console.log(`Error in writeCashtabCache()`, err); } }; const writeWalletState = async (wallet, newState) => { // Add new state as an object on the active wallet wallet.state = newState; try { await localforage.setItem('wallet', wallet); } catch (err) { console.log(`Error in writeWalletState()`); console.log(err); } }; const getWalletDetails = async wallet => { if (!wallet) { return false; } // Since this info is in localforage now, only get the var const NETWORK = process.env.REACT_APP_NETWORK; const mnemonic = wallet.mnemonic; const rootSeedBuffer = await BCH.Mnemonic.toSeed(mnemonic); let masterHDNode; if (NETWORK === `mainnet`) { masterHDNode = BCH.HDNode.fromSeed(rootSeedBuffer); } else { masterHDNode = BCH.HDNode.fromSeed(rootSeedBuffer, 'testnet'); } const Path245 = await deriveAccount(BCH, { masterHDNode, path: "m/44'/245'/0'/0/0", }); const Path145 = await deriveAccount(BCH, { masterHDNode, path: "m/44'/145'/0'/0/0", }); const Path1899 = await deriveAccount(BCH, { masterHDNode, path: "m/44'/1899'/0'/0/0", }); let name = Path1899.cashAddress.slice(12, 17); // Only set the name if it does not currently exist if (wallet && wallet.name) { name = wallet.name; } return { mnemonic: wallet.mnemonic, name, Path245, Path145, Path1899, }; }; const getSavedWallets = async activeWallet => { let savedWallets; try { savedWallets = await localforage.getItem('savedWallets'); if (savedWallets === null) { savedWallets = []; } } catch (err) { console.log(`Error in getSavedWallets`); console.log(err); savedWallets = []; } // Even though the active wallet is still stored in savedWallets, don't return it in this function for (let i = 0; i < savedWallets.length; i += 1) { if ( typeof activeWallet !== 'undefined' && activeWallet.name && savedWallets[i].name === activeWallet.name ) { savedWallets.splice(i, 1); } } return savedWallets; }; const activateWallet = async walletToActivate => { /* If the user is migrating from old version to this version, make sure to save the activeWallet 1 - check savedWallets for the previously active wallet 2 - If not there, add it */ console.log(`Activating wallet ${walletToActivate.name}`); setHasUpdated(false); let currentlyActiveWallet; try { //TODO this should just be a param used to call the function currentlyActiveWallet = await localforage.getItem('wallet'); console.log( `Currently active wallet is ${currentlyActiveWallet.name}`, ); } catch (err) { console.log( `Error in localforage.getItem("wallet") in activateWallet()`, ); return false; } // Get savedwallets let savedWallets; try { savedWallets = await localforage.getItem('savedWallets'); } catch (err) { console.log( `Error in localforage.getItem("savedWallets") in activateWallet()`, ); return false; } /* When a legacy user runs cashtab.com/, their active wallet will be migrated to Path1899 by the getWallet function. getWallet function also makes sure that each Path has a public key Wallets in savedWallets are migrated when they are activated, in this function Two cases to handle 1 - currentlyActiveWallet is valid but its stored keyvalue pair in savedWallets is not > Update savedWallets so this saved wallet is valid 2 - walletToActivate is not valid (because it's a legacy saved wallet) > Update walletToActivate before activation */ // Check savedWallets for currentlyActiveWallet let walletInSavedWallets = false; for (let i = 0; i < savedWallets.length; i += 1) { if (savedWallets[i].name === currentlyActiveWallet.name) { walletInSavedWallets = true; // Make sure the savedWallet entry matches the currentlyActiveWallet entry savedWallets[i] = currentlyActiveWallet; console.log( `Updating savedWallet ${savedWallets[i].name} to match state as currentlyActiveWallet ${currentlyActiveWallet.name}`, ); } } // resave savedWallets try { // Set walletName as the active wallet console.log(`Saving updated savedWallets`); await localforage.setItem('savedWallets', savedWallets); } catch (err) { console.log( `Error in localforage.setItem("savedWallets") in activateWallet() for unmigrated wallet`, ); } if (!walletInSavedWallets) { console.log(`Wallet is not in saved Wallets, adding`); savedWallets.push(currentlyActiveWallet); // resave savedWallets try { // Set walletName as the active wallet await localforage.setItem('savedWallets', savedWallets); } catch (err) { console.log( `Error in localforage.setItem("savedWallets") in activateWallet()`, ); } } // If wallet does not have Path1899, add it // or each of the Path1899, Path145, Path245 does not have a public key, add them // by calling migrateLagacyWallet() if (isLegacyMigrationRequired(walletToActivate)) { // Case 2, described above console.log( `Case 2: Wallet to activate is not in the most up to date Cashtab format`, ); console.log(`walletToActivate`, walletToActivate); walletToActivate = await migrateLegacyWallet(BCH, walletToActivate); } else { // Otherwise activate it as normal // Now that we have verified the last wallet was saved, we can activate the new wallet try { await localforage.setItem('wallet', walletToActivate); } catch (err) { console.log( `Error in localforage.setItem("wallet", walletToActivate) in activateWallet()`, ); return false; } } // Convert all the token balance figures to big numbers // localforage does not preserve BigNumber type; loadStoredWallet restores BigNumber type const liveWalletState = loadStoredWallet(walletToActivate.state); walletToActivate.state = liveWalletState; console.log(`Returning walletToActivate ${walletToActivate.name}`); return walletToActivate; }; const renameSavedWallet = async (oldName, newName) => { // Load savedWallets let savedWallets; try { savedWallets = await localforage.getItem('savedWallets'); } catch (err) { console.log( `Error in await localforage.getItem("savedWallets") in renameSavedWallet`, ); console.log(err); return false; } // Verify that no existing wallet has this name for (let i = 0; i < savedWallets.length; i += 1) { if (savedWallets[i].name === newName) { // return an error return false; } } // change name of desired wallet for (let i = 0; i < savedWallets.length; i += 1) { if (savedWallets[i].name === oldName) { // Replace the name of this entry with the new name savedWallets[i].name = newName; } } // resave savedWallets try { // Set walletName as the active wallet await localforage.setItem('savedWallets', savedWallets); } catch (err) { console.log( `Error in localforage.setItem("savedWallets", savedWallets) in renameSavedWallet()`, ); return false; } return true; }; const renameActiveWallet = async (wallet, oldName, newName) => { // Load savedWallets let savedWallets; try { savedWallets = await localforage.getItem('savedWallets'); } catch (err) { console.log( `Error in await localforage.getItem("savedWallets") in renameSavedWallet`, ); console.log(err); return false; } // Verify that no existing wallet has this name for (let i = 0; i < savedWallets.length; i += 1) { if (savedWallets[i].name === newName) { // return an error return false; } } if (wallet.name === oldName) { wallet.name = newName; setWallet(wallet); } // change name of desired wallet for (let i = 0; i < savedWallets.length; i += 1) { if (savedWallets[i].name === oldName) { // Replace the name of this entry with the new name savedWallets[i].name = newName; } } // resave savedWallets try { // Set walletName as the active wallet await localforage.setItem('savedWallets', savedWallets); await localforage.setItem('wallet', wallet); } catch (err) { console.log( `Error in localforage.setItem("wallet", wallet) in renameActiveWallet()`, ); return false; } return true; }; const deleteWallet = async walletToBeDeleted => { // delete a wallet // returns true if wallet is successfully deleted // otherwise returns false // Load savedWallets let savedWallets; try { savedWallets = await localforage.getItem('savedWallets'); } catch (err) { console.log( `Error in await localforage.getItem("savedWallets") in deleteWallet`, ); console.log(err); return false; } // Iterate over to find the wallet to be deleted // Verify that no existing wallet has this name let walletFoundAndRemoved = false; for (let i = 0; i < savedWallets.length; i += 1) { if (savedWallets[i].name === walletToBeDeleted.name) { // Verify it has the same mnemonic too, that's a better UUID if (savedWallets[i].mnemonic === walletToBeDeleted.mnemonic) { // Delete it savedWallets.splice(i, 1); walletFoundAndRemoved = true; } } } // If you don't find the wallet, return false if (!walletFoundAndRemoved) { return false; } // Resave savedWallets less the deleted wallet try { // Set walletName as the active wallet await localforage.setItem('savedWallets', savedWallets); } catch (err) { console.log( `Error in localforage.setItem("savedWallets", savedWallets) in deleteWallet()`, ); return false; } return true; }; const addNewSavedWallet = async importMnemonic => { // Add a new wallet to savedWallets from importMnemonic or just new wallet const lang = 'english'; // create 128 bit BIP39 mnemonic const Bip39128BitMnemonic = importMnemonic ? importMnemonic : BCH.Mnemonic.generate(128, BCH.Mnemonic.wordLists()[lang]); const newSavedWallet = await getWalletDetails({ mnemonic: Bip39128BitMnemonic.toString(), }); // Get saved wallets let savedWallets; try { savedWallets = await localforage.getItem('savedWallets'); // If this doesn't exist yet, savedWallets === null if (savedWallets === null) { savedWallets = []; } } catch (err) { console.log( `Error in savedWallets = await localforage.getItem("savedWallets") in addNewSavedWallet()`, ); console.log(err); console.log(`savedWallets in error state`, savedWallets); } // If this wallet is from an imported mnemonic, make sure it does not already exist in savedWallets if (importMnemonic) { for (let i = 0; i < savedWallets.length; i += 1) { // Check for condition "importing new wallet that is already in savedWallets" if (savedWallets[i].mnemonic === importMnemonic) { // set this as the active wallet to keep name history console.log( `Error: this wallet already exists in savedWallets`, ); console.log(`Wallet not being added.`); return false; } } } // add newSavedWallet savedWallets.push(newSavedWallet); // update savedWallets try { await localforage.setItem('savedWallets', savedWallets); } catch (err) { console.log( `Error in localforage.setItem("savedWallets", activeWallet) called in createWallet with ${importMnemonic}`, ); console.log(`savedWallets`, savedWallets); console.log(err); } return true; }; const createWallet = async importMnemonic => { const lang = 'english'; // create 128 bit BIP39 mnemonic const Bip39128BitMnemonic = importMnemonic ? importMnemonic : BCH.Mnemonic.generate(128, BCH.Mnemonic.wordLists()[lang]); const wallet = await getWalletDetails({ mnemonic: Bip39128BitMnemonic.toString(), }); try { await localforage.setItem('wallet', wallet); } catch (err) { console.log( `Error setting wallet to wallet indexedDb in createWallet()`, ); console.log(err); } // Since this function is only called from OnBoarding.js, also add this to the saved wallet try { await localforage.setItem('savedWallets', [wallet]); } catch (err) { console.log( `Error setting wallet to savedWallets indexedDb in createWallet()`, ); console.log(err); } return wallet; }; const validateMnemonic = ( mnemonic, wordlist = BCH.Mnemonic.wordLists().english, ) => { let mnemonicTestOutput; try { mnemonicTestOutput = BCH.Mnemonic.validate(mnemonic, wordlist); if (mnemonicTestOutput === 'Valid mnemonic') { return true; } else { return false; } } catch (err) { console.log(err); return false; } }; // Parse chronik ws message for incoming tx notifications const processChronikWsMsg = async (msg, wallet, fiatPrice) => { // get the message type const { type } = msg; // For now, only act on "first seen" transactions, as the only logic to happen is first seen notifications // Dev note: Other chronik msg types // "BlockConnected", arrives as new blocks are found // "Confirmed", arrives as subscribed + seen txid is confirmed in a block if (type !== 'AddedToMempool') { return; } // If you see a tx from your subscribed addresses added to the mempool, then the wallet utxo set has changed // Update it setWalletRefreshInterval(10); // get txid info const txid = msg.txid; const txDetails = await chronik.tx(txid); // parse tx for notification const parsedChronikTx = parseChronikTx(txDetails, wallet); if (parsedChronikTx.incoming) { if (parsedChronikTx.isEtokenTx) { try { // Get the tokenID const incomingTokenId = parsedChronikTx.slpMeta.tokenId; // Check cache for token info // NB this procedure will change when chronik utxo formatting is implemented let incomingTokenInfo = checkWalletForTokenInfo( incomingTokenId, wallet, ); let eTokenAmountReceived; if (!incomingTokenInfo) { // chronik call to genesis tx to get this info const tokenGenesisInfo = await chronik.tx( incomingTokenId, ); incomingTokenInfo = { decimals: tokenGenesisInfo.slpTxData.genesisInfo.decimals, name: tokenGenesisInfo.slpTxData.genesisInfo .tokenName, ticker: tokenGenesisInfo.slpTxData.genesisInfo .tokenTicker, }; } // Calculate eToken amount with decimals eTokenAmountReceived = new BigNumber( parsedChronikTx.etokenAmount, ).shiftedBy(-1 * incomingTokenInfo.decimals); // Send this info to the notification function eTokenReceivedNotification( currency, incomingTokenInfo.ticker, eTokenAmountReceived, incomingTokenInfo.name, ); } catch (err) { // In this case, no incoming tx notification is received // This is an acceptable error condition, no need to fallback to another notification option console.log( `Error parsing eToken data for incoming tx notification`, err, ); } } else { xecReceivedNotificationWebsocket( parsedChronikTx.xecAmount, cashtabSettings, fiatPrice, ); } } }; // Chronik websockets const initializeWebsocket = async (wallet, fiatPrice) => { console.log( `Initializing websocket connection for wallet ${wallet.name}`, ); // Because wallet is set to `false` before it is loaded, do nothing if you find this case // Also return and wait for legacy migration if wallet is not migrated const hash160Array = getHashArrayFromWallet(wallet); if (!wallet || !hash160Array) { return setChronikWebsocket(null); } // Initialize if not in state let ws = chronikWebsocket; if (ws === null) { ws = chronik.ws({ onMessage: msg => { processChronikWsMsg(msg, wallet, fiatPrice); }, onReconnect: e => { // Fired before a reconnect attempt is made: console.log( 'Reconnecting websocket, disconnection cause: ', e, ); }, onConnect: e => { console.log(`Chronik websocket connected`, e); console.log( `Websocket connected, adjusting wallet refresh interval to ${ currency.websocketConnectedRefreshInterval / 1000 }s`, ); setWalletRefreshInterval( currency.websocketConnectedRefreshInterval, ); }, }); // Wait for websocket to be connected: await ws.waitForOpen(); } else { /* If the websocket connection is not null, initializeWebsocket was called because one of the websocket's dependencies changed Update the onMessage method to get the latest dependencies (wallet, fiatPrice) */ ws.onMessage = msg => { processChronikWsMsg(msg, wallet, fiatPrice); }; } // Check if current subscriptions match current wallet let activeSubscriptionsMatchActiveWallet = true; const previousWebsocketSubscriptions = ws._subs; // If there are no previous subscriptions, then activeSubscriptionsMatchActiveWallet is certainly false if (previousWebsocketSubscriptions.length === 0) { activeSubscriptionsMatchActiveWallet = false; } else { const subscribedHash160Array = previousWebsocketSubscriptions.map( function (subscription) { return subscription.scriptPayload; }, ); // Confirm that websocket is subscribed to every address in wallet hash160Array for (let i = 0; i < hash160Array.length; i += 1) { if (!subscribedHash160Array.includes(hash160Array[i])) { activeSubscriptionsMatchActiveWallet = false; } } } // If you are already subscribed to the right addresses, exit here // You get to this situation if fiatPrice changed but wallet.mnemonic did not if (activeSubscriptionsMatchActiveWallet) { // Put connected websocket in state return setChronikWebsocket(ws); } // Unsubscribe to any active subscriptions console.log( `previousWebsocketSubscriptions`, previousWebsocketSubscriptions, ); if (previousWebsocketSubscriptions.length > 0) { for (let i = 0; i < previousWebsocketSubscriptions.length; i += 1) { const unsubHash160 = previousWebsocketSubscriptions[i].scriptPayload; ws.unsubscribe('p2pkh', unsubHash160); console.log(`ws.unsubscribe('p2pkh', ${unsubHash160})`); } } // Subscribe to addresses of current wallet for (let i = 0; i < hash160Array.length; i += 1) { ws.subscribe('p2pkh', hash160Array[i]); console.log(`ws.subscribe('p2pkh', ${hash160Array[i]})`); } // Put connected websocket in state return setChronikWebsocket(ws); }; const handleUpdateWallet = async setWallet => { await loadWalletFromStorageOnStartup(setWallet); }; const loadCashtabSettings = async () => { // get settings object from localforage let localSettings; try { localSettings = await localforage.getItem('settings'); // If there is no keyvalue pair in localforage with key 'settings' if (localSettings === null) { // Create one with the default settings from Ticker.js localforage.setItem('settings', currency.defaultSettings); // Set state to default settings setCashtabSettings(currency.defaultSettings); return currency.defaultSettings; } } catch (err) { console.log(`Error getting cashtabSettings`, err); // TODO If they do not exist, write them // TODO add function to change them setCashtabSettings(currency.defaultSettings); return currency.defaultSettings; } // If you found an object in localforage at the settings key, make sure it's valid if (isValidCashtabSettings(localSettings)) { setCashtabSettings(localSettings); return localSettings; } // If a settings object is present but invalid, parse to find and add missing keys let modifiedLocalSettings = parseInvalidSettingsForMigration(localSettings); if (isValidCashtabSettings(modifiedLocalSettings)) { // modifiedLocalSettings placed in local storage localforage.setItem('settings', modifiedLocalSettings); setCashtabSettings(modifiedLocalSettings); // update missing key in local storage without overwriting existing valid settings return modifiedLocalSettings; } else { // if not valid, also set cashtabSettings to default setCashtabSettings(currency.defaultSettings); // Since this is returning default settings based on an error from reading storage, do not overwrite whatever is in storage return currency.defaultSettings; } }; const loadContactList = async () => { // get contactList object from localforage let localContactList; try { localContactList = await localforage.getItem('contactList'); // If there is no keyvalue pair in localforage with key 'contactList' if (localContactList === null) { // Use an array containing a single empty object localforage.setItem('contactList', [{}]); setContactList([{}]); return [{}]; } } catch (err) { console.log(`Error getting contactList`, err); setContactList([{}]); return [{}]; } // If you found an object in localforage at the contactList key, make sure it's valid if (isValidContactList(localContactList)) { setContactList(localContactList); return localContactList; } // if not valid, also set to default setContactList([{}]); return [{}]; }; const loadCashtabCache = async () => { // get cache object from localforage let localCashtabCache; try { localCashtabCache = await localforage.getItem('cashtabCache'); // If there is no keyvalue pair in localforage with key 'cashtabCache' if (localCashtabCache === null) { // Use the default localforage.setItem( 'cashtabCache', currency.defaultCashtabCache, ); setCashtabCache(currency.defaultCashtabCache); return currency.defaultCashtabCache; } } catch (err) { console.log(`Error getting cashtabCache`, err); setCashtabCache(currency.defaultCashtabCache); return currency.defaultCashtabCache; } // If you found an object in localforage at the cashtabCache key, make sure it's valid if (isValidCashtabCache(localCashtabCache)) { setCashtabCache(localCashtabCache); return localCashtabCache; } // if not valid, also set to default setCashtabCache(currency.defaultCashtabCache); return currency.defaultCashtabCache; }; // With different currency selections possible, need unique intervals for price checks // Must be able to end them and set new ones with new currencies const initializeFiatPriceApi = async selectedFiatCurrency => { // Update fiat price and confirm it is set to make sure ap keeps loading state until this is updated await fetchBchPrice(selectedFiatCurrency); // Set interval for updating the price with given currency const thisFiatInterval = setInterval(function () { fetchBchPrice(selectedFiatCurrency); }, 60000); // set interval in state setCheckFiatInterval(thisFiatInterval); }; const clearFiatPriceApi = fiatPriceApi => { // Clear fiat price check interval of previously selected currency clearInterval(fiatPriceApi); }; const changeCashtabSettings = async (key, newValue) => { // Set loading to true as you do not want to display the fiat price of the last currency // loading = true will lock the UI until the fiat price has updated setLoading(true); // Get settings from localforage let currentSettings; let newSettings; try { currentSettings = await localforage.getItem('settings'); } catch (err) { console.log(`Error in changeCashtabSettings`, err); // Set fiat price to null, which disables fiat sends throughout the app setFiatPrice(null); // Unlock the UI setLoading(false); return; } // Make sure function was called with valid params if (currency.settingsValidation[key].includes(newValue)) { // Update settings newSettings = currentSettings; newSettings[key] = newValue; } else { // Set fiat price to null, which disables fiat sends throughout the app setFiatPrice(null); // Unlock the UI setLoading(false); return; } // Set new settings in state so they are available in context throughout the app setCashtabSettings(newSettings); // If this settings change adjusted the fiat currency, update fiat price if (key === 'fiatCurrency') { clearFiatPriceApi(checkFiatInterval); initializeFiatPriceApi(newValue); } // Write new settings in localforage try { await localforage.setItem('settings', newSettings); } catch (err) { console.log( `Error writing newSettings object to localforage in changeCashtabSettings`, err, ); console.log(`newSettings`, newSettings); // do nothing. If this happens, the user will see default currency next time they load the app. } setLoading(false); }; // Parse for incoming XEC transactions // hasUpdated is set to true in the useInterval function, and re-sets to false during activateWallet // Do not show this notification if websocket connection is live; in this case the websocket will handle it if ( !isActiveWebsocket(chronikWebsocket) && previousBalances && balances && 'totalBalance' in previousBalances && 'totalBalance' in balances && new BigNumber(balances.totalBalance) .minus(previousBalances.totalBalance) .gt(0) && hasUpdated ) { xecReceivedNotification( balances, previousBalances, cashtabSettings, fiatPrice, ); } // Parse for incoming eToken transactions // Do not show this notification if websocket connection is live; in this case the websocket will handle it if ( !isActiveWebsocket(chronikWebsocket) && tokens && tokens[0] && tokens[0].balance && previousTokens && previousTokens[0] && previousTokens[0].balance && hasUpdated === true ) { // If tokens length is greater than previousTokens length, a new token has been received // Note, a user could receive a new token, AND more of existing tokens in between app updates // In this case, the app will only notify about the new token // TODO better handling for all possible cases to cover this // TODO handle with websockets for better response time, less complicated calc if (tokens.length > previousTokens.length) { // Find the new token const tokenIds = tokens.map(({ tokenId }) => tokenId); const previousTokenIds = previousTokens.map( ({ tokenId }) => tokenId, ); //console.log(`tokenIds`, tokenIds); //console.log(`previousTokenIds`, previousTokenIds); // An array with the new token Id const newTokenIdArr = tokenIds.filter( tokenId => !previousTokenIds.includes(tokenId), ); // It's possible that 2 new tokens were received // To do, handle this case const newTokenId = newTokenIdArr[0]; //console.log(newTokenId); // How much of this tokenId did you get? // would be at // Find where the newTokenId is const receivedTokenObjectIndex = tokens.findIndex( x => x.tokenId === newTokenId, ); //console.log(`receivedTokenObjectIndex`, receivedTokenObjectIndex); // Calculate amount received //console.log(`receivedTokenObject:`, tokens[receivedTokenObjectIndex]); const receivedSlpQty = tokens[receivedTokenObjectIndex].balance.toString(); const receivedSlpTicker = tokens[receivedTokenObjectIndex].info.tokenTicker; const receivedSlpName = tokens[receivedTokenObjectIndex].info.tokenName; //console.log(`receivedSlpQty`, receivedSlpQty); // Notification if you received SLP if (receivedSlpQty > 0) { eTokenReceivedNotification( currency, receivedSlpTicker, receivedSlpQty, receivedSlpName, ); } // } else { // If tokens[i].balance > previousTokens[i].balance, a new SLP tx of an existing token has been received // Note that tokens[i].balance is of type BigNumber for (let i = 0; i < tokens.length; i += 1) { if (tokens[i].balance.gt(previousTokens[i].balance)) { // Received this token // console.log(`previousTokenId`, previousTokens[i].tokenId); // console.log(`currentTokenId`, tokens[i].tokenId); if (previousTokens[i].tokenId !== tokens[i].tokenId) { console.log( `TokenIds do not match, breaking from SLP notifications`, ); // Then don't send the notification // Also don't 'continue' ; this means you have sent a token, just stop iterating through break; } const receivedSlpQty = tokens[i].balance.minus( previousTokens[i].balance, ); const receivedSlpTicker = tokens[i].info.tokenTicker; const receivedSlpName = tokens[i].info.tokenName; eTokenReceivedNotification( currency, receivedSlpTicker, receivedSlpQty, receivedSlpName, ); } } } } // Update wallet according to defined interval useInterval(async () => { const wallet = await getWallet(); update({ wallet, }).finally(() => { setLoading(false); if (!hasUpdated) { setHasUpdated(true); } }); }, walletRefreshInterval); const fetchBchPrice = async ( fiatCode = cashtabSettings ? cashtabSettings.fiatCurrency : 'usd', ) => { // Split this variable out in case coingecko changes const cryptoId = currency.coingeckoId; // Keep this in the code, because different URLs will have different outputs require different parsing const priceApiUrl = `https://api.coingecko.com/api/v3/simple/price?ids=${cryptoId}&vs_currencies=${fiatCode}&include_last_updated_at=true`; let bchPrice; let bchPriceJson; try { bchPrice = await fetch(priceApiUrl); //console.log(`bchPrice`, bchPrice); } catch (err) { console.log(`Error fetching BCH Price`); console.log(err); } try { bchPriceJson = await bchPrice.json(); //console.log(`bchPriceJson`, bchPriceJson); let bchPriceInFiat = bchPriceJson[cryptoId][fiatCode]; const validEcashPrice = typeof bchPriceInFiat === 'number'; if (validEcashPrice) { setFiatPrice(bchPriceInFiat); } else { // If API price looks fishy, do not allow app to send using fiat settings setFiatPrice(null); } } catch (err) { console.log(`Error parsing price API response to JSON`); console.log(err); } }; useEffect(async () => { handleUpdateWallet(setWallet); await loadContactList(); await loadCashtabCache(); const initialSettings = await loadCashtabSettings(); initializeFiatPriceApi(initialSettings.fiatCurrency); }, []); /* Run initializeWebsocket(wallet, fiatPrice) each time the wallet or fiatPrice changes Use wallet.mnemonic as the useEffect parameter here because we want to run initializeWebsocket(wallet, fiatPrice) when a new unique wallet is selected, not when the active wallet changes state */ useEffect(async () => { await initializeWebsocket(wallet, fiatPrice); }, [wallet.mnemonic, fiatPrice]); return { BCH, chronik, wallet, fiatPrice, loading, apiError, contactList, cashtabSettings, changeCashtabSettings, getActiveWalletFromLocalForage, getWallet, validateMnemonic, getWalletDetails, getSavedWallets, migrateLegacyWallet, getContactListFromLocalForage, updateContactListInLocalForage, createWallet: async importMnemonic => { setLoading(true); const newWallet = await createWallet(importMnemonic); setWallet(newWallet); update({ wallet: newWallet, }).finally(() => setLoading(false)); }, activateWallet: async walletToActivate => { setLoading(true); // Make sure that the wallet update interval is not called on the former wallet before this function completes console.log( `Suspending wallet update interval while new wallet is activated`, ); setWalletRefreshInterval( currency.websocketDisconnectedRefreshInterval, ); const newWallet = await activateWallet(walletToActivate); console.log(`activateWallet gives newWallet ${newWallet.name}`); // Changing the wallet here will cause `initializeWebsocket` to fire which will update the websocket interval on a successful connection setWallet(newWallet); // Immediately call update on this wallet to populate it in the latest format // Use the instant interval of 10ms that the update function will cancel setWalletRefreshInterval(10); setLoading(false); }, addNewSavedWallet, renameSavedWallet, renameActiveWallet, deleteWallet, }; }; export default useWallet; diff --git a/web/cashtab/src/utils/__tests__/chronik.test.js b/web/cashtab/src/utils/__tests__/chronik.test.js index 7802f4c03..734199436 100644 --- a/web/cashtab/src/utils/__tests__/chronik.test.js +++ b/web/cashtab/src/utils/__tests__/chronik.test.js @@ -1,313 +1,352 @@ import BigNumber from 'bignumber.js'; import { organizeUtxosByType, getPreliminaryTokensArray, finalizeTokensArray, finalizeSlpUtxos, getTokenStats, flattenChronikTxHistory, sortAndTrimChronikTxHistory, parseChronikTx, } from 'utils/chronik'; import { mockChronikUtxos, mockOrganizedUtxosByType, mockPreliminaryTokensArray, mockPreliminaryTokensArrayClone, mockPreliminaryTokensArrayCloneClone, mockChronikTxDetailsResponses, mockFinalTokenArray, mockFinalCachedTokenInfo, mockPartialCachedTokenInfo, mockPartialChronikTxDetailsResponses, mockPreliminarySlpUtxos, mockFinalizedSlpUtxos, mockTokenInfoById, } from '../__mocks__/chronikUtxos'; import { mockChronikTokenResponse, mockGetTokenStatsReturn, } from '../__mocks__/mockChronikTokenStats'; import { mockTxHistoryOfAllAddresses, mockFlatTxHistoryNoUnconfirmed, mockSortedTxHistoryNoUnconfirmed, mockFlatTxHistoryWithUnconfirmed, mockSortedFlatTxHistoryWithUnconfirmed, mockFlatTxHistoryWithAllUnconfirmed, mockSortedFlatTxHistoryWithAllUnconfirmed, mockParseTxWallet, lambdaIncomingXecTx, lambdaOutgoingXecTx, lambdaIncomingEtokenTx, lambdaOutgoingEtokenTx, } from '../__mocks__/chronikTxHistory'; import { ChronikClient } from 'chronik-client'; import { when } from 'jest-when'; +import BCHJS from '@psf/bch-js'; it(`getTokenStats successfully returns a token stats object`, async () => { // Initialize chronik const chronik = new ChronikClient( 'https://FakeChronikUrlToEnsureMocksOnly.com', ); const tokenId = 'bb8e9f685a06a2071d82f757ce19201b4c8e5e96fbe186960a3d65aec83eab20'; /* Mock the API response from chronik.token('tokenId') called in getTokenStats() */ chronik.token = jest.fn(); when(chronik.token) .calledWith(tokenId) .mockResolvedValue(mockChronikTokenResponse); expect(await getTokenStats(chronik, tokenId)).toStrictEqual( mockGetTokenStatsReturn, ); }); it(`organizeUtxosByType successfully splits a chronikUtxos array into slpUtxos and nonSlpUtxos`, () => { expect(organizeUtxosByType(mockChronikUtxos)).toStrictEqual( mockOrganizedUtxosByType, ); const resultingOrganizedUtxosObject = organizeUtxosByType(mockChronikUtxos); const { nonSlpUtxos, preliminarySlpUtxos } = resultingOrganizedUtxosObject; const utxosWithUnexpectedKeys = []; for (let i = 0; i < nonSlpUtxos.length; i += 1) { // None of the objects in mockOrganizedUtxosByType.nonSlpUtxos should have the `slpToken` key // Note: Some may have an `slpMeta` key, if the utxo is from a token burn const nonSlpUtxo = nonSlpUtxos[i]; if ('slpToken' in nonSlpUtxo) { console.log(`unexpected nonSlpUtxo!`, nonSlpUtxo); utxosWithUnexpectedKeys.push(nonSlpUtxo); } } for (let i = 0; i < preliminarySlpUtxos.length; i += 1) { // All of the objects in mockOrganizedUtxosByType.slpUtxos should have the `slpMeta` and `slpToken` keys const slpUtxo = preliminarySlpUtxos[i]; if (!('slpMeta' in slpUtxo) || !('slpToken' in slpUtxo)) { console.log(`unexpected slpUtxo!`, slpUtxo); utxosWithUnexpectedKeys.push(slpUtxo); } } expect(utxosWithUnexpectedKeys.length).toBe(0); // Length of organized utxos should match original expect(preliminarySlpUtxos.length + nonSlpUtxos.length).toBe( mockChronikUtxos.length, ); }); it(`getPreliminaryTokensArray successfully returns an array of all tokenIds and token balances (not yet adjusted for token decimals)`, () => { expect( getPreliminaryTokensArray(mockOrganizedUtxosByType.preliminarySlpUtxos), ).toStrictEqual(mockPreliminaryTokensArray); }); it(`finalizeTokensArray successfully returns finalTokenArray and cachedTokenInfoById even if no cachedTokenInfoById is provided`, async () => { // Initialize chronik const chronik = new ChronikClient( 'https://FakeChronikUrlToEnsureMocksOnly.com', ); /* Mock the API response from chronik.tx('tokenId') called in returnGetTokenInfoChronikPromise -- for each tokenId used */ chronik.tx = jest.fn(); for (let i = 0; i < mockChronikTxDetailsResponses.length; i += 1) { when(chronik.tx) .calledWith(mockChronikTxDetailsResponses[i].txid) .mockResolvedValue(mockChronikTxDetailsResponses[i]); } expect( await finalizeTokensArray(chronik, mockPreliminaryTokensArray), ).toStrictEqual({ finalTokenArray: mockFinalTokenArray, updatedTokenInfoById: mockFinalCachedTokenInfo, newTokensToCache: true, }); }); it(`finalizeTokensArray successfully returns finalTokenArray and cachedTokenInfoById when called with all token info in cache`, async () => { // Initialize chronik const chronik = new ChronikClient( 'https://FakeChronikUrlToEnsureMocksOnly.com', ); expect( await finalizeTokensArray( chronik, mockPreliminaryTokensArrayClone, mockFinalCachedTokenInfo, ), ).toStrictEqual({ finalTokenArray: mockFinalTokenArray, updatedTokenInfoById: mockFinalCachedTokenInfo, newTokensToCache: false, }); }); it(`updateCachedTokenInfoAndFinalizeTokensArray successfully returns finalTokenArray and cachedTokenInfoById when called with some token info in cache`, async () => { // Initialize chronik const chronik = new ChronikClient( 'https://FakeChronikUrlToEnsureMocksOnly.com', ); /* Mock the API response from chronik.tx('tokenId') called in returnGetTokenInfoChronikPromise -- for each tokenId used */ chronik.tx = jest.fn(); for (let i = 0; i < mockPartialChronikTxDetailsResponses.length; i += 1) { when(chronik.tx) .calledWith(mockPartialChronikTxDetailsResponses[i].txid) .mockResolvedValue(mockPartialChronikTxDetailsResponses[i]); } expect( await finalizeTokensArray( chronik, mockPreliminaryTokensArrayCloneClone, mockPartialCachedTokenInfo, ), ).toStrictEqual({ finalTokenArray: mockFinalTokenArray, updatedTokenInfoById: mockFinalCachedTokenInfo, newTokensToCache: true, }); }); it(`finalizeSlpUtxos successfully adds token quantity adjusted for token decimals to preliminarySlpUtxos`, async () => { expect( await finalizeSlpUtxos(mockPreliminarySlpUtxos, mockTokenInfoById), ).toStrictEqual(mockFinalizedSlpUtxos); }); it(`flattenChronikTxHistory successfully combines the result of getTxHistoryChronik into a single array`, async () => { expect( await flattenChronikTxHistory(mockTxHistoryOfAllAddresses), ).toStrictEqual(mockFlatTxHistoryNoUnconfirmed); }); it(`sortAndTrimChronikTxHistory successfully orders the result of flattenChronikTxHistory by blockheight and firstSeenTime if all txs are confirmed, and returns a result of expected length`, async () => { expect( await sortAndTrimChronikTxHistory(mockFlatTxHistoryNoUnconfirmed, 10), ).toStrictEqual(mockSortedTxHistoryNoUnconfirmed); }); it(`sortAndTrimChronikTxHistory successfully orders the result of flattenChronikTxHistory by blockheight and firstSeenTime if some txs are confirmed and others unconfirmed, and returns a result of expected length`, async () => { expect( await sortAndTrimChronikTxHistory(mockFlatTxHistoryWithUnconfirmed, 10), ).toStrictEqual(mockSortedFlatTxHistoryWithUnconfirmed); }); it(`sortAndTrimChronikTxHistory successfully orders the result of flattenChronikTxHistory by blockheight and firstSeenTime if all txs are unconfirmed, and returns a result of expected length`, async () => { expect( await sortAndTrimChronikTxHistory( mockFlatTxHistoryWithAllUnconfirmed, 10, ), ).toStrictEqual(mockSortedFlatTxHistoryWithAllUnconfirmed); }); it(`Successfully parses an incoming XEC tx`, () => { + const BCH = new BCHJS({ + restURL: 'https://FakeBchApiUrlToEnsureMocksOnly.com', + }); + // This function needs to be mocked as bch-js functions that require Buffer types do not work in jest environment + BCH.Address.hash160ToCash = jest + .fn() + .mockReturnValue( + 'bitcoincash:qp89xgjhcqdnzzemts0aj378nfe2mhu9yvll3cvjwd', + ); expect( - parseChronikTx(lambdaIncomingXecTx, mockParseTxWallet), + parseChronikTx(BCH, lambdaIncomingXecTx, mockParseTxWallet), ).toStrictEqual({ incoming: true, xecAmount: '42', originatingHash160: '4e532257c01b310b3b5c1fd947c79a72addf8523', isEtokenTx: false, legacy: { airdropFlag: false, airdropTokenId: '', amountReceived: '42', amountSent: 0, decryptionSuccess: false, isCashtabMessage: false, isEncryptedMessage: false, opReturnMessage: '', outgoingTx: false, + replyAddress: 'ecash:qp89xgjhcqdnzzemts0aj378nfe2mhu9yvxj9nhgg6', tokenTx: false, }, }); }); it(`Successfully parses an outgoing XEC tx`, () => { + const BCH = new BCHJS({ + restURL: 'https://FakeBchApiUrlToEnsureMocksOnly.com', + }); + // This function needs to be mocked as bch-js functions that require Buffer types do not work in jest environment + BCH.Address.hash160ToCash = jest + .fn() + .mockReturnValue( + 'bitcoincash:qpmytrdsakt0axrrlswvaj069nat3p9s7ct4lsf8k9', + ); expect( - parseChronikTx(lambdaOutgoingXecTx, mockParseTxWallet), + parseChronikTx(BCH, lambdaOutgoingXecTx, mockParseTxWallet), ).toStrictEqual({ incoming: false, xecAmount: '222', originatingHash160: '76458db0ed96fe9863fc1ccec9fa2cfab884b0f6', isEtokenTx: false, legacy: { airdropFlag: false, airdropTokenId: '', amountReceived: 0, amountSent: '222', decryptionSuccess: false, isCashtabMessage: false, isEncryptedMessage: false, opReturnMessage: '', outgoingTx: true, + replyAddress: 'ecash:qpmytrdsakt0axrrlswvaj069nat3p9s7cjctmjasj', tokenTx: false, }, }); }); it(`Successfully parses an incoming eToken tx`, () => { + const BCH = new BCHJS(); + // This function needs to be mocked as bch-js functions that require Buffer types do not work in jest environment + BCH.Address.hash160ToCash = jest + .fn() + .mockReturnValue( + 'bitcoincash:qp89xgjhcqdnzzemts0aj378nfe2mhu9yvll3cvjwd', + ); expect( - parseChronikTx(lambdaIncomingEtokenTx, mockParseTxWallet), + parseChronikTx(BCH, lambdaIncomingEtokenTx, mockParseTxWallet), ).toStrictEqual({ incoming: true, xecAmount: '5.46', isEtokenTx: true, originatingHash160: '4e532257c01b310b3b5c1fd947c79a72addf8523', slpMeta: { tokenId: '4bd147fc5d5ff26249a9299c46b80920c0b81f59a60e05428262160ebee0b0c3', tokenType: 'FUNGIBLE', txType: 'SEND', }, etokenAmount: '12', legacy: { airdropFlag: false, airdropTokenId: '', amountReceived: '5.46', amountSent: 0, decryptionSuccess: false, isCashtabMessage: false, isEncryptedMessage: false, opReturnMessage: '', outgoingTx: false, + replyAddress: 'ecash:qp89xgjhcqdnzzemts0aj378nfe2mhu9yvxj9nhgg6', tokenTx: true, }, }); }); it(`Successfully parses an outgoing eToken tx`, () => { + const BCH = new BCHJS({ + restURL: 'https://FakeBchApiUrlToEnsureMocksOnly.com', + }); + // This function needs to be mocked as bch-js functions that require Buffer types do not work in jest environment + BCH.Address.hash160ToCash = jest + .fn() + .mockReturnValue( + 'bitcoincash:qpmytrdsakt0axrrlswvaj069nat3p9s7ct4lsf8k9', + ); expect( - parseChronikTx(lambdaOutgoingEtokenTx, mockParseTxWallet), + parseChronikTx(BCH, lambdaOutgoingEtokenTx, mockParseTxWallet), ).toStrictEqual({ incoming: false, xecAmount: '5.46', isEtokenTx: true, originatingHash160: '76458db0ed96fe9863fc1ccec9fa2cfab884b0f6', slpMeta: { tokenId: '4bd147fc5d5ff26249a9299c46b80920c0b81f59a60e05428262160ebee0b0c3', tokenType: 'FUNGIBLE', txType: 'SEND', }, etokenAmount: '17', legacy: { airdropFlag: false, airdropTokenId: '', amountReceived: 0, amountSent: '5.46', decryptionSuccess: false, isCashtabMessage: false, isEncryptedMessage: false, opReturnMessage: '', outgoingTx: true, + replyAddress: 'ecash:qpmytrdsakt0axrrlswvaj069nat3p9s7cjctmjasj', tokenTx: true, }, }); }); diff --git a/web/cashtab/src/utils/chronik.js b/web/cashtab/src/utils/chronik.js index 1d9e9e6b0..7c082fab3 100644 --- a/web/cashtab/src/utils/chronik.js +++ b/web/cashtab/src/utils/chronik.js @@ -1,762 +1,774 @@ // Chronik methods import BigNumber from 'bignumber.js'; import { currency } from 'components/Common/Ticker'; import { parseOpReturn, convertToEncryptStruct, getHashArrayFromWallet, getUtxoWif, } from 'utils/cashMethods'; import ecies from 'ecies-lite'; import wif from 'wif'; +import cashaddr from 'ecashaddrjs'; // Return false if do not get a valid response export const getTokenStats = async (chronik, tokenId) => { try { // token attributes available via chronik's token() method let tokenResponseObj = await chronik.token(tokenId); const tokenDecimals = tokenResponseObj.slpTxData.genesisInfo.decimals; // additional arithmetic to account for token decimals // circulating supply not provided by chronik, calculate via totalMinted - totalBurned tokenResponseObj.circulatingSupply = new BigNumber( tokenResponseObj.tokenStats.totalMinted, ) .minus(new BigNumber(tokenResponseObj.tokenStats.totalBurned)) .shiftedBy(-1 * tokenDecimals) .toString(); tokenResponseObj.tokenStats.totalMinted = new BigNumber( tokenResponseObj.tokenStats.totalMinted, ) .shiftedBy(-1 * tokenDecimals) .toString(); tokenResponseObj.initialTokenQuantity = new BigNumber( tokenResponseObj.initialTokenQuantity, ) .shiftedBy(-1 * tokenDecimals) .toString(); tokenResponseObj.tokenStats.totalBurned = new BigNumber( tokenResponseObj.tokenStats.totalBurned, ) .shiftedBy(-1 * tokenDecimals) .toString(); return tokenResponseObj; } catch (err) { console.log( `Error fetching token stats for tokenId ${tokenId}: ` + err, ); return false; } }; /* Note: chronik.script('p2pkh', hash160).utxos(); is not readily mockable in jest Hence it is necessary to keep this out of any functions that require unit testing */ export const getUtxosSingleHashChronik = async (chronik, hash160) => { // Get utxos at a single address, which chronik takes in as a hash160 let utxos; try { utxos = await chronik.script('p2pkh', hash160).utxos(); if (utxos.length === 0) { // Chronik returns an empty array if there are no utxos at this hash160 return []; } /* Chronik returns an array of with a single object if there are utxos at this hash 160 [ { outputScript: , utxos:[{utxo}, {utxo}, ..., {utxo}] } ] */ // Return only the array of utxos at this address return utxos[0].utxos; } catch (err) { console.log(`Error in chronik.utxos(${hash160})`, err); } }; export const returnGetUtxosChronikPromise = (chronik, hash160AndAddressObj) => { /* Chronik thinks in hash160s, but people and wallets think in addresses Add the address to each utxo */ return new Promise((resolve, reject) => { getUtxosSingleHashChronik(chronik, hash160AndAddressObj.hash160).then( result => { for (let i = 0; i < result.length; i += 1) { const thisUtxo = result[i]; thisUtxo.address = hash160AndAddressObj.address; } resolve(result); }, err => { reject(err); }, ); }); }; export const getUtxosChronik = async (chronik, hash160sMappedToAddresses) => { /* Chronik only accepts utxo requests for one address at a time Construct an array of promises for each address Note: Chronik requires the hash160 of an address for this request */ const chronikUtxoPromises = []; for (let i = 0; i < hash160sMappedToAddresses.length; i += 1) { const thisPromise = returnGetUtxosChronikPromise( chronik, hash160sMappedToAddresses[i], ); chronikUtxoPromises.push(thisPromise); } const allUtxos = await Promise.all(chronikUtxoPromises); // Since each individual utxo has address information, no need to keep them in distinct arrays // Combine into one array of all utxos const flatUtxos = allUtxos.flat(); return flatUtxos; }; export const organizeUtxosByType = chronikUtxos => { /* Convert chronik utxos (returned by getUtxosChronik function, above) to match shape of existing slpBalancesAndUtxos object This means sequestering eToken utxos from non-eToken utxos For legacy reasons, the term "SLP" is still sometimes used to describe an eToken So, SLP utxos === eToken utxos, it's just a semantics difference here */ const nonSlpUtxos = []; const preliminarySlpUtxos = []; for (let i = 0; i < chronikUtxos.length; i += 1) { // Construct nonSlpUtxos and slpUtxos arrays const thisUtxo = chronikUtxos[i]; if (typeof thisUtxo.slpToken !== 'undefined') { preliminarySlpUtxos.push(thisUtxo); } else { nonSlpUtxos.push(thisUtxo); } } return { preliminarySlpUtxos, nonSlpUtxos }; }; export const getPreliminaryTokensArray = preliminarySlpUtxos => { // Iterate over the slpUtxos to create the 'tokens' object let tokensById = {}; preliminarySlpUtxos.forEach(preliminarySlpUtxo => { /* Note that a wallet could have many eToken utxos all belonging to the same eToken For example, a user could have 100 of a certain eToken, but this is composed of four utxos, one for 17, one for 50, one for 30, one for 3 */ // Start with the existing object for this particular token, if it exists let token = tokensById[preliminarySlpUtxo.slpMeta.tokenId]; if (token) { if (preliminarySlpUtxo.slpToken.amount) { token.balance = token.balance.plus( new BigNumber(preliminarySlpUtxo.slpToken.amount), ); } } else { // If it does not exist, create it token = {}; token.tokenId = preliminarySlpUtxo.slpMeta.tokenId; if (preliminarySlpUtxo.slpToken.amount) { token.balance = new BigNumber( preliminarySlpUtxo.slpToken.amount, ); } else { token.balance = new BigNumber(0); } tokensById[preliminarySlpUtxo.slpMeta.tokenId] = token; } }); const preliminaryTokensArray = Object.values(tokensById); return preliminaryTokensArray; }; const returnGetTokenInfoChronikPromise = (chronik, tokenId) => { /* The chronik.tx(txid) API call returns extensive transaction information For the purposes of finalizing token information, we only need the token metadata This function returns a promise that extracts only this needed information from the chronik.tx(txid) API call In this way, calling Promise.all() on an array of tokenIds that lack metadata will return an array with all required metadata */ return new Promise((resolve, reject) => { chronik.tx(tokenId).then( result => { const thisTokenInfo = result.slpTxData.genesisInfo; thisTokenInfo.tokenId = tokenId; // You only want the genesis info for tokenId resolve(thisTokenInfo); }, err => { reject(err); }, ); }); }; export const processPreliminaryTokensArray = ( preliminaryTokensArray, tokenInfoByTokenId, ) => { /* Iterate over preliminaryTokensArray to 1 - Add slp metadata (token ticker, name, other metadata) 2 - Calculate the token balance. Token balance in preliminaryTokensArray does not take into account the decimal places of the token...so it is incorrect. */ const finalTokenArray = []; for (let i = 0; i < preliminaryTokensArray.length; i += 1) { const thisToken = preliminaryTokensArray[i]; const thisTokenId = thisToken.tokenId; // Because tokenInfoByTokenId is indexed by tokenId, it's easy to reference const thisTokenInfo = tokenInfoByTokenId[thisTokenId]; // The decimals are specifically needed to calculate the correct balance const thisTokenDecimals = thisTokenInfo.decimals; // Add info object to token thisToken.info = thisTokenInfo; // Update balance according to decimals thisToken.balance = thisToken.balance.shiftedBy(-1 * thisTokenDecimals); // Now that you have the metadata and the correct balance, // preliminaryTokenInfo is finalTokenInfo finalTokenArray.push(thisToken); } return finalTokenArray; }; export const finalizeTokensArray = async ( chronik, preliminaryTokensArray, cachedTokenInfoById = {}, ) => { // Iterate over preliminaryTokensArray to determine what tokens you need to make API calls for // Create an array of promises // Each promise is a chronik API call to obtain token metadata for this token ID const getTokenInfoPromises = []; for (let i = 0; i < preliminaryTokensArray.length; i += 1) { const thisTokenId = preliminaryTokensArray[i].tokenId; // See if you already have this info in cachedTokenInfo if (thisTokenId in cachedTokenInfoById) { // If you already have this info in cache, do not create an API request for it continue; } const thisTokenInfoPromise = returnGetTokenInfoChronikPromise( chronik, thisTokenId, ); getTokenInfoPromises.push(thisTokenInfoPromise); } const newTokensToCache = getTokenInfoPromises.length > 0; // Get all the token info you need let tokenInfoArray = []; try { tokenInfoArray = await Promise.all(getTokenInfoPromises); } catch (err) { console.log(`Error in Promise.all(getTokenInfoPromises)`, err); } // Add the token info you received from those API calls to // your token info cache object, cachedTokenInfoByTokenId const updatedTokenInfoById = cachedTokenInfoById; for (let i = 0; i < tokenInfoArray.length; i += 1) { /* tokenInfoArray is an array of objects that look like { "tokenTicker": "ST", "tokenName": "ST", "tokenDocumentUrl": "developer.bitcoin.com", "tokenDocumentHash": "", "decimals": 0, "tokenId": "bf24d955f59351e738ecd905966606a6837e478e1982943d724eab10caad82fd" } */ const thisTokenInfo = tokenInfoArray[i]; const thisTokenId = thisTokenInfo.tokenId; // Add this entry to updatedTokenInfoById updatedTokenInfoById[thisTokenId] = thisTokenInfo; } // Now use cachedTokenInfoByTokenId object to finalize token info // Split this out into a separate function so you can unit test const finalTokenArray = processPreliminaryTokensArray( preliminaryTokensArray, updatedTokenInfoById, ); return { finalTokenArray, updatedTokenInfoById, newTokensToCache }; }; export const finalizeSlpUtxos = (preliminarySlpUtxos, tokenInfoById) => { // We need tokenQty in each slpUtxo to support transaction creation // Add this info here const finalizedSlpUtxos = []; for (let i = 0; i < preliminarySlpUtxos.length; i += 1) { const thisUtxo = preliminarySlpUtxos[i]; const thisTokenId = thisUtxo.slpMeta.tokenId; const { decimals } = tokenInfoById[thisTokenId]; // Update balance according to decimals thisUtxo.tokenQty = new BigNumber(thisUtxo.slpToken.amount) .shiftedBy(-1 * decimals) .toString(); // SLP utxos also require tokenId and decimals directly in the utxo object // This is bad organization but necessary until bch-js is refactored // https://github.com/Permissionless-Software-Foundation/bch-js/blob/master/src/slp/tokentype1.js#L217 thisUtxo.tokenId = thisTokenId; thisUtxo.decimals = decimals; finalizedSlpUtxos.push(thisUtxo); } return finalizedSlpUtxos; }; export const flattenChronikTxHistory = txHistoryOfAllAddresses => { // Create an array of all txs let flatTxHistoryArray = []; for (let i = 0; i < txHistoryOfAllAddresses.length; i += 1) { const txHistoryResponseOfThisAddress = txHistoryOfAllAddresses[i]; const txHistoryOfThisAddress = txHistoryResponseOfThisAddress.txs; flatTxHistoryArray = flatTxHistoryArray.concat(txHistoryOfThisAddress); } return flatTxHistoryArray; }; export const sortAndTrimChronikTxHistory = ( flatTxHistoryArray, txHistoryCount, ) => { // Isolate unconfirmed txs // In chronik, unconfirmed txs have an `undefined` block key const unconfirmedTxs = []; const confirmedTxs = []; for (let i = 0; i < flatTxHistoryArray.length; i += 1) { const thisTx = flatTxHistoryArray[i]; if (typeof thisTx.block === 'undefined') { unconfirmedTxs.push(thisTx); } else { confirmedTxs.push(thisTx); } } console.log(`confirmed txs`, confirmedTxs); console.log(`unconfirmed txs`, unconfirmedTxs); // Sort confirmed txs by blockheight, and then timeFirstSeen const sortedConfirmedTxHistoryArray = confirmedTxs.sort( (a, b) => // We want more recent blocks i.e. higher blockheights to have earlier array indices b.block.height - a.block.height || // For blocks with the same height, we want more recent timeFirstSeen i.e. higher timeFirstSeen to have earlier array indices b.timeFirstSeen - a.timeFirstSeen, ); // Sort unconfirmed txs by timeFirstSeen const sortedUnconfirmedTxHistoryArray = unconfirmedTxs.sort( (a, b) => b.timeFirstSeen - a.timeFirstSeen, ); // The unconfirmed txs are more recent, so they should be inserted into an array before the confirmed txs const sortedChronikTxHistoryArray = sortedUnconfirmedTxHistoryArray.concat( sortedConfirmedTxHistoryArray, ); const trimmedAndSortedChronikTxHistoryArray = sortedChronikTxHistoryArray.splice(0, txHistoryCount); return trimmedAndSortedChronikTxHistoryArray; }; export const returnGetTxHistoryChronikPromise = ( chronik, hash160AndAddressObj, ) => { /* Chronik thinks in hash160s, but people and wallets think in addresses Add the address to each utxo */ return new Promise((resolve, reject) => { chronik .script('p2pkh', hash160AndAddressObj.hash160) .history(/*page=*/ 0, /*page_size=*/ currency.txHistoryCount) .then( result => { console.log( `result for ${hash160AndAddressObj.hash160}`, result, ); resolve(result); }, err => { reject(err); }, ); }); }; -export const parseChronikTx = (tx, wallet) => { +export const parseChronikTx = (BCH, tx, wallet) => { const walletHash160s = getHashArrayFromWallet(wallet); const { inputs, outputs } = tx; // Assign defaults let incoming = true; let xecAmount = new BigNumber(0); let originatingHash160 = ''; let etokenAmount = new BigNumber(0); const isEtokenTx = 'slpTxData' in tx && typeof tx.slpTxData !== 'undefined'; // Defining variables used in lines legacy parseTxData function from useBCH.js let substring = ''; let airdropFlag = false; let airdropTokenId = ''; let opReturnMessage = ''; let isCashtabMessage = false; let isEncryptedMessage = false; let decryptionSuccess = false; + let replyAddress = ''; // Iterate over inputs to see if this is an incoming tx (incoming === true) for (let i = 0; i < inputs.length; i += 1) { const thisInput = inputs[i]; const thisInputSendingHash160 = thisInput.outputScript; /* Assume the first input is the originating address https://en.bitcoin.it/wiki/Script for reference Assume standard pay-to-pubkey-hash tx scriptPubKey: OP_DUP OP_HASH160 OP_EQUALVERIFY OP_CHECKSIG 76 + a9 + 14 = OP_DUP + OP_HASH160 + 14 Bytes to push 88 + ac = OP_EQUALVERIFY + OP_CHECKSIG So, the hash160 we want will be in between '76a914' and '88ac' ...most of the time ;) */ try { originatingHash160 = thisInputSendingHash160.substring( thisInputSendingHash160.indexOf('76a914') + '76a914'.length, thisInputSendingHash160.lastIndexOf('88ac'), ); + + let replyAddressBchFormat = + BCH.Address.hash160ToCash(originatingHash160); + + const { type, hash } = cashaddr.decode(replyAddressBchFormat); + replyAddress = cashaddr.encode('ecash', type, hash); + console.log(`replyAddressXecFormat`, replyAddress); } catch (err) { + console.log(`err from ${originatingHash160}`, err); // If the transaction is nonstandard, don't worry about a reply address for now originatingHash160 = 'N/A'; } for (let j = 0; j < walletHash160s.length; j += 1) { const thisWalletHash160 = walletHash160s[j]; if (thisInputSendingHash160.includes(thisWalletHash160)) { // Then this is an outgoing tx incoming = false; // Break out of this for loop once you know this is an incoming tx break; } } } // Iterate over outputs to get the amount sent for (let i = 0; i < outputs.length; i += 1) { const thisOutput = outputs[i]; const thisOutputReceivedAtHash160 = thisOutput.outputScript; // Check for OP_RETURN msg if ( thisOutput.value === '0' && typeof thisOutput.slpToken === 'undefined' ) { let hex = thisOutputReceivedAtHash160; let parsedOpReturnArray = parseOpReturn(hex); // Exactly copying lines 177-293 of useBCH.js // Differences // 1 - patched ecies not async error // 2 - Removed if loop for tx being token, as this is handled elsewhere here if (!parsedOpReturnArray) { console.log( 'useBCH.parsedTxData() error: parsed array is empty', ); break; } let message = ''; let txType = parsedOpReturnArray[0]; if (txType === currency.opReturn.appPrefixesHex.airdrop) { // this is to facilitate special Cashtab-specific cases of airdrop txs, both with and without msgs // The UI via Tx.js can check this airdropFlag attribute in the parsedTx object to conditionally render airdrop-specific formatting if it's true airdropFlag = true; // index 0 is drop prefix, 1 is the token Id, 2 is msg prefix, 3 is msg airdropTokenId = parsedOpReturnArray[1]; txType = parsedOpReturnArray[2]; // remove the first two elements of airdrop prefix and token id from array so the array parsing logic below can remain unchanged parsedOpReturnArray.splice(0, 2); // index 0 now becomes msg prefix, 1 becomes the msg } if (txType === currency.opReturn.appPrefixesHex.cashtab) { // this is a Cashtab message try { opReturnMessage = Buffer.from( parsedOpReturnArray[1], 'hex', ); isCashtabMessage = true; } catch (err) { // soft error if an unexpected or invalid cashtab hex is encountered opReturnMessage = ''; console.log( 'useBCH.parsedTxData() error: invalid cashtab msg hex: ' + parsedOpReturnArray[1], ); } } else if ( txType === currency.opReturn.appPrefixesHex.cashtabEncrypted ) { // this is an encrypted Cashtab message let msgString = parsedOpReturnArray[1]; let fundingWif, privateKeyObj, privateKeyBuff; if ( wallet && wallet.state && wallet.state.slpBalancesAndUtxos && wallet.state.slpBalancesAndUtxos.nonSlpUtxos[0] ) { fundingWif = getUtxoWif( wallet.state.slpBalancesAndUtxos.nonSlpUtxos[0], wallet, ); privateKeyObj = wif.decode(fundingWif); privateKeyBuff = privateKeyObj.privateKey; if (!privateKeyBuff) { throw new Error('Private key extraction error'); } } else { break; } let structData; let decryptedMessage; try { // Convert the hex encoded message to a buffer const msgBuf = Buffer.from(msgString, 'hex'); // Convert the bufer into a structured object. structData = convertToEncryptStruct(msgBuf); decryptedMessage = ecies.decrypt( privateKeyBuff, structData, ); decryptionSuccess = true; } catch (err) { console.log( 'useBCH.parsedTxData() decryption error: ' + err, ); decryptedMessage = 'Only the message recipient can view this'; } isCashtabMessage = true; isEncryptedMessage = true; opReturnMessage = decryptedMessage; } else { // this is an externally generated message message = txType; // index 0 is the message content in this instance // if there are more than one part to the external message const arrayLength = parsedOpReturnArray.length; for (let i = 1; i < arrayLength; i++) { message = message + parsedOpReturnArray[i]; } try { opReturnMessage = Buffer.from(message, 'hex'); } catch (err) { // soft error if an unexpected or invalid cashtab hex is encountered opReturnMessage = ''; console.log( 'useBCH.parsedTxData() error: invalid external msg hex: ' + substring, ); } } } // Find amounts at your wallet's addresses for (let j = 0; j < walletHash160s.length; j += 1) { const thisWalletHash160 = walletHash160s[j]; if (thisOutputReceivedAtHash160.includes(thisWalletHash160)) { // If incoming tx, this is amount received by the user's wallet // if outgoing tx (incoming === false), then this is a change amount const thisOutputAmount = new BigNumber(thisOutput.value); xecAmount = incoming ? xecAmount.plus(thisOutputAmount) : xecAmount.minus(thisOutputAmount); // Parse token qty if token tx // Note: edge case this is a token tx that sends XEC to Cashtab recipient but token somewhere else if (isEtokenTx) { try { const thisEtokenAmount = new BigNumber( thisOutput.slpToken.amount, ); etokenAmount = incoming ? etokenAmount.plus(thisEtokenAmount) : etokenAmount.minus(thisEtokenAmount); } catch (err) { // edge case described above; in this case there is zero eToken value for this Cashtab recipient, so add 0 etokenAmount.plus(new BigNumber(0)); } } } } // Output amounts not at your wallet are sent amounts if !incoming if (!incoming) { const thisOutputAmount = new BigNumber(thisOutput.value); xecAmount = xecAmount.plus(thisOutputAmount); if (isEtokenTx) { try { const thisEtokenAmount = new BigNumber( thisOutput.slpToken.amount, ); etokenAmount = etokenAmount.plus(thisEtokenAmount); } catch (err) { // NB the edge case described above cannot exist in an outgoing tx // because the eTokens sent originated from this wallet } } } } // Convert from sats to XEC xecAmount = xecAmount.shiftedBy(-1 * currency.cashDecimals); // Convert from BigNumber to string xecAmount = xecAmount.toString(); etokenAmount = etokenAmount.toString(); // Convert opReturnMessage to string opReturnMessage = Buffer.from(opReturnMessage).toString(); // Return eToken specific fields if eToken tx if (isEtokenTx) { const { slpMeta } = tx.slpTxData; return { incoming, xecAmount, originatingHash160, isEtokenTx, etokenAmount, slpMeta, legacy: { amountSent: incoming ? 0 : xecAmount, amountReceived: incoming ? xecAmount : 0, outgoingTx: !incoming, tokenTx: true, airdropFlag, airdropTokenId, opReturnMessage: '', isCashtabMessage, isEncryptedMessage, decryptionSuccess, + replyAddress, }, }; } // Otherwise do not include these fields return { incoming, xecAmount, originatingHash160, isEtokenTx, legacy: { amountSent: incoming ? 0 : xecAmount, amountReceived: incoming ? xecAmount : 0, outgoingTx: !incoming, tokenTx: false, airdropFlag, airdropTokenId, opReturnMessage, isCashtabMessage, isEncryptedMessage, decryptionSuccess, + replyAddress, }, }; }; -export const getTxHistoryChronik = async (chronik, wallet) => { +export const getTxHistoryChronik = async (chronik, BCH, wallet) => { // Create array of promises to get chronik history for each address // Combine them all and sort by blockheight and firstSeen // Add all the info cashtab needs to make them useful const hash160AndAddressObjArray = [ { address: wallet.Path145.cashAddress, hash160: wallet.Path145.hash160, }, { address: wallet.Path245.cashAddress, hash160: wallet.Path245.hash160, }, { address: wallet.Path1899.cashAddress, hash160: wallet.Path1899.hash160, }, ]; let txHistoryPromises = []; for (let i = 0; i < hash160AndAddressObjArray.length; i += 1) { const txHistoryPromise = returnGetTxHistoryChronikPromise( chronik, hash160AndAddressObjArray[i], ); txHistoryPromises.push(txHistoryPromise); } let txHistoryOfAllAddresses; try { txHistoryOfAllAddresses = await Promise.all(txHistoryPromises); } catch (err) { console.log(`Error in Promise.all(txHistoryPromises)`, err); } console.log(`txHistoryOfAllAddresses`, txHistoryOfAllAddresses); const flatTxHistoryArray = flattenChronikTxHistory(txHistoryOfAllAddresses); console.log(`flatTxHistoryArray`, flatTxHistoryArray); const sortedTxHistoryArray = sortAndTrimChronikTxHistory( flatTxHistoryArray, currency.txHistoryCount, ); // Parse txs const parsedTxs = []; for (let i = 0; i < sortedTxHistoryArray.length; i += 1) { const sortedTx = sortedTxHistoryArray[i]; - sortedTx.parsed = parseChronikTx(sortedTx, wallet); + sortedTx.parsed = parseChronikTx(BCH, sortedTx, wallet); parsedTxs.push(sortedTx); } return parsedTxs; };