diff --git a/web/cashtab/src/components/Send/SendToken.js b/web/cashtab/src/components/Send/SendToken.js index a56217785..3487494b5 100644 --- a/web/cashtab/src/components/Send/SendToken.js +++ b/web/cashtab/src/components/Send/SendToken.js @@ -1,319 +1,313 @@ import React, { useState, useEffect } from 'react'; import { WalletContext } from '../../utils/context'; -import { Alert, Form, notification, message } from 'antd'; +import { Form, notification, message } from 'antd'; import { CashSpin, CashSpinIcon } from '../Common/CustomSpinner'; import { Row, Col } from 'antd'; import Paragraph from 'antd/lib/typography/Paragraph'; -import { SecondaryButton } from '../Common/PrimaryButton'; +import PrimaryButton, { SecondaryButton } from '../Common/PrimaryButton'; import { CashLoader } from '../Common/CustomIcons'; import { FormItemWithMaxAddon, FormItemWithQRCodeAddon, } from '../Common/EnhancedInputs'; import useBCH from '../../hooks/useBCH'; import { BalanceHeader } from './Send'; import { Redirect } from 'react-router-dom'; import useWindowDimensions from '../../hooks/useWindowDimensions'; 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 } from '../Common/Ticker.js'; import { Event } from '../../utils/GoogleAnalytics'; const SendToken = ({ tokenId }) => { const { wallet, tokens, slpBalancesAndUtxos, apiError } = React.useContext( WalletContext, ); const token = tokens.find(token => token.tokenId === tokenId); const [sendTokenAmountError, setSendTokenAmountError] = 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 = width < 769 && isMobile && !(isIOS && !isSafari); const [formData, setFormData] = useState({ dirty: true, value: '', address: '', }); const [loading, setLoading] = useState(false); const { getBCH, getRestUrl, sendToken } = useBCH(); const BCH = getBCH(); // Keep this function around for re-enabling later // eslint-disable-next-line no-unused-vars async function submit() { setFormData({ ...formData, dirty: false, }); 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); setLoading(true); const { address, value } = formData; try { const link = await sendToken(BCH, wallet, slpBalancesAndUtxos, { tokenId: tokenId, tokenReceiverAddress: address, amount: value, }); notification.success({ message: 'Success', description: ( Transaction successful. Click or tap here for more details ), duration: 5, }); } catch (e) { setLoading(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); } console.log(e); notification.error({ message: 'Error', description: message, duration: 3, }); console.error(e); } } 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 handleChange = e => { const { value, name } = e.target; 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', ); } }; useEffect(() => { // If the balance has changed, unlock the UI // This is redundant, if backend has refreshed in 1.75s timeout below, UI will already be unlocked setLoading(false); }, [token]); return ( <> {!token && } {token && ( <>

Available balance

{token.balance.toString()} {token.info.tokenTicker}

setFormData({ ...formData, address: result, }) } inputProps={{ placeholder: `${currency.tokenTicker} Address`, name: 'address', onChange: e => handleChange(e), required: true, value: formData.address, }} /> } /> ) : ( {`identicon ), suffix: token.info.tokenTicker, onChange: e => handleSlpAmountChange(e), required: true, value: formData.value, }} />
{apiError || sendTokenAmountError ? ( <> Send {token.info.tokenName} {apiError && } ) : ( - + submit()} + > Send {token.info.tokenName} - + )}
{apiError && (

An error occured on our end. Reconnecting...

)} -
)} ); }; export default SendToken; diff --git a/web/cashtab/src/hooks/useBCH.js b/web/cashtab/src/hooks/useBCH.js index f02ca752e..28efaa499 100644 --- a/web/cashtab/src/hooks/useBCH.js +++ b/web/cashtab/src/hooks/useBCH.js @@ -1,705 +1,535 @@ import BigNumber from 'bignumber.js'; import { currency } from '../components/Common/Ticker'; export default function useBCH() { const DUST = 0.000005; const SEND_BCH_ERRORS = { INSUFICIENT_FUNDS: 0, NETWORK_ERROR: 1, INSUFFICIENT_PRIORITY: 66, // ~insufficient fee DOUBLE_SPENDING: 18, MAX_UNCONFIRMED_TXS: 64, }; const getRestUrl = (apiIndex = 0) => { const apiString = process.env.REACT_APP_NETWORK === `mainnet` ? process.env.REACT_APP_BCHA_APIS : process.env.REACT_APP_BCHA_APIS_TEST; const apiArray = apiString.split(','); return apiArray[apiIndex]; }; const getTxHistory = async (BCH, addresses) => { let txHistoryResponse; try { //console.log(`API Call: BCH.Electrumx.utxo(addresses)`); //console.log(addresses); txHistoryResponse = await BCH.Electrumx.transactions(addresses); //console.log(`BCH.Electrumx.transactions(addresses) succeeded`); //console.log(`txHistoryResponse`, txHistoryResponse); if (txHistoryResponse.success && txHistoryResponse.transactions) { return txHistoryResponse.transactions; } else { // eslint-disable-next-line no-throw-literal throw new Error('Error in getTxHistory'); } } catch (err) { console.log(`Error in BCH.Electrumx.transactions(addresses):`); console.log(err); return err; } }; // Split out the BCH.Electrumx.utxo(addresses) call from the getSlpBalancesandUtxos function // If utxo set has not changed, you do not need to hydrate the utxo set // This drastically reduces calls to the API const getUtxos = async (BCH, addresses) => { let utxosResponse; try { //console.log(`API Call: BCH.Electrumx.utxo(addresses)`); //console.log(addresses); utxosResponse = await BCH.Electrumx.utxo(addresses); //console.log(`BCH.Electrumx.utxo(addresses) succeeded`); //console.log(`utxosResponse`, utxosResponse); return utxosResponse.utxos; } catch (err) { console.log(`Error in BCH.Electrumx.utxo(addresses):`); return err; } }; const getSlpBalancesAndUtxos = async (BCH, utxos) => { let hydratedUtxoDetails; try { hydratedUtxoDetails = await BCH.SLP.Utils.hydrateUtxos(utxos); //console.log(`hydratedUtxoDetails`, hydratedUtxoDetails); } catch (err) { console.log( `Error in BCH.SLP.Utils.hydrateUtxos(utxosResponse.utxos)`, ); console.log(err); } const hydratedUtxos = []; for (let i = 0; i < hydratedUtxoDetails.slpUtxos.length; i += 1) { const hydratedUtxosAtAddress = hydratedUtxoDetails.slpUtxos[i]; for (let j = 0; j < hydratedUtxosAtAddress.utxos.length; j += 1) { const hydratedUtxo = hydratedUtxosAtAddress.utxos[j]; hydratedUtxo.address = hydratedUtxosAtAddress.address; hydratedUtxos.push(hydratedUtxo); } } //console.log(`hydratedUtxos`, hydratedUtxos); // WARNING // If you hit rate limits, your above utxos object will come back with `isValid` as null, but otherwise ok // You need to throw an error before setting nonSlpUtxos and slpUtxos in this case const nullUtxos = hydratedUtxos.filter(utxo => utxo.isValid === null); //console.log(`nullUtxos`, nullUtxos); if (nullUtxos.length > 0) { console.log( `${nullUtxos.length} null utxos found, ignoring results`, ); throw new Error('Null utxos found, ignoring results'); } // Prevent app from treating slpUtxos as nonSlpUtxos // Must enforce === false as api will occasionally return utxo.isValid === null // Do not classify utxos with 546 satoshis as nonSlpUtxos as a precaution // Do not classify any utxos that include token information as nonSlpUtxos const nonSlpUtxos = hydratedUtxos.filter( utxo => utxo.isValid === false && utxo.satoshis !== 546 && !utxo.tokenName, ); const slpUtxos = hydratedUtxos.filter(utxo => utxo.isValid); let tokensById = {}; slpUtxos.forEach(slpUtxo => { let token = tokensById[slpUtxo.tokenId]; if (token) { // Minting baton does nto have a slpUtxo.tokenQty type if (slpUtxo.tokenQty) { token.balance = token.balance.plus( new BigNumber(slpUtxo.tokenQty), ); } //token.hasBaton = slpUtxo.transactionType === "genesis"; if (slpUtxo.utxoType && !token.hasBaton) { token.hasBaton = slpUtxo.utxoType === 'minting-baton'; } // Examples of slpUtxo /* - Genesis transaction: - { - address: "bitcoincash:qrhzv5t79e2afc3rdutcu0d3q20gl7ul3ue58whah6" - decimals: 9 - height: 617564 - isValid: true - satoshis: 546 - tokenDocumentHash: "" - tokenDocumentUrl: "developer.bitcoin.com" - tokenId: "6c41f244676ecfcbe3b4fabee2c72c2dadf8d74f8849afabc8a549157db69199" - tokenName: "PiticoLaunch" - tokenTicker: "PTCL" - tokenType: 1 - tx_hash: "6c41f244676ecfcbe3b4fabee2c72c2dadf8d74f8849afabc8a549157db69199" - tx_pos: 2 - txid: "6c41f244676ecfcbe3b4fabee2c72c2dadf8d74f8849afabc8a549157db69199" - utxoType: "minting-baton" - value: 546 - vout: 2 - } + Genesis transaction: + { + address: "bitcoincash:qrhzv5t79e2afc3rdutcu0d3q20gl7ul3ue58whah6" + decimals: 9 + height: 617564 + isValid: true + satoshis: 546 + tokenDocumentHash: "" + tokenDocumentUrl: "developer.bitcoin.com" + tokenId: "6c41f244676ecfcbe3b4fabee2c72c2dadf8d74f8849afabc8a549157db69199" + tokenName: "PiticoLaunch" + tokenTicker: "PTCL" + tokenType: 1 + tx_hash: "6c41f244676ecfcbe3b4fabee2c72c2dadf8d74f8849afabc8a549157db69199" + tx_pos: 2 + txid: "6c41f244676ecfcbe3b4fabee2c72c2dadf8d74f8849afabc8a549157db69199" + utxoType: "minting-baton" + value: 546 + vout: 2 + } - Send transaction: - { - address: "bitcoincash:qrhzv5t79e2afc3rdutcu0d3q20gl7ul3ue58whah6" - decimals: 9 - height: 655115 - isValid: true - satoshis: 546 - tokenDocumentHash: "" - tokenDocumentUrl: "developer.bitcoin.com" - tokenId: "6c41f244676ecfcbe3b4fabee2c72c2dadf8d74f8849afabc8a549157db69199" - tokenName: "PiticoLaunch" - tokenQty: 1.123456789 - tokenTicker: "PTCL" - tokenType: 1 - transactionType: "send" - tx_hash: "dea400f963bc9f51e010f88533010f8d1f82fc2bcc485ff8500c3a82b25abd9e" - tx_pos: 1 - txid: "dea400f963bc9f51e010f88533010f8d1f82fc2bcc485ff8500c3a82b25abd9e" - utxoType: "token" - value: 546 - vout: 1 - } - */ + Send transaction: + { + address: "bitcoincash:qrhzv5t79e2afc3rdutcu0d3q20gl7ul3ue58whah6" + decimals: 9 + height: 655115 + isValid: true + satoshis: 546 + tokenDocumentHash: "" + tokenDocumentUrl: "developer.bitcoin.com" + tokenId: "6c41f244676ecfcbe3b4fabee2c72c2dadf8d74f8849afabc8a549157db69199" + tokenName: "PiticoLaunch" + tokenQty: 1.123456789 + tokenTicker: "PTCL" + tokenType: 1 + transactionType: "send" + tx_hash: "dea400f963bc9f51e010f88533010f8d1f82fc2bcc485ff8500c3a82b25abd9e" + tx_pos: 1 + txid: "dea400f963bc9f51e010f88533010f8d1f82fc2bcc485ff8500c3a82b25abd9e" + utxoType: "token" + value: 546 + vout: 1 + } + */ } else { token = {}; token.info = slpUtxo; token.tokenId = slpUtxo.tokenId; if (slpUtxo.tokenQty) { token.balance = new BigNumber(slpUtxo.tokenQty); } else { token.balance = new BigNumber(0); } if (slpUtxo.utxoType) { token.hasBaton = slpUtxo.utxoType === 'minting-baton'; } else { token.hasBaton = false; } tokensById[slpUtxo.tokenId] = token; } }); const tokens = Object.values(tokensById); // console.log(`tokens`, tokens); return { tokens, nonSlpUtxos, slpUtxos, }; }; const calcFee = ( BCH, utxos, p2pkhOutputNumber = 2, satoshisPerByte = currency.defaultFee, ) => { const byteCount = BCH.BitcoinCash.getByteCount( { P2PKH: utxos.length }, { P2PKH: p2pkhOutputNumber }, ); const txFee = Math.ceil(satoshisPerByte * byteCount); return txFee; }; const sendToken = async ( BCH, wallet, slpBalancesAndUtxos, { tokenId, amount, tokenReceiverAddress }, ) => { + // Handle error of user having no BCH + if (slpBalancesAndUtxos.nonSlpUtxos.length === 0) { + throw new Error( + `You need some ${currency.ticker} to send ${currency.tokenTicker}`, + ); + } const largestBchUtxo = slpBalancesAndUtxos.nonSlpUtxos.reduce( (previous, current) => previous.satoshis > current.satoshis ? previous : current, ); - // console.log(`largestBchUtxo`, largestBchUtxo); - // this is big enough? might need to combine utxos - // TODO improve utxo selection - /* - { - address: "bitcoincash:qrcl220pxeec78vnchwyh6fsdyf60uv9tcynw3u2ev" - height: 0 - isValid: false - satoshis: 1510 - tx_hash: "faef4d8bf56353702e29c22f2aace970ddbac617144456d509e23e1192b320a8" - tx_pos: 0 - txid: "faef4d8bf56353702e29c22f2aace970ddbac617144456d509e23e1192b320a8" - value: 1510 - vout: 0 - wif: "removed for git potential" - } - */ + const bchECPair = BCH.ECPair.fromWIF(largestBchUtxo.wif); const tokenUtxos = slpBalancesAndUtxos.slpUtxos.filter( (utxo, index) => { if ( utxo && // UTXO is associated with a token. utxo.tokenId === tokenId && // UTXO matches the token ID. utxo.utxoType === 'token' // UTXO is not a minting baton. ) { return true; } return false; }, ); if (tokenUtxos.length === 0) { throw new Error( 'No token UTXOs for the specified token could be found.', ); } // BEGIN transaction construction. // instance of transaction builder let transactionBuilder; if (process.env.REACT_APP_NETWORK === 'mainnet') { transactionBuilder = new BCH.TransactionBuilder(); } else transactionBuilder = new BCH.TransactionBuilder('testnet'); const originalAmount = largestBchUtxo.value; transactionBuilder.addInput( largestBchUtxo.tx_hash, largestBchUtxo.tx_pos, ); let finalTokenAmountSent = new BigNumber(0); let tokenAmountBeingSentToAddress = new BigNumber(amount); - /* - console.log(`tokenAmountBeingSentToAddress`, tokenAmountBeingSentToAddress); - console.log( - `tokenAmountBeingSentToAddress.toString()`, - tokenAmountBeingSentToAddress.toString() - ); - */ + let tokenUtxosBeingSpent = []; for (let i = 0; i < tokenUtxos.length; i++) { finalTokenAmountSent = finalTokenAmountSent.plus( - new BigNumber(tokenUtxos[i].tokenQty).div( - Math.pow(10, tokenUtxos[i].decimals), - ), + new BigNumber(tokenUtxos[i].tokenQty), ); transactionBuilder.addInput( tokenUtxos[i].tx_hash, tokenUtxos[i].tx_pos, ); tokenUtxosBeingSpent.push(tokenUtxos[i]); if (tokenAmountBeingSentToAddress.lte(finalTokenAmountSent)) { break; } } - // Run a test function to mock the outputs generated by BCH.SLP.TokenType1.generateSendOpReturn below - slpDebug( - tokenUtxosBeingSpent, - tokenAmountBeingSentToAddress.toString(), - ); - - // Generate the OP_RETURN code. - console.log(`Debug output`); - console.log(`tokenUtxos`, tokenUtxosBeingSpent); - console.log(`sendQty`, tokenAmountBeingSentToAddress.toString()); const slpSendObj = BCH.SLP.TokenType1.generateSendOpReturn( tokenUtxosBeingSpent, tokenAmountBeingSentToAddress.toString(), ); const slpData = slpSendObj.script; // Add OP_RETURN as first output. transactionBuilder.addOutput(slpData, 0); // Send dust transaction representing tokens being sent. transactionBuilder.addOutput( BCH.SLP.Address.toLegacyAddress(tokenReceiverAddress), 546, ); // Return any token change back to the sender. if (slpSendObj.outputs > 1) { transactionBuilder.addOutput( BCH.SLP.Address.toLegacyAddress( tokenUtxosBeingSpent[0].address, ), 546, ); } // get byte count to calculate fee. paying 1 sat // Note: This may not be totally accurate. Just guessing on the byteCount size. const txFee = calcFee( BCH, tokenUtxosBeingSpent, 5, 1.1 * currency.defaultFee, ); // amount to send back to the sending address. It's the original amount - 1 sat/byte for tx size const remainder = originalAmount - txFee - 546 * 2; if (remainder < 1) { throw new Error('Selected UTXO does not have enough satoshis'); } // Last output: send the BCH change back to the wallet. transactionBuilder.addOutput( BCH.Address.toLegacyAddress(largestBchUtxo.address), remainder, ); // Sign the transaction with the private key for the BCH UTXO paying the fees. let redeemScript; transactionBuilder.sign( 0, bchECPair, redeemScript, transactionBuilder.hashTypes.SIGHASH_ALL, originalAmount, ); // Sign each token UTXO being consumed. for (let i = 0; i < tokenUtxosBeingSpent.length; i++) { const thisUtxo = tokenUtxosBeingSpent[i]; const accounts = [wallet.Path245, wallet.Path145]; const utxoEcPair = BCH.ECPair.fromWIF( accounts .filter(acc => acc.cashAddress === thisUtxo.address) .pop().fundingWif, ); transactionBuilder.sign( 1 + i, utxoEcPair, redeemScript, transactionBuilder.hashTypes.SIGHASH_ALL, thisUtxo.value, ); } // build tx const tx = transactionBuilder.build(); // output rawhex const hex = tx.toHex(); // console.log(`Transaction raw hex: `, hex); // END transaction construction. - // Broadcast transaction to the network - const txidStr = await BCH.RawTransactions.sendRawTransaction([hex]); if (txidStr && txidStr[0]) { console.log(`${currency.tokenTicker} txid`, txidStr[0]); } let link; if (process.env.REACT_APP_NETWORK === `mainnet`) { link = `${currency.blockExplorerUrl}/tx/${txidStr}`; } else { link = `${currency.blockExplorerUrlTestnet}/tx/${txidStr}`; } + //console.log(`link`, link); return link; }; - const slpDebug = (tokenUtxos, sendQty) => { - console.log(`slpDebug test called with`); - console.log(`tokenUtxos`, tokenUtxos); - console.log(`sendQty`, sendQty); - try { - //const tokenId = tokenUtxos[0].tokenId; - const decimals = tokenUtxos[0].decimals; - - // Joey patch to do - // totalTokens must be a big number accounting for decimals - // sendQty must be the same - /* From slp-sdk - - amount = new BigNumber(amount).times(10 ** tokenDecimals) // Don't forget to account for token precision - - - This is analagous to sendQty here - */ - const sendQtyBig = new BigNumber(sendQty).times(10 ** decimals); - - // Calculate the total amount of tokens owned by the wallet. - //let totalTokens = 0; - //for (let i = 0; i < tokenUtxos.length; i++) totalTokens += tokenUtxos[i].tokenQty; - - // Calculate total amount of tokens using Big Number throughout - /* - let totalTokens = new BigNumber(0); - for (let i = 0; i < tokenUtxos.length; i++) { - console.log(`tokenQty normal`, tokenUtxos[i].tokenQty); - const thisTokenQty = new BigNumber(tokenUtxos[i].tokenQty); - totalTokens.plus(thisTokenQty); - } - totalTokens.times(10 ** decimals); - */ - let totalTokens = tokenUtxos.reduce((tot, txo) => { - return tot.plus( - new BigNumber(txo.tokenQty).times(10 ** decimals), - ); - }, new BigNumber(0)); - - console.log(`totalTokens`, totalTokens); - //test - //totalTokens = new BigNumber(totalTokens).times(10 ** decimals); - - console.log(`sendQtyBig`, sendQtyBig); - const change = totalTokens.minus(sendQtyBig); - console.log(`change`, change); - - //let script; - //let outputs = 1; - - // The normal case, when there is token change to return to sender. - if (change > 0) { - //outputs = 2; - - // Convert the send quantity to the format expected by slp-mdm. - - //let baseQty = new BigNumber(sendQty).times(10 ** decimals); - // Update: you've done this earlier, so don't do it now - let baseQty = sendQtyBig.toString(); - console.log(`baseQty: `, baseQty); - - // Convert the change quantity to the format expected by slp-mdm. - //let baseChange = new BigNumber(change).times(10 ** decimals); - // Update: you've done this earlier, so don't do it now - let baseChange = change.toString(); - console.log(`baseChange: `, baseChange); - - const outputQty = new BigNumber(baseChange).plus( - new BigNumber(baseQty), - ); - const inputQty = new BigNumber(totalTokens); - console.log( - `new BigNumber(baseChange)`, - new BigNumber(baseChange), - ); - console.log(`new BigNumber(baseQty)`, new BigNumber(baseQty)); - console.log(`outputQty:`, outputQty); - console.log(`inputQty:`, inputQty); - console.log( - `outputQty.minus(inputQty).toString():`, - outputQty.minus(inputQty).toString(), - ); - console.log( - `outputQty.minus(inputQty).toString():`, - outputQty.minus(inputQty).toString() === '0', - ); - - const tokenOutputDelta = - outputQty.minus(inputQty).toString() !== '0'; - if (tokenOutputDelta) - console.log( - 'Token transaction inputs do not match outputs, cannot send transaction', - ); - // Generate the OP_RETURN as a Buffer. - /* - script = slpMdm.TokenType1.send(tokenId, [ - new slpMdm.BN(baseQty), - new slpMdm.BN(baseChange) - ]); - */ - // - - // Corner case, when there is no token change to send back. - } else { - console.log(`No change case:`); - let baseQty = sendQtyBig.toString(); - console.log(`baseQty: `, baseQty); - - // Check for potential burns - const noChangeOutputQty = new BigNumber(baseQty); - const noChangeInputQty = new BigNumber(totalTokens); - console.log(`noChangeOutputQty`, noChangeOutputQty); - console.log(`noChangeInputQty`, noChangeInputQty); - - const tokenSingleOutputError = - noChangeOutputQty.minus(noChangeInputQty).toString() !== - '0'; - if (tokenSingleOutputError) - console.log( - 'Token transaction inputs do not match outputs, cannot send transaction', - ); - - // Generate the OP_RETURN as a Buffer. - //script = slpMdm.TokenType1.send(tokenId, [new slpMdm.BN(baseQty)]); - } - } catch (err) { - console.log(`Error in generateSendOpReturn()`); - throw err; - } - }; - const sendBch = async ( BCH, wallet, utxos, { addresses, values, encodedOpReturn }, callbackTxId, ) => { // Note: callbackTxId is a callback function that accepts a txid as its only parameter - /* Debug logs - console.log(`sendBch called with`); - console.log("BCH", BCH); - console.log("wallet", wallet); - console.log("utxos", utxos); - console.log("addresses", addresses); - console.log("values", values); - console.log("encodedOpReturn", encodedOpReturn); - console.log("callbackTxid", callbackTxId); - */ + try { if (!values || values.length === 0) { return null; } const value = values.reduce( (previous, current) => new BigNumber(current).plus(previous), new BigNumber(0), ); const REMAINDER_ADDR = wallet.Path145.cashAddress; const inputUtxos = []; let transactionBuilder; // instance of transaction builder if (process.env.REACT_APP_NETWORK === `mainnet`) transactionBuilder = new BCH.TransactionBuilder(); else transactionBuilder = new BCH.TransactionBuilder('testnet'); const satoshisToSend = BCH.BitcoinCash.toSatoshi(value.toFixed(8)); let originalAmount = new BigNumber(0); let txFee = 0; for (let i = 0; i < utxos.length; i++) { const utxo = utxos[i]; originalAmount = originalAmount.plus(utxo.satoshis); const vout = utxo.vout; const txid = utxo.txid; // add input with txid and index of vout transactionBuilder.addInput(txid, vout); inputUtxos.push(utxo); txFee = encodedOpReturn ? calcFee(BCH, inputUtxos, addresses.length + 2) : calcFee(BCH, inputUtxos, addresses.length + 1); if (originalAmount.minus(satoshisToSend).minus(txFee).gte(0)) { break; } } // amount to send back to the remainder address. const remainder = Math.floor( originalAmount.minus(satoshisToSend).minus(txFee), ); if (remainder < 0) { const error = new Error(`Insufficient funds`); error.code = SEND_BCH_ERRORS.INSUFICIENT_FUNDS; throw error; } if (encodedOpReturn) { transactionBuilder.addOutput(encodedOpReturn, 0); } // add output w/ address and amount to send for (let i = 0; i < addresses.length; i++) { const address = addresses[i]; transactionBuilder.addOutput( BCH.Address.toCashAddress(address), BCH.BitcoinCash.toSatoshi(Number(values[i]).toFixed(8)), ); } if (remainder >= BCH.BitcoinCash.toSatoshi(DUST)) { transactionBuilder.addOutput(REMAINDER_ADDR, remainder); } // Sign the transactions with the HD node. for (let i = 0; i < inputUtxos.length; i++) { const utxo = inputUtxos[i]; transactionBuilder.sign( i, BCH.ECPair.fromWIF(utxo.wif), undefined, transactionBuilder.hashTypes.SIGHASH_ALL, utxo.satoshis, ); } // build tx const tx = transactionBuilder.build(); // output rawhex const hex = tx.toHex(); // Broadcast transaction to the network const txidStr = await BCH.RawTransactions.sendRawTransaction([hex]); if (txidStr && txidStr[0]) { console.log(`${currency.ticker} txid`, txidStr[0]); } let link; if (callbackTxId) { callbackTxId(txidStr); } if (process.env.REACT_APP_NETWORK === `mainnet`) { link = `${currency.blockExplorerUrl}/tx/${txidStr}`; } else { link = `${currency.blockExplorerUrlTestnet}/tx/${txidStr}`; } //console.log(`link`, link); return link; } catch (err) { if (err.error === 'insufficient priority (code 66)') { err.code = SEND_BCH_ERRORS.INSUFFICIENT_PRIORITY; } else if (err.error === 'txn-mempool-conflict (code 18)') { err.code = SEND_BCH_ERRORS.DOUBLE_SPENDING; } else if (err.error === 'Network Error') { err.code = SEND_BCH_ERRORS.NETWORK_ERROR; } else if ( err.error === 'too-long-mempool-chain, too many unconfirmed ancestors [limit: 25] (code 64)' ) { err.code = SEND_BCH_ERRORS.MAX_UNCONFIRMED_TXS; } console.log(`error: `, err); throw err; } }; const getBCH = (apiIndex = 0, fromWindowObject = true) => { if (fromWindowObject && window.SlpWallet) { const SlpWallet = new window.SlpWallet('', { restURL: getRestUrl(apiIndex), }); return SlpWallet.bchjs; } }; return { getBCH, calcFee, getUtxos, getSlpBalancesAndUtxos, getTxHistory, getRestUrl, sendBch, sendToken, }; } diff --git a/web/cashtab/src/hooks/useWallet.js b/web/cashtab/src/hooks/useWallet.js index 8ec125dac..80391cb28 100644 --- a/web/cashtab/src/hooks/useWallet.js +++ b/web/cashtab/src/hooks/useWallet.js @@ -1,1099 +1,1104 @@ /* eslint-disable react-hooks/exhaustive-deps */ import React, { useState, useEffect } from 'react'; import Paragraph from 'antd/lib/typography/Paragraph'; import { notification } from 'antd'; import useAsyncTimeout from './useAsyncTimeout'; import usePrevious from './usePrevious'; import useBCH from '../hooks/useBCH'; import BigNumber from 'bignumber.js'; import localforage from 'localforage'; import { currency } from '../components/Common/Ticker'; import _ from 'lodash'; const useWallet = () => { const [wallet, setWallet] = useState(false); const [fiatPrice, setFiatPrice] = useState(null); const [ws, setWs] = useState(null); const [apiError, setApiError] = useState(false); const [walletState, setWalletState] = useState({ balances: {}, tokens: [], slpBalancesAndUtxos: [], txHistory: [], }); const { getBCH, getUtxos, getSlpBalancesAndUtxos, getTxHistory } = useBCH(); const [loading, setLoading] = useState(true); const [apiIndex, setApiIndex] = useState(0); const [BCH, setBCH] = useState(getBCH(apiIndex)); const [utxos, setUtxos] = useState(null); const { balances, tokens, slpBalancesAndUtxos, txHistory } = walletState; const previousBalances = usePrevious(balances); const previousTokens = usePrevious(tokens); const previousWallet = usePrevious(wallet); const previousUtxos = usePrevious(utxos); // 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 normalizeSlpBalancesAndUtxos = (slpBalancesAndUtxos, wallet) => { const Accounts = [wallet.Path245, wallet.Path145]; slpBalancesAndUtxos.nonSlpUtxos.forEach(utxo => { const derivatedAccount = Accounts.find( account => account.cashAddress === utxo.address, ); utxo.wif = derivatedAccount.fundingWif; }); return slpBalancesAndUtxos; }; const normalizeBalance = slpBalancesAndUtxos => { const totalBalanceInSatoshis = slpBalancesAndUtxos.nonSlpUtxos.reduce( (previousBalance, utxo) => previousBalance + utxo.satoshis, 0, ); return { totalBalanceInSatoshis, totalBalance: BCH.BitcoinCash.toBitcoinCash(totalBalanceInSatoshis), }; }; const deriveAccount = async ({ masterHDNode, path }) => { const node = BCH.HDNode.derivePath(masterHDNode, path); const cashAddress = BCH.HDNode.toCashAddress(node); const slpAddress = BCH.SLP.Address.toSLPAddress(cashAddress); return { cashAddress, slpAddress, fundingWif: BCH.HDNode.toWIF(node), fundingAddress: BCH.SLP.Address.toSLPAddress(cashAddress), legacyAddress: BCH.SLP.Address.toLegacyAddress(cashAddress), }; }; const haveUtxosChanged = (utxos, previousUtxos) => { // Relevant points for this array comparing exercise // https://stackoverflow.com/questions/13757109/triple-equal-signs-return-false-for-arrays-in-javascript-why // https://stackoverflow.com/questions/7837456/how-to-compare-arrays-in-javascript // If this is initial state if (utxos === null) { // Then make sure to get slpBalancesAndUtxos return true; } // If this is the first time the wallet received utxos if ( typeof previousUtxos === 'undefined' || typeof utxos === 'undefined' ) { // Then they have certainly changed return true; } // return true for empty array, since this means you definitely do not want to skip the next API call if (utxos && utxos.length === 0) { return true; } // Compare utxo sets const utxoArraysUnchanged = _.isEqual(utxos, previousUtxos); // If utxos are not the same as previousUtxos if (utxoArraysUnchanged) { // then utxos have not changed return false; // otherwise, } else { // utxos have changed return true; } }; const update = async ({ wallet, setWalletState }) => { //console.log(`tick()`); //console.time("update"); try { if (!wallet) { return; } const cashAddresses = [ wallet.Path245.cashAddress, wallet.Path145.cashAddress, ]; const utxos = await getUtxos(BCH, cashAddresses); //console.log(`utxos`, utxos); // If an error is returned or utxos from only 1 address are returned if (!utxos || _.isEmpty(utxos) || utxos.error || utxos.length < 2) { // Throw error here to prevent more attempted api calls // as you are likely already at rate limits throw new Error('Error fetching utxos'); } setUtxos(utxos); const utxosHaveChanged = haveUtxosChanged(utxos, previousUtxos); // If the utxo set has not changed, if (!utxosHaveChanged) { // remove api error here; otherwise it will remain if recovering from a rate // limit error with an unchanged utxo set setApiError(false); // then walletState has not changed and does not need to be updated //console.timeEnd("update"); return; } // todo: another available optimization, update slpBalancesandUtxos by hydrating only the new utxos const slpBalancesAndUtxos = await getSlpBalancesAndUtxos( BCH, utxos, ); const txHistory = await getTxHistory(BCH, cashAddresses); console.log(`slpBalancesAndUtxos`, slpBalancesAndUtxos); if (typeof slpBalancesAndUtxos === 'undefined') { console.log(`slpBalancesAndUtxos is undefined`); throw new Error('slpBalancesAndUtxos is undefined'); } const { tokens } = slpBalancesAndUtxos; const newState = { balances: {}, tokens: [], slpBalancesAndUtxos: [], }; newState.slpBalancesAndUtxos = normalizeSlpBalancesAndUtxos( slpBalancesAndUtxos, wallet, ); newState.balances = normalizeBalance(slpBalancesAndUtxos); newState.tokens = tokens; newState.txHistory = txHistory; setWalletState(newState); // If everything executed correctly, remove apiError setApiError(false); } catch (error) { console.log(`Error in update({wallet, setWalletState})`); 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 getWallet = async () => { let wallet; try { let existingWallet; try { existingWallet = await localforage.getItem('wallet'); // 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 (e) { console.log(e); existingWallet = null; } // If no wallet in indexedDb or localforage or caught error above or the initial 'false' is in indexedDB if (existingWallet === null || !existingWallet) { wallet = await getWalletDetails(existingWallet); await localforage.setItem('wallet', wallet); } else { wallet = existingWallet; } // todo: only do this if you didn't get it out of storage //wallet = await getWalletDetails(existingWallet); //await localforage.setItem("wallet", wallet); } catch (error) { console.log(error); } return wallet; }; 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({ masterHDNode, path: "m/44'/245'/0'/0/0", }); const Path145 = await deriveAccount({ masterHDNode, path: "m/44'/145'/0'/0/0", }); let name = Path145.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, }; }; 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 */ let currentlyActiveWallet; try { currentlyActiveWallet = await localforage.getItem('wallet'); } 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; } // Check savedWallets for currentlyActiveWallet let walletInSavedWallets = false; for (let i = 0; i < savedWallets.length; i += 1) { if (savedWallets[i].name === currentlyActiveWallet.name) { walletInSavedWallets = true; } } 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()`, ); } } // 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; } return walletToActivate; }; const renameWallet = async (oldName, newName) => { // Load savedWallets let savedWallets; try { savedWallets = await localforage.getItem('savedWallets'); } catch (err) { console.log( `Error in await localforage.getItem("savedWallets") in renameWallet`, ); 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 renameWallet()`, ); 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; } }; const handleUpdateWallet = async setWallet => { const wallet = await getWallet(); setWallet(wallet); }; // Parse for incoming BCH transactions // Only notify if websocket is not connected if ( (ws === null || ws.readyState !== 1) && previousBalances && balances && 'totalBalance' in previousBalances && 'totalBalance' in balances && new BigNumber(balances.totalBalance) .minus(previousBalances.totalBalance) .gt(0) ) { notification.success({ message: 'Transaction received', description: ( You received{' '} {Number( balances.totalBalance - previousBalances.totalBalance, ).toFixed(8)}{' '} BCH! ), duration: 3, }); } // Parse for incoming SLP transactions if ( tokens && tokens[0] && tokens[0].balance && previousTokens && previousTokens[0] && previousTokens[0].balance ) { // 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 - notification.success({ - message: `SLP Transaction received: ${receivedSlpTicker}`, - description: ( - - You received {receivedSlpQty} {receivedSlpName} - - ), - duration: 5, - }); + // Notification if you received SLP + if (receivedSlpQty > 0) { + notification.success({ + message: `${currency.tokenTicker} Transaction received: ${receivedSlpTicker}`, + description: ( + + You received {receivedSlpQty} {receivedSlpName} + + ), + duration: 5, + }); + } // } 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 > previousTokens[i].balance) { + 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 receivedSlpDecimals = tokens[i].info.decimals; - const receivedSlpQty = ( - tokens[i].balance - previousTokens[i].balance - ).toFixed(receivedSlpDecimals); + const receivedSlpQty = tokens[i].balance.minus( + previousTokens[i].balance, + ); + const receivedSlpTicker = tokens[i].info.tokenTicker; const receivedSlpName = tokens[i].info.tokenName; notification.success({ message: `SLP Transaction received: ${receivedSlpTicker}`, description: ( - You received {receivedSlpQty} {receivedSlpName} + You received {receivedSlpQty.toString()}{' '} + {receivedSlpName} ), duration: 5, }); } } } } // Update price every 1 min useAsyncTimeout(async () => { fetchBchPrice(); }, 60000); // Update wallet every 10s useAsyncTimeout(async () => { const wallet = await getWallet(); update({ wallet, setWalletState, }).finally(() => { setLoading(false); }); }, 10000); const initializeWebsocket = (cashAddress, slpAddress) => { // console.log(`initializeWebsocket(${cashAddress}, ${slpAddress})`); // This function parses 3 cases // 1: edge case, websocket is in state but not properly connected // > Remove it from state and forget about it, fall back to normal notifications // 2: edge-ish case, websocket is in state and connected but user has changed wallet // > Unsubscribe from old addresses and subscribe to new ones // 3: most common: app is opening, creating websocket with existing addresses // If the websocket is already in state but is not properly connected if (ws !== null && ws.readyState !== 1) { // Forget about it and use conventional notifications // Close ws.close(); // Remove from state setWs(null); } // If the websocket is in state and connected else if (ws !== null) { // console.log(`Websocket already in state`); // console.log(`ws,`, ws); // instead of initializing websocket, unsubscribe from old addresses and subscribe to new ones const previousWsCashAddress = previousWallet.Path145.legacyAddress; const previousWsSlpAddress = previousWallet.Path245.legacyAddress; try { // Unsubscribe from previous addresses ws.send( JSON.stringify({ op: 'addr_unsub', addr: previousWsCashAddress, }), ); console.log( `Unsubscribed from BCH address at ${previousWsCashAddress}`, ); ws.send( JSON.stringify({ op: 'addr_unsub', addr: previousWsSlpAddress, }), ); console.log( `Unsubscribed from SLP address at ${previousWsSlpAddress}`, ); // Subscribe to new addresses ws.send( JSON.stringify({ op: 'addr_sub', addr: cashAddress, }), ); console.log(`Subscribed to BCH address at ${cashAddress}`); // Subscribe to SLP address ws.send( JSON.stringify({ op: 'addr_sub', addr: slpAddress, }), ); console.log(`Subscribed to SLP address at ${slpAddress}`); // Reset onmessage; it was previously set with the old addresses // Note this code is exactly identical to lines 431-490 // TODO put in function ws.onmessage = e => { // TODO handle case where receive multiple messages on one incoming transaction //console.log(`ws msg received`); const incomingTx = JSON.parse(e.data); console.log(incomingTx); let bchSatsReceived = 0; // First, check the inputs // If cashAddress or slpAddress are in the inputs, then this is a sent tx and should be ignored for notifications if ( incomingTx && incomingTx.x && incomingTx.x.inputs && incomingTx.x.out ) { const inputs = incomingTx.x.inputs; // Iterate over inputs and see if this transaction was sent by the active wallet for (let i = 0; i < inputs.length; i += 1) { if ( inputs[i].prev_out.addr === cashAddress || inputs[i].prev_out.addr === slpAddress ) { // console.log(`Found a sending tx, not notifying`); // This is a sent transaction and should be ignored by notification handlers return; } } // Iterate over outputs to determine receiving address const outputs = incomingTx.x.out; for (let i = 0; i < outputs.length; i += 1) { if (outputs[i].addr === cashAddress) { // console.log(`BCH transaction received`); bchSatsReceived += outputs[i].value; // handle } if (outputs[i].addr === slpAddress) { console.log(`SLP transaction received`); //handle // you would want to get the slp info using this endpoint: // https://rest.kingbch.com/v3/slp/txDetails/cb39dd04e07e172a37addfcb1d6e167dc52c01867ba21c9bf8b5acf4dd969a3f // But it does not work for unconfirmed txs // Hold off on slp tx notifications for now } } } // parse for receiving address // if received at cashAddress, parse for BCH amount, notify BCH received // if received at slpAddress, parse for token, notify SLP received // if those checks fail, could be from a 'sent' tx, ignore // Note, when you send an SLP tx, you get SLP change to SLP address and BCH change to BCH address // Also note, when you send an SLP tx, you often have inputs from both BCH and SLP addresses // This causes a sent SLP tx to register 4 times from the websocket // Best way to ignore this is to ignore any incoming utx.x with BCH or SLP address in the inputs // Notification for received BCH if (bchSatsReceived > 0) { notification.success({ message: 'Transaction received', description: ( You received {bchSatsReceived / 1e8} BCH! ), duration: 3, }); } }; } catch (err) { console.log( `Error attempting to configure websocket for new wallet`, ); console.log(err); console.log(`Closing connection`); ws.close(); setWs(null); } } else { // If there is no websocket, create one, subscribe to addresses, and add notifications for incoming BCH transactions let newWs = new WebSocket('wss://ws.blockchain.info/bch/inv'); newWs.onopen = () => { console.log(`Connected to bchWs`); // Subscribe to BCH address newWs.send( JSON.stringify({ op: 'addr_sub', addr: cashAddress, }), ); console.log(`Subscribed to BCH address at ${cashAddress}`); // Subscribe to SLP address newWs.send( JSON.stringify({ op: 'addr_sub', addr: slpAddress, }), ); console.log(`Subscribed to SLP address at ${slpAddress}`); }; newWs.onerror = e => { // close and set to null console.log(`Error in websocket connection for ${newWs}`); console.log(e); setWs(null); }; newWs.onclose = () => { console.log(`Websocket connection closed`); // Unsubscribe on close to prevent double subscribing //{"op":"addr_unsub", "addr":"$bitcoin_address"} newWs.send( JSON.stringify({ op: 'addr_unsub', addr: cashAddress, }), ); console.log(`Unsubscribed from BCH address at ${cashAddress}`); newWs.send( JSON.stringify({ op: 'addr_sub', addr: slpAddress, }), ); console.log(`Unsubscribed from SLP address at ${slpAddress}`); }; newWs.onmessage = e => { // TODO handle case where receive multiple messages on one incoming transaction //console.log(`ws msg received`); const incomingTx = JSON.parse(e.data); console.log(incomingTx); let bchSatsReceived = 0; // First, check the inputs // If cashAddress or slpAddress are in the inputs, then this is a sent tx and should be ignored for notifications if ( incomingTx && incomingTx.x && incomingTx.x.inputs && incomingTx.x.out ) { const inputs = incomingTx.x.inputs; // Iterate over inputs and see if this transaction was sent by the active wallet for (let i = 0; i < inputs.length; i += 1) { if ( inputs[i].prev_out.addr === cashAddress || inputs[i].prev_out.addr === slpAddress ) { // console.log(`Found a sending tx, not notifying`); // This is a sent transaction and should be ignored by notification handlers return; } } // Iterate over outputs to determine receiving address const outputs = incomingTx.x.out; for (let i = 0; i < outputs.length; i += 1) { if (outputs[i].addr === cashAddress) { // console.log(`BCH transaction received`); bchSatsReceived += outputs[i].value; // handle } if (outputs[i].addr === slpAddress) { console.log(`SLP transaction received`); //handle // you would want to get the slp info using this endpoint: // https://rest.kingbch.com/v3/slp/txDetails/cb39dd04e07e172a37addfcb1d6e167dc52c01867ba21c9bf8b5acf4dd969a3f // But it does not work for unconfirmed txs // Hold off on slp tx notifications for now } } } // parse for receiving address // if received at cashAddress, parse for BCH amount, notify BCH received // if received at slpAddress, parse for token, notify SLP received // if those checks fail, could be from a 'sent' tx, ignore // Note, when you send an SLP tx, you get SLP change to SLP address and BCH change to BCH address // Also note, when you send an SLP tx, you often have inputs from both BCH and SLP addresses // This causes a sent SLP tx to register 4 times from the websocket // Best way to ignore this is to ignore any incoming utx.x with BCH or SLP address in the inputs // Notification for received BCH if (bchSatsReceived > 0) { notification.success({ message: 'Transaction received', description: ( You received {bchSatsReceived / 1e8} BCH! ), duration: 3, }); } }; setWs(newWs); } }; const fetchBchPrice = async () => { // Split this variable out in case coingecko changes const cryptoId = currency.coingeckoId; // Keep currency as a variable as eventually it will be a user setting const fiatCode = 'usd'; // 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); const bchPriceInFiat = bchPriceJson[cryptoId][fiatCode]; //console.log(`bchPriceInFiat`, bchPriceInFiat); setFiatPrice(bchPriceInFiat); } catch (err) { console.log(`Error parsing price API response to JSON`); console.log(err); } }; useEffect(() => { handleUpdateWallet(setWallet); fetchBchPrice(); }, []); useEffect(() => { if ( wallet && wallet.Path145 && wallet.Path145.cashAddress && wallet.Path245 && wallet.Path245.cashAddress ) { if (currency.useBlockchainWs) { initializeWebsocket( wallet.Path145.legacyAddress, wallet.Path245.legacyAddress, ); } } }, [wallet]); return { BCH, wallet, fiatPrice, slpBalancesAndUtxos, balances, tokens, txHistory, loading, apiError, getWallet, validateMnemonic, getWalletDetails, getSavedWallets, update: async () => update({ wallet: await getWallet(), setLoading, setWalletState, }), createWallet: async importMnemonic => { setLoading(true); const newWallet = await createWallet(importMnemonic); setWallet(newWallet); update({ wallet: newWallet, setWalletState, }).finally(() => setLoading(false)); }, activateWallet: async walletToActivate => { setLoading(true); const newWallet = await activateWallet(walletToActivate); setWallet(newWallet); update({ wallet: newWallet, setWalletState, }).finally(() => setLoading(false)); }, addNewSavedWallet, renameWallet, deleteWallet, }; }; export default useWallet;