diff --git a/web/cashtab/src/components/Send/SendToken.js b/web/cashtab/src/components/Send/SendToken.js index 13b704f02..aae0d5df1 100644 --- a/web/cashtab/src/components/Send/SendToken.js +++ b/web/cashtab/src/components/Send/SendToken.js @@ -1,793 +1,790 @@ import React, { useState, useEffect } from 'react'; import { Link } from 'react-router-dom'; import PropTypes from 'prop-types'; import { WalletContext } from 'utils/context'; import { Form, message, Row, Col, Alert, Descriptions, Modal, Button, Input, } from 'antd'; import PrimaryButton, { SecondaryButton, } from 'components/Common/PrimaryButton'; import { FireTwoTone } from '@ant-design/icons'; import { DestinationAmount, DestinationAddressSingle, AntdFormWrapper, } from 'components/Common/EnhancedInputs'; import useBCH from 'hooks/useBCH'; import { SidePaddingCtn } from 'components/Common/Atoms'; import BalanceHeaderToken from 'components/Common/BalanceHeaderToken'; import { Redirect } from 'react-router-dom'; import useWindowDimensions from 'hooks/useWindowDimensions'; import usePrevious from 'hooks/usePrevious'; import { isMobile, isIOS, isSafari } from 'react-device-detect'; import { Img } from 'react-image'; import makeBlockie from 'ethereum-blockies-base64'; import BigNumber from 'bignumber.js'; import { currency, parseAddressForParams } from 'components/Common/Ticker.js'; import { Event } from 'utils/GoogleAnalytics'; -import { getWalletState, toLegacyToken } from 'utils/cashMethods'; +import { getWalletState } from 'utils/cashMethods'; import ApiError from 'components/Common/ApiError'; import { sendTokenNotification, errorNotification, burnTokenNotification, } from 'components/Common/Notifications'; import { isValidXecAddress, isValidEtokenAddress, isValidEtokenBurnAmount, } from 'utils/validation'; import { getTokenStats } from 'utils/chronik'; import { formatDate } from 'utils/formatting'; import styled, { css } from 'styled-components'; import TokenIcon from 'components/Tokens/TokenIcon'; const AntdDescriptionsCss = css` .ant-descriptions-item-label, .ant-input-number, .ant-descriptions-item-content { background-color: ${props => props.theme.contrast} !important; color: ${props => props.theme.dropdownText}; } .ant-descriptions-title { color: ${props => props.theme.lightWhite}; } `; const AntdDescriptionsWrapper = styled.div` ${AntdDescriptionsCss} `; const AirdropButton = styled.div` text-align: center; width: 100%; padding: 10px; border-radius: 5px; background: ${props => props.theme.sentMessage}; a { color: ${props => props.theme.darkBlue}; margin: 0; font-size: 11px; border: 1px solid ${props => props.theme.darkBlue}; border-radius: 5px; padding: 2px 10px; opacity: 0.6; } a:hover { opacity: 1; border-color: ${props => props.theme.eCashBlue}; color: ${props => props.theme.contrast}; background: ${props => props.theme.eCashBlue}; } ${({ received, ...props }) => received && ` text-align: left; background: ${props.theme.receivedMessage}; `} `; const SendToken = ({ tokenId, jestBCH, passLoadingStatus }) => { const { BCH, wallet, apiError, cashtabSettings, chronik } = React.useContext(WalletContext); const walletState = getWalletState(wallet); const { tokens } = walletState; const token = tokens.find(token => token.tokenId === tokenId); const previousToken = usePrevious(token); const [tokenStats, setTokenStats] = useState(null); const [queryStringText, setQueryStringText] = useState(null); const [sendTokenAddressError, setSendTokenAddressError] = useState(false); const [sendTokenAmountError, setSendTokenAmountError] = useState(false); const [eTokenBurnAmount, setETokenBurnAmount] = useState(new BigNumber(1)); const [showConfirmBurnEtoken, setShowConfirmBurnEtoken] = useState(false); const [burnTokenAmountError, setBurnTokenAmountError] = useState(false); const [burnConfirmationValid, setBurnConfirmationValid] = useState(null); const [confirmationOfEtokenToBeBurnt, setConfirmationOfEtokenToBeBurnt] = useState(''); const [bchObj, setBchObj] = useState(false); // Get device window width // If this is less than 769, the page will open with QR scanner open const { width } = useWindowDimensions(); // Load with QR code open if device is mobile and NOT iOS + anything but safari const scannerSupported = cashtabSettings && cashtabSettings.autoCameraOn === true && width < 769 && isMobile && !(isIOS && !isSafari); const [isModalVisible, setIsModalVisible] = useState(false); const [formData, setFormData] = useState({ value: '', address: '', }); const { getRestUrl, sendToken, burnToken } = useBCH(); useEffect(() => { // jestBCH is only ever specified for unit tests, otherwise app will use getBCH(); const activeBCH = jestBCH ? jestBCH : BCH; // set the BCH instance to state, for other functions to reference setBchObj(activeBCH); }, [BCH]); // Fetch token stats if you do not have them and API did not return an error if (tokenStats === null) { getTokenStats(chronik, tokenId).then( result => { setTokenStats(result); }, err => { console.log(`Error getting token stats: ${err}`); }, ); } // Clears address and amount fields following sendTokenNotification const clearInputForms = () => { setFormData({ value: '', address: '', }); }; async function submit() { setFormData({ ...formData, }); if ( !formData.address || !formData.value || Number(formData.value <= 0) || sendTokenAmountError ) { return; } // Event("Category", "Action", "Label") // Track number of SLPA send transactions and // SLPA token IDs Event('SendToken.js', 'Send', tokenId); passLoadingStatus(true); const { address, value } = formData; // Clear params from address let cleanAddress = address.split('?')[0]; - // Convert to simpleledger prefix if etoken - cleanAddress = toLegacyToken(cleanAddress); - try { const link = await sendToken(bchObj, chronik, wallet, { tokenId: tokenId, tokenReceiverAddress: cleanAddress, amount: value, }); sendTokenNotification(link); clearInputForms(); } catch (e) { passLoadingStatus(false); let message; if (!e.error && !e.message) { message = `Transaction failed: no response from ${getRestUrl()}.`; } else if ( /Could not communicate with full node or other external service/.test( e.error, ) ) { message = 'Could not communicate with API. Please try again.'; } else { message = e.message || e.error || JSON.stringify(e); } errorNotification(e, message, 'Sending eToken'); } } const handleSlpAmountChange = e => { let error = false; const { value, name } = e.target; // test if exceeds balance using BigNumber let isGreaterThanBalance = false; if (!isNaN(value)) { const bigValue = new BigNumber(value); // Returns 1 if greater, -1 if less, 0 if the same, null if n/a isGreaterThanBalance = bigValue.comparedTo(token.balance); } // Validate value for > 0 if (isNaN(value)) { error = 'Amount must be a number'; } else if (value <= 0) { error = 'Amount must be greater than 0'; } else if (token && token.balance && isGreaterThanBalance === 1) { error = `Amount cannot exceed your ${token.info.tokenTicker} balance of ${token.balance}`; } else if (!isNaN(value) && value.toString().includes('.')) { if (value.toString().split('.')[1].length > token.info.decimals) { error = `This token only supports ${token.info.decimals} decimal places`; } } setSendTokenAmountError(error); setFormData(p => ({ ...p, [name]: value, })); }; const handleTokenAddressChange = e => { const { value, name } = e.target; // validate for token address // validate for parameters // show warning that query strings are not supported let error = false; let addressString = value; const isValid = isValidEtokenAddress(addressString); const addressInfo = parseAddressForParams(addressString); /* Model addressInfo = { address: '', queryString: '', amount: null, }; */ const { address, queryString } = addressInfo; // If query string, // Show an alert that only amount and currency.ticker are supported setQueryStringText(queryString); // Is this valid address? if (!isValid) { error = 'Address is not a valid etoken: address'; // If valid address but xec format if (isValidXecAddress(address)) { error = `Cashtab does not support sending eTokens to XEC addresses. Please convert to an eToken address.`; } } setSendTokenAddressError(error); setFormData(p => ({ ...p, [name]: value, })); }; const onMax = async () => { // Clear this error before updating field setSendTokenAmountError(false); try { let value = token.balance; setFormData({ ...formData, value, }); } catch (err) { console.log(`Error in onMax:`); console.log(err); message.error( 'Unable to calculate the max value due to network errors', ); } }; const checkForConfirmationBeforeSendEtoken = () => { if (cashtabSettings.sendModal) { setIsModalVisible(cashtabSettings.sendModal); } else { // if the user does not have the send confirmation enabled in settings then send directly submit(); } }; const handleOk = () => { setIsModalVisible(false); submit(); }; const handleCancel = () => { setIsModalVisible(false); }; const handleEtokenBurnAmountChange = e => { const { value } = e.target; const burnAmount = new BigNumber(value); setETokenBurnAmount(burnAmount); let error = false; if (!isValidEtokenBurnAmount(burnAmount, token.balance)) { error = 'Burn amount must be between 1 and ' + token.balance; } setBurnTokenAmountError(error); }; const onMaxBurn = () => { setETokenBurnAmount(token.balance); // trigger validation on the inserted max value handleEtokenBurnAmountChange({ target: { value: token.balance, }, }); }; async function burn() { if ( !burnConfirmationValid || burnConfirmationValid === null || !eTokenBurnAmount ) { return; } // Event("Category", "Action", "Label") Event('SendToken.js', 'Burn eToken', tokenId); passLoadingStatus(true); try { const link = await burnToken(bchObj, chronik, wallet, { tokenId: tokenId, amount: eTokenBurnAmount, }); burnTokenNotification(link); clearInputForms(); setShowConfirmBurnEtoken(false); setConfirmationOfEtokenToBeBurnt(''); } catch (e) { setShowConfirmBurnEtoken(false); passLoadingStatus(false); setConfirmationOfEtokenToBeBurnt(''); let message; if (!e.error && !e.message) { message = `Transaction failed: no response from ${getRestUrl()}.`; } else if (/dust/.test(e.error)) { message = 'Unable to burn due to insufficient eToken utxos.'; } else if ( /Could not communicate with full node or other external service/.test( e.error, ) ) { message = 'Could not communicate with API. Please try again.'; } else { message = e.message || e.error || JSON.stringify(e); } errorNotification(e, message, 'Burning eToken'); } } const handleBurnConfirmationInput = e => { const { value } = e.target; if (value && value === `burn ${token.info.tokenTicker}`) { setBurnConfirmationValid(true); } else { setBurnConfirmationValid(false); } setConfirmationOfEtokenToBeBurnt(value); }; const handleBurnAmountInput = () => { if (!burnTokenAmountError) { setShowConfirmBurnEtoken(true); } }; useEffect(() => { /* If the balance has changed, unlock the UI Note that the 'token' dependency changes every time the wallet state is set This useEffect loop can't use token.balance as the dependency as this is occasionally undefined, which the screen UI also makes use of */ if ( token && token.balance && previousToken && previousToken.balance && token.balance.toString() !== previousToken.balance.toString() ) { passLoadingStatus(false); } // In the case of a tx that burns all of a token, also lose the loader if (!token && previousToken) { passLoadingStatus(false); } }, [token]); return ( <>

{token && token.info && formData ? `Are you sure you want to send ${formData.value}${' '} ${token.info.tokenTicker} to ${formData.address}?` : ''}

{!token && } {token && ( {/* eToken burn modal */} setShowConfirmBurnEtoken(false)} >
} placeholder={`Type "burn ${token.info.tokenTicker}" to confirm`} name="etokenToBeBurnt" value={confirmationOfEtokenToBeBurnt} onChange={e => handleBurnConfirmationInput(e) } />
handleTokenAddressChange({ target: { name: 'address', value: result, }, }) } inputProps={{ placeholder: `${currency.tokenTicker} Address`, name: 'address', onChange: e => handleTokenAddressChange(e), required: true, value: formData.address, }} /> } /> ) : ( {`identicon ), suffix: token.info.tokenTicker, onChange: e => handleSlpAmountChange(e), required: true, value: formData.value, }} />
{apiError || sendTokenAmountError || sendTokenAddressError ? ( <> Send {token.info.tokenName} ) : ( checkForConfirmationBeforeSendEtoken() } > Send {token.info.tokenName} )}
{queryStringText && ( )} {apiError && } {tokenStats !== null && ( {token.info.decimals} {token.tokenId}
Airdrop XEC to holders
{tokenStats && ( <> { tokenStats.slpTxData .genesisInfo .tokenDocumentUrl } {tokenStats.block && tokenStats.block .timestamp !== null ? formatDate( tokenStats.block .timestamp, navigator.language, ) : 'Just now (Genesis tx confirming)'} {tokenStats.containsBaton ? 'No' : 'Yes'} {new BigNumber( tokenStats.initialTokenQuantity, ) .toFormat( token.info.decimals, ) .toLocaleString()} {new BigNumber( tokenStats.tokenStats.totalBurned, ) .toFormat( token.info.decimals, ) .toLocaleString()} {new BigNumber( tokenStats.tokenStats.totalMinted, ) .toFormat( token.info.decimals, ) .toLocaleString()} {new BigNumber( tokenStats.circulatingSupply, ) .toFormat( token.info.decimals, ) .toLocaleString()} handleEtokenBurnAmountChange( e, ), initialvalue: 1, value: eTokenBurnAmount, prefix: ( ), }} /> )}
)}
)} ); }; /* passLoadingStatus must receive a default prop that is a function in order to pass the rendering unit test in SendToken.test.js status => {console.log(status)} is an arbitrary stub function */ SendToken.defaultProps = { passLoadingStatus: status => { console.log(status); }, }; SendToken.propTypes = { tokenId: PropTypes.string, jestBCH: PropTypes.object, passLoadingStatus: PropTypes.func, }; export default SendToken; diff --git a/web/cashtab/src/utils/cashMethods.js b/web/cashtab/src/utils/cashMethods.js index c10866c7f..980e53890 100644 --- a/web/cashtab/src/utils/cashMethods.js +++ b/web/cashtab/src/utils/cashMethods.js @@ -1,1232 +1,1230 @@ import { currency } from 'components/Common/Ticker'; import { isValidXecAddress, isValidEtokenAddress, isValidContactList, isValidBchAddress, } from 'utils/validation'; import BigNumber from 'bignumber.js'; import cashaddr from 'ecashaddrjs'; import bs58 from 'bs58'; import * as slpMdm from 'slp-mdm'; import Bitcoin from '@psf/bitcoincashjs-lib'; import coininfo from 'utils/coininfo'; // function is based on BCH-JS' generateBurnOpReturn() however it's been trimmed down for Cashtab use // Reference: https://github.com/Permissionless-Software-Foundation/bch-js/blob/62e56c832b35731880fe448269818b853c76dd80/src/slp/tokentype1.js#L217 export const generateBurnOpReturn = (tokenUtxos, burnQty) => { try { if (!tokenUtxos || !burnQty) { throw new Error('Invalid burn token parameter'); } // sendToken component already prevents burning of a value greater than the token utxo total held by the wallet const tokenId = tokenUtxos[0].tokenId; const decimals = tokenUtxos[0].decimals; // account for token decimals const finalBurnTokenQty = new BigNumber(burnQty).times(10 ** decimals); // Calculate the total amount of tokens owned by the wallet. const totalTokens = tokenUtxos.reduce( (tot, txo) => tot.plus(new BigNumber(txo.tokenQty).times(10 ** decimals)), new BigNumber(0), ); // calculate the token change const tokenChange = totalTokens.minus(finalBurnTokenQty); const tokenChangeStr = tokenChange.toString(); // Generate the burn OP_RETURN as a Buffer // No need for separate .send() calls for change and non-change burns as // nil change values do not generate token outputs as the full balance is burnt const script = slpMdm.TokenType1.send(tokenId, [ new slpMdm.BN(tokenChangeStr), ]); return script; } catch (err) { console.log('Error in generateBurnOpReturn(): ' + err); throw err; } }; // Function originally based on BCH-JS' generateSendOpReturn function however trimmed down for Cashtab // Reference: https://github.com/Permissionless-Software-Foundation/bch-js/blob/62e56c832b35731880fe448269818b853c76dd80/src/slp/tokentype1.js#L95 export const generateSendOpReturn = (tokenUtxos, sendQty) => { try { if (!tokenUtxos || !sendQty) { throw new Error('Invalid send token parameter'); } const tokenId = tokenUtxos[0].tokenId; const decimals = tokenUtxos[0].decimals; // account for token decimals const finalSendTokenQty = new BigNumber(sendQty).times(10 ** decimals); const finalSendTokenQtyStr = finalSendTokenQty.toString(); // Calculate the total amount of tokens owned by the wallet. const totalTokens = tokenUtxos.reduce( (tot, txo) => tot.plus(new BigNumber(txo.tokenQty).times(10 ** decimals)), new BigNumber(0), ); // calculate token change const tokenChange = totalTokens.minus(finalSendTokenQty); const tokenChangeStr = tokenChange.toString(); // When token change output is required let script, outputs; if (tokenChange > 0) { outputs = 2; // Generate the OP_RETURN as a Buffer. script = slpMdm.TokenType1.send(tokenId, [ new slpMdm.BN(finalSendTokenQtyStr), new slpMdm.BN(tokenChangeStr), ]); } else { // no token change needed outputs = 1; // Generate the OP_RETURN as a Buffer. script = slpMdm.TokenType1.send(tokenId, [ new slpMdm.BN(finalSendTokenQtyStr), ]); } return { script, outputs }; } catch (err) { console.log('Error in generateSendOpReturn(): ' + err); throw err; } }; // function is based on BCH-JS' generateGenesisOpReturn() however it's been trimmed down for Cashtab use // Reference: https://github.com/Permissionless-Software-Foundation/bch-js/blob/62e56c832b35731880fe448269818b853c76dd80/src/slp/tokentype1.js#L286 export const generateGenesisOpReturn = configObj => { try { if (!configObj) { throw new Error('Invalid token configuration'); } // adjust initial quantity for token decimals const initialQty = new BigNumber(configObj.initialQty) .times(10 ** configObj.decimals) .toString(); const script = slpMdm.TokenType1.genesis( configObj.ticker, configObj.name, configObj.documentUrl, configObj.documentHash, configObj.decimals, configObj.mintBatonVout, new slpMdm.BN(initialQty), ); return script; } catch (err) { console.log('Error in generateGenesisOpReturn(): ' + err); throw err; } }; export const getUtxoWif = (utxo, wallet) => { if (!wallet) { throw new Error('Invalid wallet parameter'); } const accounts = [wallet.Path245, wallet.Path145, wallet.Path1899]; const wif = accounts .filter(acc => acc.cashAddress === utxo.address) .pop().fundingWif; return wif; }; // Reference https://github.com/Permissionless-Software-Foundation/bch-js/blob/master/src/ecpair.js#L24 // Modified for mainnet only export const getECPairFromWIF = wif => { let xec = coininfo.bitcoincash.main; const xecBitcoinJSLib = xec.toBitcoinJS(); return Bitcoin.ECPair.fromWIF(wif, xecBitcoinJSLib); }; export const signUtxosByAddress = (inputUtxos, wallet, txBuilder) => { for (let i = 0; i < inputUtxos.length; i++) { const utxo = inputUtxos[i]; const accounts = [wallet.Path245, wallet.Path145, wallet.Path1899]; const wif = accounts .filter(acc => acc.cashAddress === utxo.address) .pop().fundingWif; const utxoECPair = getECPairFromWIF(wif); txBuilder.sign( i, utxoECPair, undefined, txBuilder.hashTypes.SIGHASH_ALL, parseInt(utxo.value), ); } return txBuilder; }; export const getCashtabByteCount = (p2pkhInputCount, p2pkhOutputCount) => { // Simplifying bch-js function for P2PKH txs only, as this is all Cashtab supports for now // https://github.com/Permissionless-Software-Foundation/bch-js/blob/master/src/bitcoincash.js#L408 /* const types = { inputs: { 'P2PKH': 148 * 4, }, outputs: { P2PKH: 34 * 4, }, }; */ const inputCount = new BigNumber(p2pkhInputCount); const outputCount = new BigNumber(p2pkhOutputCount); const inputWeight = new BigNumber(148 * 4); const outputWeight = new BigNumber(34 * 4); const nonSegwitWeightConstant = new BigNumber(10 * 4); let totalWeight = new BigNumber(0); totalWeight = totalWeight .plus(inputCount.times(inputWeight)) .plus(outputCount.times(outputWeight)) .plus(nonSegwitWeightConstant); const byteCount = totalWeight.div(4).integerValue(BigNumber.ROUND_CEIL); return Number(byteCount); }; export const calcFee = ( utxos, p2pkhOutputNumber = 2, satoshisPerByte = currency.defaultFee, ) => { const byteCount = getCashtabByteCount(utxos.length, p2pkhOutputNumber); const txFee = Math.ceil(satoshisPerByte * byteCount); return txFee; }; export const generateTokenTxOutput = ( BCH, txBuilder, tokenAction, legacyCashOriginAddress, tokenUtxosBeingSpent = [], // optional - send or burn tx only remainderXecValue = new BigNumber(0), // optional - only if > dust tokenConfigObj = {}, // optional - genesis only tokenRecipientAddress = false, // optional - send tx only tokenAmount = false, // optional - send or burn amount for send/burn tx only ) => { try { if (!BCH || !tokenAction || !legacyCashOriginAddress || !txBuilder) { throw new Error('Invalid token tx output parameter'); } let script, opReturnObj, destinationAddress; switch (tokenAction) { case 'GENESIS': script = generateGenesisOpReturn(tokenConfigObj); destinationAddress = legacyCashOriginAddress; break; case 'SEND': opReturnObj = generateSendOpReturn( tokenUtxosBeingSpent, tokenAmount.toString(), ); script = opReturnObj.script; - destinationAddress = BCH.SLP.Address.toLegacyAddress( - tokenRecipientAddress, - ); + destinationAddress = tokenRecipientAddress; break; case 'BURN': script = generateBurnOpReturn( tokenUtxosBeingSpent, tokenAmount, ); destinationAddress = BCH.SLP.Address.toLegacyAddress( legacyCashOriginAddress, ); break; default: throw new Error('Invalid token transaction type'); } // OP_RETURN needs to be the first output in the transaction. txBuilder.addOutput(script, 0); // add XEC dust output as fee for genesis, send or burn token output txBuilder.addOutput(destinationAddress, parseInt(currency.etokenSats)); // Return any token change back to the sender for send and burn txs if ( tokenAction !== 'GENESIS' || (opReturnObj && opReturnObj.outputs > 1) ) { // add XEC dust output as fee txBuilder.addOutput( tokenUtxosBeingSpent[0].address, // etoken address parseInt(currency.etokenSats), ); } // Send xec change to own address if (remainderXecValue.gte(new BigNumber(currency.dustSats))) { txBuilder.addOutput( legacyCashOriginAddress, parseInt(remainderXecValue), ); } } catch (err) { console.log(`generateTokenTxOutput() error: ` + err); throw err; } return txBuilder; }; export const generateTxInput = ( BCH, isOneToMany, utxos, txBuilder, destinationAddressAndValueArray, satoshisToSend, feeInSatsPerByte, ) => { let txInputObj = {}; const inputUtxos = []; let txFee = 0; let totalInputUtxoValue = new BigNumber(0); try { if ( !BCH || (isOneToMany && !destinationAddressAndValueArray) || !utxos || !txBuilder || !satoshisToSend || !feeInSatsPerByte ) { throw new Error('Invalid tx input parameter'); } // A normal tx will have 2 outputs, destination and change // A one to many tx will have n outputs + 1 change output, where n is the number of recipients const txOutputs = isOneToMany ? destinationAddressAndValueArray.length + 1 : 2; for (let i = 0; i < utxos.length; i++) { const utxo = utxos[i]; totalInputUtxoValue = totalInputUtxoValue.plus(utxo.value); const vout = utxo.outpoint.outIdx; const txid = utxo.outpoint.txid; // add input with txid and index of vout txBuilder.addInput(txid, vout); inputUtxos.push(utxo); txFee = calcFee(inputUtxos, txOutputs, feeInSatsPerByte); if (totalInputUtxoValue.minus(satoshisToSend).minus(txFee).gte(0)) { break; } } } catch (err) { console.log(`generateTxInput() error: ` + err); throw err; } txInputObj.txBuilder = txBuilder; txInputObj.totalInputUtxoValue = totalInputUtxoValue; txInputObj.inputUtxos = inputUtxos; txInputObj.txFee = txFee; return txInputObj; }; export const generateTokenTxInput = ( BCH, tokenAction, // GENESIS, SEND or BURN totalXecUtxos, totalTokenUtxos, tokenId, tokenAmount, // optional - only for sending or burning feeInSatsPerByte, txBuilder, ) => { let totalXecInputUtxoValue = new BigNumber(0); let remainderXecValue = new BigNumber(0); let remainderTokenValue = new BigNumber(0); let totalXecInputUtxos = []; let txFee = 0; let tokenUtxosBeingSpent = []; try { if ( !BCH || !tokenAction || !totalXecUtxos || (tokenAction !== 'GENESIS' && !tokenId) || !feeInSatsPerByte || !txBuilder ) { throw new Error('Invalid token tx input parameter'); } // collate XEC UTXOs for this token tx const txOutputs = tokenAction === 'GENESIS' ? 2 // one for genesis OP_RETURN output and one for change : 4; // for SEND/BURN token txs see T2645 on why this is not dynamically generated for (let i = 0; i < totalXecUtxos.length; i++) { const thisXecUtxo = totalXecUtxos[i]; totalXecInputUtxoValue = totalXecInputUtxoValue.plus( new BigNumber(thisXecUtxo.value), ); const vout = thisXecUtxo.outpoint.outIdx; const txid = thisXecUtxo.outpoint.txid; // add input with txid and index of vout txBuilder.addInput(txid, vout); totalXecInputUtxos.push(thisXecUtxo); txFee = calcFee(totalXecInputUtxos, txOutputs, feeInSatsPerByte); remainderXecValue = tokenAction === 'GENESIS' ? totalXecInputUtxoValue .minus(new BigNumber(currency.etokenSats)) .minus(new BigNumber(txFee)) : totalXecInputUtxoValue .minus(new BigNumber(currency.etokenSats * 2)) // one for token send/burn output, one for token change .minus(new BigNumber(txFee)); if (remainderXecValue.gte(0)) { break; } } if (remainderXecValue.lt(0)) { throw new Error(`Insufficient funds`); } let filteredTokenInputUtxos = []; let finalTokenAmountSpent = new BigNumber(0); let tokenAmountBeingSpent = new BigNumber(tokenAmount); if (tokenAction === 'SEND' || tokenAction === 'BURN') { // filter for token UTXOs matching the token being sent/burnt filteredTokenInputUtxos = totalTokenUtxos.filter(utxo => { if ( utxo && // UTXO is associated with a token. utxo.slpMeta.tokenId === tokenId && // UTXO matches the token ID. !utxo.slpToken.isMintBaton // UTXO is not a minting baton. ) { return true; } return false; }); if (filteredTokenInputUtxos.length === 0) { throw new Error( 'No token UTXOs for the specified token could be found.', ); } // collate token UTXOs to cover the token amount being sent/burnt for (let i = 0; i < filteredTokenInputUtxos.length; i++) { finalTokenAmountSpent = finalTokenAmountSpent.plus( new BigNumber(filteredTokenInputUtxos[i].tokenQty), ); txBuilder.addInput( filteredTokenInputUtxos[i].outpoint.txid, filteredTokenInputUtxos[i].outpoint.outIdx, ); tokenUtxosBeingSpent.push(filteredTokenInputUtxos[i]); if (tokenAmountBeingSpent.lte(finalTokenAmountSpent)) { break; } } // calculate token change remainderTokenValue = finalTokenAmountSpent.minus( new BigNumber(tokenAmount), ); if (remainderTokenValue.lt(0)) { throw new Error( 'Insufficient token UTXOs for the specified token amount.', ); } } } catch (err) { console.log(`generateTokenTxInput() error: ` + err); throw err; } return { txBuilder: txBuilder, inputXecUtxos: totalXecInputUtxos, inputTokenUtxos: tokenUtxosBeingSpent, remainderXecValue: remainderXecValue, remainderTokenValue: remainderTokenValue, }; }; export const getChangeAddressFromInputUtxos = (BCH, inputUtxos, wallet) => { if (!BCH || !inputUtxos || !wallet) { throw new Error('Invalid getChangeAddressFromWallet input parameter'); } // Assume change address is input address of utxo at index 0 let changeAddress; // Validate address try { changeAddress = inputUtxos[0].address; if (!isValidBchAddress(changeAddress)) { throw new Error('Invalid change address'); } } catch (err) { throw new Error('Invalid input utxo'); } return changeAddress; }; /* * Parse the total value of a send XEC tx and checks whether it is more than dust * One to many: isOneToMany is true, singleSendValue is null * One to one: isOneToMany is false, destinationAddressAndValueArray is null * Returns the aggregate send value in BigNumber format */ export const parseXecSendValue = ( isOneToMany, singleSendValue, destinationAddressAndValueArray, ) => { let value = new BigNumber(0); try { 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 // each array row is: 'eCash address, send value' value = BigNumber.sum( value, new BigNumber( destinationAddressAndValueArray[i].split(',')[1], ), ); } } else { // this is a one to one XEC transaction then check singleSendValue // note: one to many transactions won't be sending a singleSendValue param if (!singleSendValue) { throw new Error('Invalid singleSendValue'); } value = new BigNumber(singleSendValue); } // If user is attempting to send an aggregate value that is less than minimum accepted by the backend if ( value.lt( new BigNumber(fromSatoshisToXec(currency.dustSats).toString()), ) ) { // Throw the same error given by the backend attempting to broadcast such a tx throw new Error('dust'); } } catch (err) { console.log('Error in parseXecSendValue: ' + err); throw err; } return value; }; export const encodeOpReturnScript = scriptChunks => { // reference https://github.com/Permissionless-Software-Foundation/bch-js/blob/master/src/script.js#L153 const arr = []; scriptChunks.forEach(chunk => { arr.push(chunk); }); return Bitcoin.script.compile(arr); }; /* * Generates an OP_RETURN script to reflect the various send XEC permutations * involving messaging, encryption, eToken IDs and airdrop flags. * * Returns the final encoded script object */ export const generateOpReturnScript = ( optionalOpReturnMsg, encryptionFlag, airdropFlag, airdropTokenId, encryptedEj, ) => { // encrypted mesage is mandatory when encryptionFlag is true // airdrop token id is mandatory when airdropFlag is true if ((encryptionFlag && !encryptedEj) || (airdropFlag && !airdropTokenId)) { throw new Error('Invalid OP RETURN script input'); } // Note: script.push(Buffer.from(currency.opReturn.opReturnPrefixHex, 'hex')); actually evaluates to '016a' // instead of keeping the hex string intact. This behavour is specific to the initial script array element. // To get around this, the bch-js approach of directly using the opReturn prefix in decimal form for the initial entry is used here. let script = [currency.opReturn.opReturnPrefixDec]; // initialize script with the OP_RETURN op code (6a) in decimal form (106) try { if (encryptionFlag) { // if the user has opted to encrypt this message // add the encrypted cashtab messaging prefix and encrypted msg to script script.push( Buffer.from( currency.opReturn.appPrefixesHex.cashtabEncrypted, 'hex', ), // 65746162 ); // add the encrypted message to script script.push(Buffer.from(encryptedEj)); } else { // this is an un-encrypted message if (airdropFlag) { // if this was routed from the airdrop component // add the airdrop prefix to script script.push( Buffer.from( currency.opReturn.appPrefixesHex.airdrop, 'hex', ), // drop ); // add the airdrop token ID to script script.push(Buffer.from(airdropTokenId, 'hex')); } // add the cashtab prefix to script script.push( Buffer.from(currency.opReturn.appPrefixesHex.cashtab, 'hex'), // 00746162 ); // add the un-encrypted message to script if supplied if (optionalOpReturnMsg) { script.push(Buffer.from(optionalOpReturnMsg)); } } } catch (err) { console.log('Error in generateOpReturnScript(): ' + err); throw err; } return encodeOpReturnScript(script); }; export const generateTxOutput = ( BCH, isOneToMany, singleSendValue, satoshisToSend, totalInputUtxoValue, destinationAddress, destinationAddressAndValueArray, changeAddress, txFee, txBuilder, ) => { try { if ( !BCH || (isOneToMany && !destinationAddressAndValueArray) || (!isOneToMany && !destinationAddress && !singleSendValue) || !changeAddress || !satoshisToSend || !totalInputUtxoValue || !txFee || !txBuilder ) { throw new Error('Invalid tx input parameter'); } // amount to send back to the remainder address. const remainder = new BigNumber(totalInputUtxoValue) .minus(satoshisToSend) .minus(txFee); if (remainder.lt(0)) { throw new Error(`Insufficient funds`); } 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], ); txBuilder.addOutput( outputAddress, parseInt(fromXecToSatoshis(outputValue)), ); } } else { // for one to one mode, add output w/ single address and amount to send txBuilder.addOutput( destinationAddress, parseInt(fromXecToSatoshis(singleSendValue)), ); } // if a remainder exists, return to change address as the final output if (remainder.gte(new BigNumber(currency.dustSats))) { txBuilder.addOutput(changeAddress, parseInt(remainder)); } } catch (err) { console.log('Error in generateTxOutput(): ' + err); throw err; } return txBuilder; }; export const signAndBuildTx = (BCH, inputUtxos, txBuilder, wallet) => { if ( !BCH || !inputUtxos || inputUtxos.length === 0 || !txBuilder || !wallet || // txBuilder.transaction.tx.ins is empty until the inputUtxos are signed txBuilder.transaction.tx.outs.length === 0 ) { throw new Error('Invalid buildTx parameter'); } // Sign each XEC UTXO being consumed and refresh transactionBuilder txBuilder = signUtxosByAddress(inputUtxos, wallet, txBuilder); let hex; try { // build tx const tx = txBuilder.build(); // output rawhex hex = tx.toHex(); } catch (err) { throw new Error('Transaction build failed'); } return hex; }; 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 if ( i === 0 && message === currency.opReturn.appPrefixesHex.airdrop ) { // add the airdrop prefix to array resultArray[i] = currency.opReturn.appPrefixesHex.airdrop; } 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 fromSatoshisToXec = ( amount, cashDecimals = currency.cashDecimals, ) => { const amountBig = new BigNumber(amount); const multiplier = new BigNumber(10 ** (-1 * cashDecimals)); const amountInBaseUnits = amountBig.times(multiplier); return amountInBaseUnits; }; export const fromXecToSatoshis = ( 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 flattenContactList = contactList => { /* Converts contactList from array of objects of type {address: , name: } to array of addresses only If contact list is invalid, returns and empty array */ if (!isValidContactList(contactList)) { return []; } let flattenedContactList = []; for (let i = 0; i < contactList.length; i += 1) { const thisAddress = contactList[i].address; flattenedContactList.push(thisAddress); } return flattenedContactList; }; 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 = getWalletBalanceFromUtxos( slpBalancesAndUtxos.nonSlpUtxos, ); liveWalletState.balances = balancesRebased; return liveWalletState; }; export const getWalletBalanceFromUtxos = nonSlpUtxos => { const totalBalanceInSatoshis = nonSlpUtxos.reduce( (previousBalance, utxo) => previousBalance.plus(new BigNumber(utxo.value)), new BigNumber(0), ); return { totalBalanceInSatoshis: totalBalanceInSatoshis.toString(), totalBalance: fromSatoshisToXec(totalBalanceInSatoshis).toString(), }; }; 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 convertEtokenToEcashAddr(eTokenAddress) { if (!eTokenAddress) { return new Error( `cashMethods.convertToEcashAddr() error: No etoken address provided`, ); } // Confirm input is a valid eToken address const isValidInput = isValidEtokenAddress(eTokenAddress); if (!isValidInput) { return new Error( `cashMethods.convertToEcashAddr() error: ${eTokenAddress} is not a valid etoken address`, ); } // Check for etoken: prefix const isPrefixedEtokenAddress = eTokenAddress.slice(0, 7) === 'etoken:'; // If no prefix, assume it is checksummed for an etoken: prefix const testedEtokenAddr = isPrefixedEtokenAddress ? eTokenAddress : `etoken:${eTokenAddress}`; let ecashAddress; try { const { type, hash } = cashaddr.decode(testedEtokenAddr); ecashAddress = cashaddr.encode('ecash', type, hash); } catch (err) { return err; } return ecashAddress; } 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 convertEcashtoEtokenAddr(eCashAddress) { const isValidInput = isValidXecAddress(eCashAddress); if (!isValidInput) { return new Error(`${eCashAddress} is not a valid ecash address`); } // Check for ecash: prefix const isPrefixedEcashAddress = eCashAddress.slice(0, 6) === 'ecash:'; // If no prefix, assume it is checksummed for an ecash: prefix const testedEcashAddr = isPrefixedEcashAddress ? eCashAddress : `ecash:${eCashAddress}`; let eTokenAddress; try { const { type, hash } = cashaddr.decode(testedEcashAddr); eTokenAddress = cashaddr.encode('etoken', type, hash); } catch (err) { return new Error('eCash to eToken address conversion error'); } return eTokenAddress; } // converts ecash, etoken, bitcoincash and simpleledger addresses to hash160 export function toHash160(addr) { try { // decode address hash const { hash } = cashaddr.decode(addr); // encode the address hash to legacy format (bitcoin) const legacyAdress = bs58.encode(hash); // convert legacy to hash160 const addrHash160 = Buffer.from(bs58.decode(legacyAdress)).toString( 'hex', ); return addrHash160; } catch (err) { console.log('Error converting address to hash160'); throw err; } } 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; } /* 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 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.Path1899.hash160 || !wallet.Path145.publicKey || !wallet.Path145.hash160 || !wallet.Path245.publicKey || !wallet.Path245.hash160 ) { return true; } return false; }; export const getHashArrayFromWallet = wallet => { // If the wallet has wallet.Path1899.hash160, it's migrated and will have all of them // Return false for an umigrated wallet const hash160Array = wallet && wallet.Path1899 && 'hash160' in wallet.Path1899 ? [ wallet.Path245.hash160, wallet.Path145.hash160, wallet.Path1899.hash160, ] : false; return hash160Array; }; export const isActiveWebsocket = ws => { // Return true if websocket is connected and subscribed // Otherwise return false return ( ws !== null && ws && '_ws' in ws && 'readyState' in ws._ws && ws._ws.readyState === 1 && '_subs' in ws && ws._subs.length > 0 ); };