diff --git a/web/cashtab/src/components/Send/Send.js b/web/cashtab/src/components/Send/Send.js index d2f980bc5..d54a22e80 100644 --- a/web/cashtab/src/components/Send/Send.js +++ b/web/cashtab/src/components/Send/Send.js @@ -1,532 +1,530 @@ import React, { useState, useEffect } from 'react'; import styled from 'styled-components'; import { WalletContext } from '@utils/context'; import { Form, notification, message, Spin, Modal, Alert } from 'antd'; import { CashLoader, CashLoadingIcon } from '@components/Common/CustomIcons'; import { Row, Col } from 'antd'; import Paragraph from 'antd/lib/typography/Paragraph'; import PrimaryButton, { SecondaryButton, } from '@components/Common/PrimaryButton'; import { SendBchInput, FormItemWithQRCodeAddon, } from '@components/Common/EnhancedInputs'; import useBCH from '@hooks/useBCH'; import useWindowDimensions from '@hooks/useWindowDimensions'; import { isMobile, isIOS, isSafari } from 'react-device-detect'; import { currency, isValidTokenPrefix, parseAddress, toLegacy, } from '@components/Common/Ticker.js'; import { Event } from '@utils/GoogleAnalytics'; import { fiatToCrypto, shouldRejectAmountInput } from '@utils/validation'; export const BalanceHeader = styled.div` p { color: #777; width: 100%; font-size: 14px; margin-bottom: 0px; } h3 { color: #444; width: 100%; font-size: 26px; font-weight: bold; margin-bottom: 0px; } `; export const BalanceHeaderFiat = styled.div` color: #444; width: 100%; font-size: 18px; margin-bottom: 20px; font-weight: bold; @media (max-width: 768px) { font-size: 16px; } `; export const ZeroBalanceHeader = styled.div` color: #444; width: 100%; font-size: 14px; margin-bottom: 20px; `; const ConvertAmount = styled.div` color: #777; width: 100%; font-size: 14px; margin-bottom: 10px; font-weight: bold; @media (max-width: 768px) { font-size: 12px; } `; const SendBCH = ({ filledAddress, callbackTxId }) => { const { wallet, fiatPrice, balances, slpBalancesAndUtxos, apiError, } = React.useContext(WalletContext); // 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: filledAddress || '', }); const [loading, setLoading] = useState(false); const [queryStringText, setQueryStringText] = useState(null); const [sendBchAddressError, setSendBchAddressError] = useState(false); const [sendBchAmountError, setSendBchAmountError] = useState(false); const [selectedCurrency, setSelectedCurrency] = useState(currency.ticker); // Support cashtab button from web pages const [txInfoFromUrl, setTxInfoFromUrl] = useState(false); // Show a confirmation modal on transactions created by populating form from web page button const [isModalVisible, setIsModalVisible] = useState(false); const showModal = () => { setIsModalVisible(true); }; const handleOk = () => { setIsModalVisible(false); submit(); }; const handleCancel = () => { setIsModalVisible(false); }; const { getBCH, getRestUrl, sendBch, calcFee } = useBCH(); const BCH = getBCH(); // 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 useEffect(() => { setLoading(false); }, [balances.totalBalance]); useEffect(() => { // Manually parse for txInfo object on page load when Send.js is loaded with a query string // Do not set txInfo in state if query strings are not present if ( !window.location || !window.location.hash || window.location.hash === '#/send' ) { return; } const txInfoArr = window.location.hash.split('?')[1].split('&'); // Iterate over this to create object const txInfo = {}; for (let i = 0; i < txInfoArr.length; i += 1) { let txInfoKeyValue = txInfoArr[i].split('='); let key = txInfoKeyValue[0]; let value = txInfoKeyValue[1]; txInfo[key] = value; } console.log(`txInfo from page params`, txInfo); setTxInfoFromUrl(txInfo); populateFormsFromUrl(txInfo); }, []); function populateFormsFromUrl(txInfo) { if (txInfo && txInfo.address && txInfo.value) { setFormData({ address: txInfo.address, value: txInfo.value }); } } async function submit() { setFormData({ ...formData, dirty: false, }); if ( !formData.address || !formData.value || Number(formData.value) <= 0 ) { return; } // Event("Category", "Action", "Label") // Track number of BCHA send transactions and whether users // are sending BCHA or USD Event('Send.js', 'Send', selectedCurrency); setLoading(true); const { address, value } = formData; // Get the param-free address let cleanAddress = address.split('?')[0]; // Ensure address has bitcoincash: prefix and checksum cleanAddress = toLegacy(cleanAddress); let hasValidCashPrefix; try { hasValidCashPrefix = cleanAddress.startsWith( currency.legacyPrefix + ':', ); } catch (err) { hasValidCashPrefix = false; console.log(`toLegacy() returned an error:`, cleanAddress); } if (!hasValidCashPrefix) { // set loading to false and set address validation to false // Now that the no-prefix case is handled, this happens when user tries to send // BCHA to an SLPA address setLoading(false); setSendBchAddressError( `Destination is not a valid ${currency.ticker} address`, ); return; } // Calculate the amount in BCH let bchValue = value; if (selectedCurrency === 'USD') { bchValue = fiatToCrypto(value, fiatPrice); } try { const link = await sendBch( BCH, wallet, slpBalancesAndUtxos.nonSlpUtxos, - { - addresses: [filledAddress || cleanAddress], - values: [bchValue], - }, + filledAddress || cleanAddress, + bchValue, currency.defaultFee, callbackTxId, ); notification.success({ message: 'Success', description: ( Transaction successful. Click or tap here for more details ), duration: 5, }); } catch (e) { // Set loading to false here as well, as balance may not change depending on where error occured in try loop 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 if ( e.error && e.error.includes( 'too-long-mempool-chain, too many unconfirmed ancestors [limit: 50] (code 64)', ) ) { message = `The ${currency.ticker} you are trying to send has too many unconfirmed ancestors to send (limit 50). Sending will be possible after a block confirmation. Try again in about 10 minutes.`; } else { message = e.message || e.error || JSON.stringify(e); } notification.error({ message: 'Error', description: message, duration: 5, }); console.error(e); } } const handleAddressChange = e => { const { value, name } = e.target; let error = false; let addressString = value; // parse address const addressInfo = parseAddress(BCH, addressString); /* Model addressInfo = { address: '', isValid: false, queryString: '', amount: null, }; */ const { address, isValid, queryString, amount } = 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 cash address'; // If valid address but token format if (isValidTokenPrefix(address)) { error = `Token addresses are not supported for ${currency.ticker} sends`; } } setSendBchAddressError(error); // Set amount if it's in the query string if (amount !== null) { // Set currency to BCHA setSelectedCurrency(currency.ticker); // Use this object to mimic user input and get validation for the value let amountObj = { target: { name: 'value', value: amount } }; handleBchAmountChange(amountObj); setFormData({ ...formData, value: amount, }); } // Set address field to user input setFormData(p => ({ ...p, [name]: value, })); }; const handleSelectedCurrencyChange = e => { setSelectedCurrency(e); // Clear input field to prevent accidentally sending 1 BCH instead of 1 USD setFormData(p => ({ ...p, value: '' })); }; const handleBchAmountChange = e => { const { value, name } = e.target; let bchValue = value; const error = shouldRejectAmountInput( bchValue, selectedCurrency, fiatPrice, balances.totalBalance, ); setSendBchAmountError(error); setFormData(p => ({ ...p, [name]: value })); }; const onMax = async () => { // Clear amt error setSendBchAmountError(false); // Set currency to BCH setSelectedCurrency(currency.ticker); try { const txFeeSats = calcFee(BCH, slpBalancesAndUtxos.nonSlpUtxos); const txFeeBch = txFeeSats / 10 ** currency.cashDecimals; let value = balances.totalBalance - txFeeBch >= 0 ? (balances.totalBalance - txFeeBch).toFixed( currency.cashDecimals, ) : 0; 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', ); } }; // Display price in USD below input field for send amount, if it can be calculated let fiatPriceString = ''; if (fiatPrice !== null && !isNaN(formData.value)) { if (selectedCurrency === currency.ticker) { fiatPriceString = `$ ${(fiatPrice * Number(formData.value)).toFixed( 2, )} USD`; } else { fiatPriceString = `${ formData.value ? fiatToCrypto(formData.value, fiatPrice) : '0' } ${currency.ticker}`; } } return ( <>

Are you sure you want to send {formData.value}{' '} {currency.ticker} to {formData.address}?

{!balances.totalBalance ? ( You currently have 0 {currency.ticker}
Deposit some funds to use this feature
) : ( <>

Available balance

{balances.totalBalance} {currency.ticker}

{fiatPrice !== null && ( ${(balances.totalBalance * fiatPrice).toFixed(2)}{' '} USD )} )}
handleAddressChange({ target: { name: 'address', value: result, }, }) } inputProps={{ disabled: Boolean(filledAddress), placeholder: `${currency.ticker} Address`, name: 'address', onChange: e => handleAddressChange(e), required: true, value: filledAddress || formData.address, }} > handleBchAmountChange(e), required: true, value: formData.value, }} selectProps={{ value: selectedCurrency, disabled: queryStringText !== null, onChange: e => handleSelectedCurrencyChange(e), }} > = {fiatPriceString}
{!balances.totalBalance || apiError || sendBchAmountError || sendBchAddressError ? ( Send ) : ( <> {txInfoFromUrl ? ( showModal()} > Send ) : ( submit()} > Send )} )}
{queryStringText && ( )} {apiError && ( <>

An error occured on our end. Reconnecting...

)}
); }; export default SendBCH; diff --git a/web/cashtab/src/hooks/__mocks__/sendBCH.js b/web/cashtab/src/hooks/__mocks__/sendBCH.js index 7158a72e8..dae86442b 100644 --- a/web/cashtab/src/hooks/__mocks__/sendBCH.js +++ b/web/cashtab/src/hooks/__mocks__/sendBCH.js @@ -1,35 +1,36 @@ export default { utxos: [ { height: 0, tx_hash: '6e83b4bf54b5a85b6c40c4e2076a6e3945b86e4d219a931d0eb93ba1a1e3bd6f', tx_pos: 1, value: 131689, address: 'bitcoincash:qrzuvj0vvnsz5949h4axercl5k420eygavv0awgz05', satoshis: 131689, txid: '6e83b4bf54b5a85b6c40c4e2076a6e3945b86e4d219a931d0eb93ba1a1e3bd6f', vout: 1, isValid: false, wif: 'L3ufcMjHZ2u8v2NeyHB2pCSE5ezCk8dvR7kcLLX2B3xK5VgK9wz4', }, ], wallet: { Path145: { cashAddress: 'bitcoincash:qrzuvj0vvnsz5949h4axercl5k420eygavv0awgz05', }, Path1899: { cashAddress: 'bitcoincash:qrzuvj0vvnsz5949h4axercl5k420eygavv0awgz05', }, }, - addresses: ['bitcoincash:qr2npxqwznhp7gphatcqzexeclx0hhwdxg386ez36n'], - values: ['0.00000546'], + destinationAddress: + 'bitcoincash:qr2npxqwznhp7gphatcqzexeclx0hhwdxg386ez36n', + sendAmount: '0.00000546', expectedTxId: '7a39961bbd7e27d804fb3169ef38a83234710fbc53897a4eb0c98454854a26d1', expectedHex: [ '02000000016fbde3a1a13bb90e1d939a214d6eb845396e6a07e2c4406c5ba8b554bfb4836e010000006b483045022100d52237ac2c000c0be195bb27d5488b378559cf4a7958c9a1b1ce7a6850773a2b02202e3c148450c6efdb11f199a9398332b313b54686ff98cf74b254f9ae144b0c7d4121032d9ea429b4782e9a2c18a383362c23a44efa2f6d6641d63f53788b4bf45c1decffffffff0222020000000000001976a914d530980e14ee1f2037eaf00164d9c7ccfbddcd3288ac62ff0100000000001976a914c5c649ec64e02a16a5bd7a6c8f1fa5aaa7e488eb88ac00000000', ], }; diff --git a/web/cashtab/src/hooks/__tests__/useBCH.test.js b/web/cashtab/src/hooks/__tests__/useBCH.test.js index f69891fba..835af3cb6 100644 --- a/web/cashtab/src/hooks/__tests__/useBCH.test.js +++ b/web/cashtab/src/hooks/__tests__/useBCH.test.js @@ -1,268 +1,261 @@ /* eslint-disable no-native-reassign */ import useBCH from '../useBCH'; import mockReturnGetHydratedUtxoDetails from '../__mocks__/mockReturnGetHydratedUtxoDetails'; import mockReturnGetSlpBalancesAndUtxos from '../__mocks__/mockReturnGetSlpBalancesAndUtxos'; import sendBCHMock from '../__mocks__/sendBCH'; import BCHJS from '@psf/bch-js'; // TODO: should be removed when external lib not needed anymore import { currency } from '../../components/Common/Ticker'; import BigNumber from 'bignumber.js'; describe('useBCH hook', () => { it('gets Rest Api Url on testnet', () => { process = { env: { REACT_APP_NETWORK: `testnet`, REACT_APP_BCHA_APIS: 'https://rest.kingbch.com/v3/,https://wallet-service-prod.bitframe.org/v3/,notevenaurl,https://rest.kingbch.com/v3/', REACT_APP_BCHA_APIS_TEST: 'https://free-test.fullstack.cash/v3/', }, }; const { getRestUrl } = useBCH(); const expectedApiUrl = `https://free-test.fullstack.cash/v3/`; expect(getRestUrl(0)).toBe(expectedApiUrl); }); it('gets primary Rest API URL on mainnet', () => { process = { env: { REACT_APP_BCHA_APIS: 'https://rest.kingbch.com/v3/,https://wallet-service-prod.bitframe.org/v3/,notevenaurl,https://rest.kingbch.com/v3/', REACT_APP_NETWORK: 'mainnet', }, }; const { getRestUrl } = useBCH(); const expectedApiUrl = `https://rest.kingbch.com/v3/`; expect(getRestUrl(0)).toBe(expectedApiUrl); }); it('calculates fee correctly for 2 P2PKH outputs', () => { const { calcFee } = useBCH(); const BCH = new BCHJS(); const utxosMock = [{}, {}]; expect(calcFee(BCH, utxosMock, 2, 1.01)).toBe(378); }); it('gets SLP and BCH balances and utxos from hydrated utxo details', async () => { const { getSlpBalancesAndUtxos } = useBCH(); const result = await getSlpBalancesAndUtxos( mockReturnGetHydratedUtxoDetails, ); expect(result).toStrictEqual(mockReturnGetSlpBalancesAndUtxos); }); it('sends BCH correctly', async () => { const { sendBch } = useBCH(); const BCH = new BCHJS(); const { expectedTxId, expectedHex, utxos, wallet, - addresses, - values, + destinationAddress, + sendAmount, } = sendBCHMock; BCH.RawTransactions.sendRawTransaction = jest .fn() .mockResolvedValue(expectedTxId); expect( - await sendBch(BCH, wallet, utxos, { addresses, values }, 1.01), + await sendBch( + BCH, + wallet, + utxos, + destinationAddress, + sendAmount, + 1.01, + ), ).toBe(`${currency.blockExplorerUrl}/tx/${expectedTxId}`); expect(BCH.RawTransactions.sendRawTransaction).toHaveBeenCalledWith( expectedHex, ); }); it('sends BCH correctly with callback', async () => { const { sendBch } = useBCH(); const BCH = new BCHJS(); const callback = jest.fn(); const { expectedTxId, expectedHex, utxos, wallet, - addresses, - values, + destinationAddress, + sendAmount, } = sendBCHMock; BCH.RawTransactions.sendRawTransaction = jest .fn() .mockResolvedValue(expectedTxId); expect( await sendBch( BCH, wallet, utxos, - { addresses, values }, + destinationAddress, + sendAmount, 1.01, callback, ), ).toBe(`${currency.blockExplorerUrl}/tx/${expectedTxId}`); expect(BCH.RawTransactions.sendRawTransaction).toHaveBeenCalledWith( expectedHex, ); expect(callback).toHaveBeenCalledWith(expectedTxId); }); it(`Throws error if called trying to send one base unit ${currency.ticker} more than available in utxo set`, async () => { const { sendBch } = useBCH(); const BCH = new BCHJS(); - const { expectedTxId, utxos, wallet, addresses } = sendBCHMock; + const { expectedTxId, utxos, wallet, destinationAddress } = sendBCHMock; const expectedTxFeeInSats = 229; BCH.RawTransactions.sendRawTransaction = jest .fn() .mockResolvedValue(expectedTxId); const oneBaseUnitMoreThanBalance = new BigNumber(utxos[0].value) .minus(expectedTxFeeInSats) .plus(1) .div(10 ** currency.cashDecimals) .toString(); const failedSendBch = sendBch( BCH, wallet, utxos, - { - addresses, - values: [oneBaseUnitMoreThanBalance], - }, + destinationAddress, + oneBaseUnitMoreThanBalance, 1.01, ); expect(failedSendBch).rejects.toThrow(new Error('Insufficient funds')); const nullValuesSendBch = await sendBch( BCH, wallet, utxos, - { - addresses, - values: null, - }, + destinationAddress, + null, 1.01, ); expect(nullValuesSendBch).toBe(null); }); it('Throws error on attempt to send one satoshi less than backend dust limit', async () => { const { sendBch } = useBCH(); const BCH = new BCHJS(); - const { expectedTxId, utxos, wallet, addresses } = sendBCHMock; + const { expectedTxId, utxos, wallet, destinationAddress } = sendBCHMock; BCH.RawTransactions.sendRawTransaction = jest .fn() .mockResolvedValue(expectedTxId); const failedSendBch = sendBch( BCH, wallet, utxos, - { - addresses, - values: [ - new BigNumber(currency.dust) - .minus(new BigNumber('0.00000001')) - .toString(), - ], - }, + destinationAddress, + new BigNumber(currency.dust) + .minus(new BigNumber('0.00000001')) + .toString(), 1.01, ); expect(failedSendBch).rejects.toThrow(new Error('dust')); const nullValuesSendBch = await sendBch( BCH, wallet, utxos, - { - addresses, - values: null, - }, + destinationAddress, + null, 1.01, ); expect(nullValuesSendBch).toBe(null); }); it('receives errors from the network and parses it', async () => { const { sendBch } = useBCH(); const BCH = new BCHJS(); - const { values, utxos, wallet, addresses } = sendBCHMock; + const { sendAmount, utxos, wallet, destinationAddress } = sendBCHMock; BCH.RawTransactions.sendRawTransaction = jest .fn() .mockImplementation(async () => { throw new Error('insufficient priority (code 66)'); }); const insufficientPriority = sendBch( BCH, wallet, utxos, - { - addresses, - values, - }, + destinationAddress, + sendAmount, 1.01, ); await expect(insufficientPriority).rejects.toThrow( new Error('insufficient priority (code 66)'), ); BCH.RawTransactions.sendRawTransaction = jest .fn() .mockImplementation(async () => { throw new Error('txn-mempool-conflict (code 18)'); }); const txnMempoolConflict = sendBch( BCH, wallet, utxos, - { - addresses, - values, - }, + destinationAddress, + sendAmount, 1.01, ); await expect(txnMempoolConflict).rejects.toThrow( new Error('txn-mempool-conflict (code 18)'), ); BCH.RawTransactions.sendRawTransaction = jest .fn() .mockImplementation(async () => { throw new Error('Network Error'); }); const networkError = sendBch( BCH, wallet, utxos, - { addresses, values }, + destinationAddress, + sendAmount, 1.01, ); await expect(networkError).rejects.toThrow(new Error('Network Error')); BCH.RawTransactions.sendRawTransaction = jest .fn() .mockImplementation(async () => { const err = new Error( 'too-long-mempool-chain, too many unconfirmed ancestors [limit: 25] (code 64)', ); throw err; }); const tooManyAncestorsMempool = sendBch( BCH, wallet, utxos, - { - addresses, - values, - }, + destinationAddress, + sendAmount, 1.01, ); await expect(tooManyAncestorsMempool).rejects.toThrow( new Error( 'too-long-mempool-chain, too many unconfirmed ancestors [limit: 25] (code 64)', ), ); }); }); diff --git a/web/cashtab/src/hooks/useBCH.js b/web/cashtab/src/hooks/useBCH.js index d6fca809b..56644f4bb 100644 --- a/web/cashtab/src/hooks/useBCH.js +++ b/web/cashtab/src/hooks/useBCH.js @@ -1,613 +1,600 @@ import BigNumber from 'bignumber.js'; import { currency } from '@components/Common/Ticker'; import SlpWallet from 'minimal-slp-wallet'; export default function useBCH() { const SEND_BCH_ERRORS = { INSUFFICIENT_FUNDS: 0, NETWORK_ERROR: 1, INSUFFICIENT_PRIORITY: 66, // ~insufficient fee DOUBLE_SPENDING: 18, MAX_UNCONFIRMED_TXS: 64, }; const getRestUrl = (apiIndex = 0) => { const apiString = process.env.REACT_APP_NETWORK === `mainnet` ? process.env.REACT_APP_BCHA_APIS : process.env.REACT_APP_BCHA_APIS_TEST; const apiArray = apiString.split(','); return apiArray[apiIndex]; }; const 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 getHydratedUtxoDetails = async (BCH, utxos) => { let hydratedUtxoDetails; try { hydratedUtxoDetails = await BCH.SLP.Utils.hydrateUtxos(utxos); return hydratedUtxoDetails; } catch (err) { console.log( `Error in BCH.SLP.Utils.hydrateUtxos(utxosResponse.utxos)`, ); console.log(err); return err; } }; const getSlpBalancesAndUtxos = hydratedUtxoDetails => { 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.value !== 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 } 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.value > current.value ? previous : current, ); const bchECPair = BCH.ECPair.fromWIF(largestBchUtxo.wif); const tokenUtxos = slpBalancesAndUtxos.slpUtxos.filter( (utxo, index) => { if ( utxo && // UTXO is associated with a token. utxo.tokenId === tokenId && // UTXO matches the token ID. utxo.utxoType === 'token' // UTXO is not a minting baton. ) { return true; } return false; }, ); if (tokenUtxos.length === 0) { throw new Error( 'No token UTXOs for the specified token could be found.', ); } // BEGIN transaction construction. // instance of transaction builder let transactionBuilder; if (process.env.REACT_APP_NETWORK === 'mainnet') { transactionBuilder = new BCH.TransactionBuilder(); } else transactionBuilder = new BCH.TransactionBuilder('testnet'); const originalAmount = largestBchUtxo.value; transactionBuilder.addInput( largestBchUtxo.tx_hash, largestBchUtxo.tx_pos, ); let finalTokenAmountSent = new BigNumber(0); let tokenAmountBeingSentToAddress = new BigNumber(amount); let tokenUtxosBeingSpent = []; for (let i = 0; i < tokenUtxos.length; i++) { finalTokenAmountSent = finalTokenAmountSent.plus( new BigNumber(tokenUtxos[i].tokenQty), ); transactionBuilder.addInput( tokenUtxos[i].tx_hash, tokenUtxos[i].tx_pos, ); tokenUtxosBeingSpent.push(tokenUtxos[i]); if (tokenAmountBeingSentToAddress.lte(finalTokenAmountSent)) { break; } } const slpSendObj = BCH.SLP.TokenType1.generateSendOpReturn( tokenUtxosBeingSpent, tokenAmountBeingSentToAddress.toString(), ); const slpData = slpSendObj.script; // Add OP_RETURN as first output. transactionBuilder.addOutput(slpData, 0); // Send dust transaction representing tokens being sent. transactionBuilder.addOutput( BCH.SLP.Address.toLegacyAddress(tokenReceiverAddress), 546, ); // Return any token change back to the sender. if (slpSendObj.outputs > 1) { // Try to send this to Path1899 to move all utxos off legacy addresses if (wallet.Path1899.legacyAddress) { transactionBuilder.addOutput( wallet.Path1899.legacyAddress, 546, ); } else { // If you can't, send it back from whence it came 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. // If Path1899, send it to Path1899 address if (wallet.Path1899.legacyAddress) { transactionBuilder.addOutput( wallet.Path1899.legacyAddress, remainder, ); } else { // Otherwise send it back from whence it came transactionBuilder.addOutput( BCH.Address.toLegacyAddress(largestBchUtxo.address), remainder, ); } // Sign the transaction with the private key for the BCH UTXO paying the fees. let redeemScript; transactionBuilder.sign( 0, bchECPair, redeemScript, transactionBuilder.hashTypes.SIGHASH_ALL, originalAmount, ); // Sign each token UTXO being consumed. for (let i = 0; i < tokenUtxosBeingSpent.length; i++) { const thisUtxo = tokenUtxosBeingSpent[i]; const accounts = [wallet.Path245, wallet.Path145, wallet.Path1899]; const utxoEcPair = BCH.ECPair.fromWIF( accounts .filter(acc => acc.cashAddress === thisUtxo.address) .pop().fundingWif, ); transactionBuilder.sign( 1 + i, utxoEcPair, redeemScript, transactionBuilder.hashTypes.SIGHASH_ALL, thisUtxo.value, ); } // build tx const tx = transactionBuilder.build(); // output rawhex const hex = tx.toHex(); // console.log(`Transaction raw hex: `, hex); // END transaction construction. const txidStr = await BCH.RawTransactions.sendRawTransaction([hex]); if (txidStr && txidStr[0]) { console.log(`${currency.tokenTicker} txid`, txidStr[0]); } let link; if (process.env.REACT_APP_NETWORK === `mainnet`) { link = `${currency.blockExplorerUrl}/tx/${txidStr}`; } else { link = `${currency.blockExplorerUrlTestnet}/tx/${txidStr}`; } //console.log(`link`, link); return link; }; const toSmallestDenomination = ( sendAmount, cashDecimals = currency.cashDecimals, ) => { // Replace the BCH.toSatoshi method with an equivalent function that works for arbitrary decimal places // Example, for an 8 decimal place currency like Bitcoin // Input: a BigNumber of the amount of Bitcoin to be sent // Output: a BigNumber of the amount of satoshis to be sent, or false if input is invalid // Validate // Input should be a BigNumber with no more decimal places than cashDecimals const isValidSendAmount = BigNumber.isBigNumber(sendAmount) && sendAmount.dp() <= cashDecimals; if (!isValidSendAmount) { return false; } const conversionFactor = new BigNumber(10 ** cashDecimals); const sendAmountSmallestDenomination = sendAmount.times( conversionFactor, ); return sendAmountSmallestDenomination; }; const sendBch = async ( BCH, wallet, utxos, - { addresses, values, encodedOpReturn }, + destinationAddress, + sendAmount, feeInSatsPerByte, callbackTxId, + encodedOpReturn, ) => { // Note: callbackTxId is a callback function that accepts a txid as its only parameter try { - if (!values || values.length === 0) { + if (!sendAmount) { return null; } - const value = values.reduce( - (previous, current) => new BigNumber(current).plus(previous), - new BigNumber(0), - ); + const value = new BigNumber(sendAmount); // If user is attempting to send less than minimum accepted by the backend if (value.lt(new BigNumber(currency.dust))) { // Throw the same error given by the backend attempting to broadcast such a tx throw new Error('dust'); } const REMAINDER_ADDR = wallet.Path1899.cashAddress; const inputUtxos = []; let transactionBuilder; // instance of transaction builder if (process.env.REACT_APP_NETWORK === `mainnet`) transactionBuilder = new BCH.TransactionBuilder(); else transactionBuilder = new BCH.TransactionBuilder('testnet'); const satoshisToSend = toSmallestDenomination(value); // Throw validation error if toSmallestDenomination returns false if (!satoshisToSend) { const error = new Error( `Invalid decimal places for send amount`, ); throw error; } let originalAmount = new BigNumber(0); let txFee = 0; for (let i = 0; i < utxos.length; i++) { const utxo = utxos[i]; originalAmount = originalAmount.plus(utxo.value); const vout = utxo.vout; const txid = utxo.txid; // add input with txid and index of vout transactionBuilder.addInput(txid, vout); inputUtxos.push(utxo); txFee = encodedOpReturn - ? calcFee( - BCH, - inputUtxos, - addresses.length + 2, - feeInSatsPerByte, - ) - : calcFee( - BCH, - inputUtxos, - addresses.length + 1, - feeInSatsPerByte, - ); + ? calcFee(BCH, inputUtxos, 3, feeInSatsPerByte) + : calcFee(BCH, inputUtxos, 2, feeInSatsPerByte); 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.INSUFFICIENT_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)), - ); - } + + transactionBuilder.addOutput( + BCH.Address.toCashAddress(destinationAddress), + BCH.BitcoinCash.toSatoshi(Number(sendAmount).toFixed(8)), + ); if ( remainder >= BCH.BitcoinCash.toSatoshi( parseFloat(new BigNumber(currency.dust).toFixed(8)), ) ) { 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.value, ); } // build tx const tx = transactionBuilder.build(); // output rawhex const hex = tx.toHex(); // Broadcast transaction to the network const txidStr = await BCH.RawTransactions.sendRawTransaction([hex]); if (txidStr && txidStr[0]) { console.log(`${currency.ticker} txid`, txidStr[0]); } let link; if (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) => { let ConstructedSlpWallet; ConstructedSlpWallet = new SlpWallet('', { restURL: getRestUrl(apiIndex), }); return ConstructedSlpWallet.bchjs; }; return { getBCH, calcFee, getUtxos, getHydratedUtxoDetails, getSlpBalancesAndUtxos, getTxHistory, getRestUrl, toSmallestDenomination, sendBch, sendToken, }; }