diff --git a/web/cashtab/src/components/Send/Send.js b/web/cashtab/src/components/Send/Send.js --- a/web/cashtab/src/components/Send/Send.js +++ b/web/cashtab/src/components/Send/Send.js @@ -13,7 +13,6 @@ import { Form, message, Modal, Alert, Input } from 'antd'; import { Row, Col, Switch } from 'antd'; import PrimaryButton, { DisabledButton } from 'components/Common/PrimaryButton'; -import useBCH from 'hooks/useBCH'; import useWindowDimensions from 'hooks/useWindowDimensions'; import { sendXecNotification, @@ -40,6 +39,7 @@ FormLabel, } from 'components/Common/Atoms'; import { getWalletState, fromSatoshisToXec, calcFee } from 'utils/cashMethods'; +import { sendXec } from 'utils/transactions'; import ApiError from 'components/Common/ApiError'; import { formatFiatBalance, formatBalance } from 'utils/formatting'; import styled from 'styled-components'; @@ -195,8 +195,6 @@ setIsModalVisible(false); }; - const { sendXec } = useBCH(); - // 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(() => { diff --git a/web/cashtab/src/components/Send/SendToken.js b/web/cashtab/src/components/Send/SendToken.js --- a/web/cashtab/src/components/Send/SendToken.js +++ b/web/cashtab/src/components/Send/SendToken.js @@ -22,7 +22,6 @@ 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'; @@ -35,6 +34,7 @@ import { currency, parseAddressForParams } from 'components/Common/Ticker.js'; import { Event } from 'utils/GoogleAnalytics'; import { getWalletState } from 'utils/cashMethods'; +import { sendToken, burnToken } from 'utils/transactions'; import ApiError from 'components/Common/ApiError'; import { sendTokenNotification, @@ -130,8 +130,6 @@ address: '', }); - const { sendToken, burnToken } = useBCH(); - // Fetch token stats if you do not have them and API did not return an error if (tokenStats === null) { getTokenStats(chronik, tokenId).then( diff --git a/web/cashtab/src/components/Tokens/Tokens.js b/web/cashtab/src/components/Tokens/Tokens.js --- a/web/cashtab/src/components/Tokens/Tokens.js +++ b/web/cashtab/src/components/Tokens/Tokens.js @@ -2,9 +2,9 @@ import PropTypes from 'prop-types'; import { WalletContext } from 'utils/context'; import { fromSatoshisToXec, getWalletState } from 'utils/cashMethods'; +import { createToken } from 'utils/transactions'; import CreateTokenForm from 'components/Tokens/CreateTokenForm'; import { currency } from 'components/Common/Ticker.js'; -import useBCH from 'hooks/useBCH'; import BalanceHeader from 'components/Common/BalanceHeader'; import BalanceHeaderFiat from 'components/Common/BalanceHeaderFiat'; import { @@ -26,7 +26,6 @@ } = React.useContext(WalletContext); const walletState = getWalletState(wallet); const { balances } = walletState; - const { createToken } = useBCH(); return ( <> diff --git a/web/cashtab/src/components/Tokens/__tests__/CreateTokenForm.test.js b/web/cashtab/src/components/Tokens/__tests__/CreateTokenForm.test.js --- a/web/cashtab/src/components/Tokens/__tests__/CreateTokenForm.test.js +++ b/web/cashtab/src/components/Tokens/__tests__/CreateTokenForm.test.js @@ -3,11 +3,11 @@ import { ThemeProvider } from 'styled-components'; import { theme } from 'assets/styles/theme'; import CreateTokenForm from 'components/Tokens/CreateTokenForm'; -import useBCH from 'hooks/useBCH'; import { walletWithBalancesAndTokensWithCorrectState } from '../../Home/__mocks__/walletAndBalancesMock'; import { WalletContext } from 'utils/context'; import BigNumber from 'bignumber.js'; import { currency } from 'components/Common/Ticker'; +import { createToken } from 'utils/transactions'; beforeEach(() => { // Mock method not implemented in JSDOM @@ -28,7 +28,6 @@ }); test('Wallet with BCH balances and tokens and state field', () => { - const { createToken } = useBCH(); const component = renderer.create( { - try { - // Throw error if wallet does not have utxo set in state - if (!isValidStoredWallet(wallet)) { - const walletError = new Error(`Invalid wallet`); - throw walletError; - } - const utxos = wallet.state.nonSlpUtxos; - const CREATION_ADDR = wallet.Path1899.cashAddress; - let txBuilder = new TransactionBuilder(); - - let tokenTxInputObj = generateTokenTxInput( - 'GENESIS', - utxos, - null, // total token UTXOS - not applicable for GENESIS tx - null, // token ID - not applicable for GENESIS tx - null, // token amount - not applicable for GENESIS tx - feeInSatsPerByte, - txBuilder, - ); - // update txBuilder object with inputs - txBuilder = tokenTxInputObj.txBuilder; - - let tokenTxOutputObj = generateTokenTxOutput( - txBuilder, - 'GENESIS', - CREATION_ADDR, - null, // token UTXOS being spent - not applicable for GENESIS tx - tokenTxInputObj.remainderXecValue, - configObj, - ); - // update txBuilder object with outputs - txBuilder = tokenTxOutputObj; - - // sign the collated inputUtxos and build the raw tx hex - // returns the raw tx hex string - const rawTxHex = signAndBuildTx( - tokenTxInputObj.inputXecUtxos, - txBuilder, - wallet, - ); - - // Broadcast transaction to the network via the chronik client - // sample chronik.broadcastTx() response: - // {"txid":"0075130c9ecb342b5162bb1a8a870e69c935ea0c9b2353a967cda404401acf19"} - let broadcastResponse; - try { - broadcastResponse = await chronik.broadcastTx( - rawTxHex, - true, // skipSlpCheck to bypass chronik safety mechanism in place to avoid accidental burns - // if the wallet has existing burns via bch-api then chronik will throw 'invalid-slp-burns' errors without this flag - ); - if (!broadcastResponse) { - throw new Error('Empty chronik broadcast response'); - } - } catch (err) { - console.log('Error broadcasting tx to chronik client'); - throw err; - } - - // return the explorer link for the broadcasted tx - return `${currency.blockExplorerUrl}/tx/${broadcastResponse.txid}`; - } catch (err) { - if (err.error === 'insufficient priority (code 66)') { - err.code = SEND_XEC_ERRORS.INSUFFICIENT_PRIORITY; - } else if (err.error === 'txn-mempool-conflict (code 18)') { - err.code = SEND_XEC_ERRORS.DOUBLE_SPENDING; - } else if (err.error === 'Network Error') { - err.code = SEND_XEC_ERRORS.NETWORK_ERROR; - } else if ( - err.error === - 'too-long-mempool-chain, too many unconfirmed ancestors [limit: 25] (code 64)' - ) { - err.code = SEND_XEC_ERRORS.MAX_UNCONFIRMED_TXS; - } - console.log(`error: `, err); - throw err; - } - }; - - const sendToken = async ( - chronik, - wallet, - { tokenId, amount, tokenReceiverAddress }, - ) => { - const { slpUtxos, nonSlpUtxos } = wallet.state; - const CREATION_ADDR = wallet.Path1899.cashAddress; - - // Handle error of user having no XEC - if (!nonSlpUtxos || nonSlpUtxos.length === 0) { - throw new Error( - `You need some ${currency.ticker} to send ${currency.tokenTicker}`, - ); - } - - // instance of transaction builder - let txBuilder = new TransactionBuilder(); - - let tokenTxInputObj = generateTokenTxInput( - 'SEND', - nonSlpUtxos, - slpUtxos, - tokenId, - amount, - currency.defaultFee, - txBuilder, - ); - // update txBuilder object with inputs - txBuilder = tokenTxInputObj.txBuilder; - - let tokenTxOutputObj = generateTokenTxOutput( - txBuilder, - 'SEND', - CREATION_ADDR, - tokenTxInputObj.inputTokenUtxos, - tokenTxInputObj.remainderXecValue, - null, // token config object - for GENESIS tx only - tokenReceiverAddress, - amount, - ); - // update txBuilder object with outputs - txBuilder = tokenTxOutputObj; - - // append the token input UTXOs to the array of XEC input UTXOs for signing - const combinedInputUtxos = tokenTxInputObj.inputXecUtxos.concat( - tokenTxInputObj.inputTokenUtxos, - ); - - // sign the collated inputUtxos and build the raw tx hex - // returns the raw tx hex string - const rawTxHex = signAndBuildTx(combinedInputUtxos, txBuilder, wallet); - - // Broadcast transaction to the network via the chronik client - // sample chronik.broadcastTx() response: - // {"txid":"0075130c9ecb342b5162bb1a8a870e69c935ea0c9b2353a967cda404401acf19"} - let broadcastResponse; - try { - broadcastResponse = await chronik.broadcastTx( - rawTxHex, - true, // skipSlpCheck to bypass chronik safety mechanism in place to avoid accidental burns - // if the wallet has existing burns via bch-api then chronik will throw 'invalid-slp-burns' errors without this flag - ); - if (!broadcastResponse) { - throw new Error('Empty chronik broadcast response'); - } - } catch (err) { - console.log('Error broadcasting tx to chronik client'); - throw err; - } - - // return the explorer link for the broadcasted tx - return `${currency.blockExplorerUrl}/tx/${broadcastResponse.txid}`; - }; - - const burnToken = async (chronik, wallet, { tokenId, amount }) => { - const { slpUtxos, nonSlpUtxos } = wallet.state; - const CREATION_ADDR = wallet.Path1899.cashAddress; - - // Handle error of user having no XEC - if (!nonSlpUtxos || nonSlpUtxos.length === 0) { - throw new Error(`You need some ${currency.ticker} to burn eTokens`); - } - - // instance of transaction builder - let txBuilder = new TransactionBuilder(); - - let tokenTxInputObj = generateTokenTxInput( - 'BURN', - nonSlpUtxos, - slpUtxos, - tokenId, - amount, - currency.defaultFee, - txBuilder, - ); - // update txBuilder object with inputs - txBuilder = tokenTxInputObj.txBuilder; - - let tokenTxOutputObj = generateTokenTxOutput( - txBuilder, - 'BURN', - CREATION_ADDR, - tokenTxInputObj.inputTokenUtxos, - tokenTxInputObj.remainderXecValue, - null, // token config object - for GENESIS tx only - null, // token receiver address - for SEND tx only - amount, - ); - // update txBuilder object with outputs - txBuilder = tokenTxOutputObj; - - // append the token input UTXOs to the array of XEC input UTXOs for signing - const combinedInputUtxos = tokenTxInputObj.inputXecUtxos.concat( - tokenTxInputObj.inputTokenUtxos, - ); - - // sign the collated inputUtxos and build the raw tx hex - // returns the raw tx hex string - const rawTxHex = signAndBuildTx(combinedInputUtxos, txBuilder, wallet); - - // Broadcast transaction to the network via the chronik client - // sample chronik.broadcastTx() response: - // {"txid":"0075130c9ecb342b5162bb1a8a870e69c935ea0c9b2353a967cda404401acf19"} - let broadcastResponse; - try { - broadcastResponse = await chronik.broadcastTx( - rawTxHex, - true, // skipSlpCheck to bypass chronik safety mechanism in place to avoid accidental burns - ); - if (!broadcastResponse) { - throw new Error('Empty chronik broadcast response'); - } - } catch (err) { - console.log('Error broadcasting tx to chronik client'); - throw err; - } - - // return the explorer link for the broadcasted tx - return `${currency.blockExplorerUrl}/tx/${broadcastResponse.txid}`; - }; - - const getRecipientPublicKey = async ( - chronik, - recipientAddress, - optionalMockPubKeyResponse = false, - ) => { - // Necessary because jest can't mock - // chronikTxHistoryAtAddress = await chronik.script('p2pkh', recipientAddressHash160).history(/*page=*/ 0, /*page_size=*/ 10); - if (optionalMockPubKeyResponse) { - return optionalMockPubKeyResponse; - } - - // get hash160 of address - let recipientAddressHash160; - try { - recipientAddressHash160 = toHash160(recipientAddress); - } catch (err) { - console.log( - `Error determining toHash160(${recipientAddress} in getRecipientPublicKey())`, - err, - ); - throw new Error( - `Error determining toHash160(${recipientAddress} in getRecipientPublicKey())`, - ); - } - - let chronikTxHistoryAtAddress; - try { - // Get 20 txs. If no outgoing txs in those 20 txs, just don't send the tx - chronikTxHistoryAtAddress = await chronik - .script('p2pkh', recipientAddressHash160) - .history(/*page=*/ 0, /*page_size=*/ 20); - } catch (err) { - console.log( - `Error getting await chronik.script('p2pkh', ${recipientAddressHash160}).history();`, - err, - ); - throw new Error( - 'Error fetching tx history to parse for public key', - ); - } - let recipientPubKeyChronik; - - // Iterate over tx history to find an outgoing tx - for (let i = 0; i < chronikTxHistoryAtAddress.txs.length; i += 1) { - const { inputs } = chronikTxHistoryAtAddress.txs[i]; - for (let j = 0; j < inputs.length; j += 1) { - const thisInput = inputs[j]; - const thisInputSendingHash160 = thisInput.outputScript; - if (thisInputSendingHash160.includes(recipientAddressHash160)) { - // Then this is an outgoing tx, you can get the public key from this tx - // Get the public key - try { - recipientPubKeyChronik = - chronikTxHistoryAtAddress.txs[i].inputs[ - j - ].inputScript.slice(-66); - } catch (err) { - throw new Error( - 'Cannot send an encrypted message to a wallet with no outgoing transactions', - ); - } - return recipientPubKeyChronik; - } - } - } - // You get here if you find no outgoing txs in the chronik tx history - throw new Error( - 'Cannot send an encrypted message to a wallet with no outgoing transactions in the last 20 txs', - ); - }; - - const sendXec = async ( - chronik, - wallet, - utxos, - feeInSatsPerByte, - optionalOpReturnMsg, - isOneToMany, - destinationAddressAndValueArray, - destinationAddress, - sendAmount, - encryptionFlag, - airdropFlag, - airdropTokenId, - optionalMockPubKeyResponse = false, - ) => { - try { - let txBuilder = new TransactionBuilder(); - - // parse the input value of XECs to send - const value = parseXecSendValue( - isOneToMany, - sendAmount, - destinationAddressAndValueArray, - ); - - const satoshisToSend = fromXecToSatoshis(value); - - // Throw validation error if fromXecToSatoshis returns false - if (!satoshisToSend) { - const error = new Error( - `Invalid decimal places for send amount`, - ); - throw error; - } - - let encryptedEj; // serialized encryption data object - - // if the user has opted to encrypt this message - if (encryptionFlag) { - try { - // get the pub key for the recipient address - let recipientPubKey = await getRecipientPublicKey( - chronik, - destinationAddress, - optionalMockPubKeyResponse, - ); - - // if the API can't find a pub key, it is due to the wallet having no outbound tx - if (recipientPubKey === 'not found') { - throw new Error( - 'Cannot send an encrypted message to a wallet with no outgoing transactions', - ); - } - - // encrypt the message - const pubKeyBuf = Buffer.from(recipientPubKey, 'hex'); - const bufferedFile = Buffer.from(optionalOpReturnMsg); - const structuredEj = await ecies.encrypt( - pubKeyBuf, - bufferedFile, - { compressEpk: true }, - ); - - // Serialize the encrypted data object - encryptedEj = Buffer.concat([ - structuredEj.epk, - structuredEj.iv, - structuredEj.ct, - structuredEj.mac, - ]); - } catch (err) { - console.log(`sendXec() encryption error.`); - throw err; - } - } - - // Start of building the OP_RETURN output. - // only build the OP_RETURN output if the user supplied it - if ( - (optionalOpReturnMsg && - typeof optionalOpReturnMsg !== 'undefined' && - optionalOpReturnMsg.trim() !== '') || - airdropFlag - ) { - const opReturnData = generateOpReturnScript( - optionalOpReturnMsg, - encryptionFlag, - airdropFlag, - airdropTokenId, - encryptedEj, - ); - txBuilder.addOutput(opReturnData, 0); - } - - // generate the tx inputs and add to txBuilder instance - // returns the updated txBuilder, txFee, totalInputUtxoValue and inputUtxos - let txInputObj = generateTxInput( - isOneToMany, - utxos, - txBuilder, - destinationAddressAndValueArray, - satoshisToSend, - feeInSatsPerByte, - ); - - const changeAddress = getChangeAddressFromInputUtxos( - txInputObj.inputUtxos, - wallet, - ); - txBuilder = txInputObj.txBuilder; // update the local txBuilder with the generated tx inputs - - // generate the tx outputs and add to txBuilder instance - // returns the updated txBuilder - const txOutputObj = generateTxOutput( - isOneToMany, - value, - satoshisToSend, - txInputObj.totalInputUtxoValue, - destinationAddress, - destinationAddressAndValueArray, - changeAddress, - txInputObj.txFee, - txBuilder, - ); - txBuilder = txOutputObj; // update the local txBuilder with the generated tx outputs - - // sign the collated inputUtxos and build the raw tx hex - // returns the raw tx hex string - const rawTxHex = signAndBuildTx( - txInputObj.inputUtxos, - txBuilder, - wallet, - ); - - // Broadcast transaction to the network via the chronik client - // sample chronik.broadcastTx() response: - // {"txid":"0075130c9ecb342b5162bb1a8a870e69c935ea0c9b2353a967cda404401acf19"} - let broadcastResponse; - try { - broadcastResponse = await chronik.broadcastTx(rawTxHex); - if (!broadcastResponse) { - throw new Error('Empty chronik broadcast response'); - } - } catch (err) { - console.log('Error broadcasting tx to chronik client'); - throw err; - } - - // return the explorer link for the broadcasted tx - return `${currency.blockExplorerUrl}/tx/${broadcastResponse.txid}`; - } catch (err) { - if (err.error === 'insufficient priority (code 66)') { - err.code = SEND_XEC_ERRORS.INSUFFICIENT_PRIORITY; - } else if (err.error === 'txn-mempool-conflict (code 18)') { - err.code = SEND_XEC_ERRORS.DOUBLE_SPENDING; - } else if (err.error === 'Network Error') { - err.code = SEND_XEC_ERRORS.NETWORK_ERROR; - } else if ( - err.error === - 'too-long-mempool-chain, too many unconfirmed ancestors [limit: 25] (code 64)' - ) { - err.code = SEND_XEC_ERRORS.MAX_UNCONFIRMED_TXS; - } - console.log(`error: `, err); - throw err; - } - }; - - return { - sendXec, - sendToken, - createToken, - getRecipientPublicKey, - burnToken, - }; -} diff --git a/web/cashtab/src/hooks/__mocks__/burnToken.js b/web/cashtab/src/utils/__mocks__/burnToken.js rename from web/cashtab/src/hooks/__mocks__/burnToken.js rename to web/cashtab/src/utils/__mocks__/burnToken.js diff --git a/web/cashtab/src/hooks/__mocks__/createToken.js b/web/cashtab/src/utils/__mocks__/createToken.js rename from web/cashtab/src/hooks/__mocks__/createToken.js rename to web/cashtab/src/utils/__mocks__/createToken.js diff --git a/web/cashtab/src/hooks/__mocks__/sendBCH.js b/web/cashtab/src/utils/__mocks__/sendBCH.js rename from web/cashtab/src/hooks/__mocks__/sendBCH.js rename to web/cashtab/src/utils/__mocks__/sendBCH.js diff --git a/web/cashtab/src/utils/__tests__/cashMethods.test.js b/web/cashtab/src/utils/__tests__/cashMethods.test.js --- a/web/cashtab/src/utils/__tests__/cashMethods.test.js +++ b/web/cashtab/src/utils/__tests__/cashMethods.test.js @@ -84,7 +84,7 @@ import mockLegacyWallets from 'hooks/__mocks__/mockLegacyWallets'; import BCHJS from '@psf/bch-js'; -import sendBCHMock from '../../hooks/__mocks__/sendBCH'; +import sendBCHMock from '../__mocks__/sendBCH'; import { activeWebsocketAlpha, disconnectedWebsocketAlpha, @@ -108,7 +108,7 @@ mockSingleOutput, mockMultipleOutputs, } from '../__mocks__/mockTxBuilderData'; -import createTokenMock from '../../hooks/__mocks__/createToken'; +import createTokenMock from '../__mocks__/createToken'; import TransactionBuilder from 'utils/txBuilder'; import { mockWif, mockStringifiedECPair } from '../__mocks__/mockECPair'; diff --git a/web/cashtab/src/hooks/__tests__/useBCH.test.js b/web/cashtab/src/utils/__tests__/transactions.test.js rename from web/cashtab/src/hooks/__tests__/useBCH.test.js rename to web/cashtab/src/utils/__tests__/transactions.test.js --- a/web/cashtab/src/hooks/__tests__/useBCH.test.js +++ b/web/cashtab/src/utils/__tests__/transactions.test.js @@ -1,5 +1,4 @@ /* eslint-disable no-native-reassign */ -import useBCH from '../useBCH'; import sendBCHMock from '../__mocks__/sendBCH'; import createTokenMock from '../__mocks__/createToken'; import { burnTokenWallet } from '../__mocks__/burnToken'; @@ -7,10 +6,15 @@ import BigNumber from 'bignumber.js'; import { fromSatoshisToXec } from 'utils/cashMethods'; import { ChronikClient } from 'chronik-client'; // for mocking purposes +import { + sendXec, + burnToken, + createToken, + getRecipientPublicKey, +} from 'utils/transactions'; -describe('useBCH hook', () => { +describe('Cashtab transaction broadcasting functions', () => { it('sends XEC correctly', async () => { - const { sendXec } = useBCH(); const chronik = new ChronikClient( 'https://FakeChronikUrlToEnsureMocksOnly.com', ); @@ -36,7 +40,6 @@ }); it('sends XEC correctly with an encrypted OP_RETURN message', async () => { - const { sendXec } = useBCH(); const chronik = new ChronikClient( 'https://FakeChronikUrlToEnsureMocksOnly.com', ); @@ -68,7 +71,6 @@ }); it('sends one to many XEC correctly', async () => { - const { sendXec } = useBCH(); const chronik = new ChronikClient( 'https://FakeChronikUrlToEnsureMocksOnly.com', ); @@ -98,7 +100,6 @@ }); it(`Throws error if called trying to send one base unit ${currency.ticker} more than available in utxo set`, async () => { - const { sendXec } = useBCH(); const chronik = new ChronikClient( 'https://FakeChronikUrlToEnsureMocksOnly.com', ); @@ -155,7 +156,6 @@ }); it('Throws error on attempt to send one satoshi less than backend dust limit', async () => { - const { sendXec } = useBCH(); const chronik = new ChronikClient( 'https://FakeChronikUrlToEnsureMocksOnly.com', ); @@ -177,7 +177,6 @@ }); it("Throws error attempting to burn an eToken ID that is not within the wallet's utxo", async () => { - const { burnToken } = useBCH(); const wallet = burnTokenWallet; const burnAmount = 10; const eTokenId = '0203c768a66eba24affNOTVALID103b772de4d9f8f63ba79e'; @@ -200,7 +199,6 @@ }); it('receives errors from the network and parses it', async () => { - const { sendXec } = useBCH(); const chronik = new ChronikClient( 'https://FakeChronikUrlToEnsureMocksOnly.com', ); @@ -283,7 +281,6 @@ }); it('creates a token correctly', async () => { - const { createToken } = useBCH(); const { expectedTxId, expectedHex, wallet, configObj } = createTokenMock; const chronik = new ChronikClient( @@ -298,7 +295,6 @@ }); it('Throws correct error if user attempts to create a token with an invalid wallet', async () => { - const { createToken } = useBCH(); const { invalidWallet, configObj } = createTokenMock; const chronik = new ChronikClient( 'https://FakeChronikUrlToEnsureMocksOnly.com', @@ -315,7 +311,6 @@ }); it(`getRecipientPublicKey() correctly retrieves the public key of a cash address`, async () => { - const { getRecipientPublicKey } = useBCH(); const chronik = new ChronikClient( 'https://FakeChronikUrlToEnsureMocksOnly.com', ); diff --git a/web/cashtab/src/utils/__tests__/txBuilder.test.js b/web/cashtab/src/utils/__tests__/txBuilder.test.js --- a/web/cashtab/src/utils/__tests__/txBuilder.test.js +++ b/web/cashtab/src/utils/__tests__/txBuilder.test.js @@ -1,7 +1,7 @@ /* eslint-disable no-native-reassign */ import BigNumber from 'bignumber.js'; import { currency } from 'components/Common/Ticker.js'; -import sendBCHMock from 'hooks/__mocks__/sendBCH'; +import sendBCHMock from '../__mocks__/sendBCH'; import { generateTxInput, generateTxOutput, diff --git a/web/cashtab/src/utils/transactions.js b/web/cashtab/src/utils/transactions.js new file mode 100644 --- /dev/null +++ b/web/cashtab/src/utils/transactions.js @@ -0,0 +1,485 @@ +import { currency } from 'components/Common/Ticker'; +import { + fromXecToSatoshis, + isValidStoredWallet, + parseXecSendValue, + generateOpReturnScript, + generateTxInput, + generateTxOutput, + generateTokenTxInput, + generateTokenTxOutput, + signAndBuildTx, + getChangeAddressFromInputUtxos, + toHash160, +} from 'utils/cashMethods'; +import ecies from 'ecies-lite'; +import TransactionBuilder from 'utils/txBuilder'; + +const SEND_XEC_ERRORS = { + INSUFFICIENT_FUNDS: 0, + NETWORK_ERROR: 1, + INSUFFICIENT_PRIORITY: 66, // ~insufficient fee + DOUBLE_SPENDING: 18, + MAX_UNCONFIRMED_TXS: 64, +}; + +export const createToken = async ( + chronik, + wallet, + feeInSatsPerByte, + configObj, +) => { + try { + // Throw error if wallet does not have utxo set in state + if (!isValidStoredWallet(wallet)) { + const walletError = new Error(`Invalid wallet`); + throw walletError; + } + const utxos = wallet.state.nonSlpUtxos; + const CREATION_ADDR = wallet.Path1899.cashAddress; + let txBuilder = new TransactionBuilder(); + + let tokenTxInputObj = generateTokenTxInput( + 'GENESIS', + utxos, + null, // total token UTXOS - not applicable for GENESIS tx + null, // token ID - not applicable for GENESIS tx + null, // token amount - not applicable for GENESIS tx + feeInSatsPerByte, + txBuilder, + ); + // update txBuilder object with inputs + txBuilder = tokenTxInputObj.txBuilder; + + let tokenTxOutputObj = generateTokenTxOutput( + txBuilder, + 'GENESIS', + CREATION_ADDR, + null, // token UTXOS being spent - not applicable for GENESIS tx + tokenTxInputObj.remainderXecValue, + configObj, + ); + // update txBuilder object with outputs + txBuilder = tokenTxOutputObj; + + // sign the collated inputUtxos and build the raw tx hex + // returns the raw tx hex string + const rawTxHex = signAndBuildTx( + tokenTxInputObj.inputXecUtxos, + txBuilder, + wallet, + ); + + // Broadcast transaction to the network via the chronik client + // sample chronik.broadcastTx() response: + // {"txid":"0075130c9ecb342b5162bb1a8a870e69c935ea0c9b2353a967cda404401acf19"} + let broadcastResponse; + try { + broadcastResponse = await chronik.broadcastTx( + rawTxHex, + true, // skipSlpCheck to bypass chronik safety mechanism in place to avoid accidental burns + // if the wallet has existing burns via bch-api then chronik will throw 'invalid-slp-burns' errors without this flag + ); + if (!broadcastResponse) { + throw new Error('Empty chronik broadcast response'); + } + } catch (err) { + console.log('Error broadcasting tx to chronik client'); + throw err; + } + + // return the explorer link for the broadcasted tx + return `${currency.blockExplorerUrl}/tx/${broadcastResponse.txid}`; + } catch (err) { + if (err.error === 'insufficient priority (code 66)') { + err.code = SEND_XEC_ERRORS.INSUFFICIENT_PRIORITY; + } else if (err.error === 'txn-mempool-conflict (code 18)') { + err.code = SEND_XEC_ERRORS.DOUBLE_SPENDING; + } else if (err.error === 'Network Error') { + err.code = SEND_XEC_ERRORS.NETWORK_ERROR; + } else if ( + err.error === + 'too-long-mempool-chain, too many unconfirmed ancestors [limit: 25] (code 64)' + ) { + err.code = SEND_XEC_ERRORS.MAX_UNCONFIRMED_TXS; + } + console.log(`error: `, err); + throw err; + } +}; + +export const sendToken = async ( + chronik, + wallet, + { tokenId, amount, tokenReceiverAddress }, +) => { + const { slpUtxos, nonSlpUtxos } = wallet.state; + const CREATION_ADDR = wallet.Path1899.cashAddress; + + // Handle error of user having no XEC + if (!nonSlpUtxos || nonSlpUtxos.length === 0) { + throw new Error( + `You need some ${currency.ticker} to send ${currency.tokenTicker}`, + ); + } + + // instance of transaction builder + let txBuilder = new TransactionBuilder(); + + let tokenTxInputObj = generateTokenTxInput( + 'SEND', + nonSlpUtxos, + slpUtxos, + tokenId, + amount, + currency.defaultFee, + txBuilder, + ); + // update txBuilder object with inputs + txBuilder = tokenTxInputObj.txBuilder; + + let tokenTxOutputObj = generateTokenTxOutput( + txBuilder, + 'SEND', + CREATION_ADDR, + tokenTxInputObj.inputTokenUtxos, + tokenTxInputObj.remainderXecValue, + null, // token config object - for GENESIS tx only + tokenReceiverAddress, + amount, + ); + // update txBuilder object with outputs + txBuilder = tokenTxOutputObj; + + // append the token input UTXOs to the array of XEC input UTXOs for signing + const combinedInputUtxos = tokenTxInputObj.inputXecUtxos.concat( + tokenTxInputObj.inputTokenUtxos, + ); + + // sign the collated inputUtxos and build the raw tx hex + // returns the raw tx hex string + const rawTxHex = signAndBuildTx(combinedInputUtxos, txBuilder, wallet); + + // Broadcast transaction to the network via the chronik client + // sample chronik.broadcastTx() response: + // {"txid":"0075130c9ecb342b5162bb1a8a870e69c935ea0c9b2353a967cda404401acf19"} + let broadcastResponse; + try { + broadcastResponse = await chronik.broadcastTx( + rawTxHex, + true, // skipSlpCheck to bypass chronik safety mechanism in place to avoid accidental burns + // if the wallet has existing burns via bch-api then chronik will throw 'invalid-slp-burns' errors without this flag + ); + if (!broadcastResponse) { + throw new Error('Empty chronik broadcast response'); + } + } catch (err) { + console.log('Error broadcasting tx to chronik client'); + throw err; + } + + // return the explorer link for the broadcasted tx + return `${currency.blockExplorerUrl}/tx/${broadcastResponse.txid}`; +}; + +export const burnToken = async (chronik, wallet, { tokenId, amount }) => { + const { slpUtxos, nonSlpUtxos } = wallet.state; + const CREATION_ADDR = wallet.Path1899.cashAddress; + + // Handle error of user having no XEC + if (!nonSlpUtxos || nonSlpUtxos.length === 0) { + throw new Error(`You need some ${currency.ticker} to burn eTokens`); + } + + // instance of transaction builder + let txBuilder = new TransactionBuilder(); + + let tokenTxInputObj = generateTokenTxInput( + 'BURN', + nonSlpUtxos, + slpUtxos, + tokenId, + amount, + currency.defaultFee, + txBuilder, + ); + // update txBuilder object with inputs + txBuilder = tokenTxInputObj.txBuilder; + + let tokenTxOutputObj = generateTokenTxOutput( + txBuilder, + 'BURN', + CREATION_ADDR, + tokenTxInputObj.inputTokenUtxos, + tokenTxInputObj.remainderXecValue, + null, // token config object - for GENESIS tx only + null, // token receiver address - for SEND tx only + amount, + ); + // update txBuilder object with outputs + txBuilder = tokenTxOutputObj; + + // append the token input UTXOs to the array of XEC input UTXOs for signing + const combinedInputUtxos = tokenTxInputObj.inputXecUtxos.concat( + tokenTxInputObj.inputTokenUtxos, + ); + + // sign the collated inputUtxos and build the raw tx hex + // returns the raw tx hex string + const rawTxHex = signAndBuildTx(combinedInputUtxos, txBuilder, wallet); + + // Broadcast transaction to the network via the chronik client + // sample chronik.broadcastTx() response: + // {"txid":"0075130c9ecb342b5162bb1a8a870e69c935ea0c9b2353a967cda404401acf19"} + let broadcastResponse; + try { + broadcastResponse = await chronik.broadcastTx( + rawTxHex, + true, // skipSlpCheck to bypass chronik safety mechanism in place to avoid accidental burns + ); + if (!broadcastResponse) { + throw new Error('Empty chronik broadcast response'); + } + } catch (err) { + console.log('Error broadcasting tx to chronik client'); + throw err; + } + + // return the explorer link for the broadcasted tx + return `${currency.blockExplorerUrl}/tx/${broadcastResponse.txid}`; +}; + +export const getRecipientPublicKey = async ( + chronik, + recipientAddress, + optionalMockPubKeyResponse = false, +) => { + // Necessary because jest can't mock + // chronikTxHistoryAtAddress = await chronik.script('p2pkh', recipientAddressHash160).history(/*page=*/ 0, /*page_size=*/ 10); + if (optionalMockPubKeyResponse) { + return optionalMockPubKeyResponse; + } + + // get hash160 of address + let recipientAddressHash160; + try { + recipientAddressHash160 = toHash160(recipientAddress); + } catch (err) { + console.log( + `Error determining toHash160(${recipientAddress} in getRecipientPublicKey())`, + err, + ); + throw new Error( + `Error determining toHash160(${recipientAddress} in getRecipientPublicKey())`, + ); + } + + let chronikTxHistoryAtAddress; + try { + // Get 20 txs. If no outgoing txs in those 20 txs, just don't send the tx + chronikTxHistoryAtAddress = await chronik + .script('p2pkh', recipientAddressHash160) + .history(/*page=*/ 0, /*page_size=*/ 20); + } catch (err) { + console.log( + `Error getting await chronik.script('p2pkh', ${recipientAddressHash160}).history();`, + err, + ); + throw new Error('Error fetching tx history to parse for public key'); + } + let recipientPubKeyChronik; + + // Iterate over tx history to find an outgoing tx + for (let i = 0; i < chronikTxHistoryAtAddress.txs.length; i += 1) { + const { inputs } = chronikTxHistoryAtAddress.txs[i]; + for (let j = 0; j < inputs.length; j += 1) { + const thisInput = inputs[j]; + const thisInputSendingHash160 = thisInput.outputScript; + if (thisInputSendingHash160.includes(recipientAddressHash160)) { + // Then this is an outgoing tx, you can get the public key from this tx + // Get the public key + try { + recipientPubKeyChronik = + chronikTxHistoryAtAddress.txs[i].inputs[ + j + ].inputScript.slice(-66); + } catch (err) { + throw new Error( + 'Cannot send an encrypted message to a wallet with no outgoing transactions', + ); + } + return recipientPubKeyChronik; + } + } + } + // You get here if you find no outgoing txs in the chronik tx history + throw new Error( + 'Cannot send an encrypted message to a wallet with no outgoing transactions in the last 20 txs', + ); +}; + +export const sendXec = async ( + chronik, + wallet, + utxos, + feeInSatsPerByte, + optionalOpReturnMsg, + isOneToMany, + destinationAddressAndValueArray, + destinationAddress, + sendAmount, + encryptionFlag, + airdropFlag, + airdropTokenId, + optionalMockPubKeyResponse = false, +) => { + try { + let txBuilder = new TransactionBuilder(); + + // parse the input value of XECs to send + const value = parseXecSendValue( + isOneToMany, + sendAmount, + destinationAddressAndValueArray, + ); + + const satoshisToSend = fromXecToSatoshis(value); + + // Throw validation error if fromXecToSatoshis returns false + if (!satoshisToSend) { + const error = new Error(`Invalid decimal places for send amount`); + throw error; + } + + let encryptedEj; // serialized encryption data object + + // if the user has opted to encrypt this message + if (encryptionFlag) { + try { + // get the pub key for the recipient address + let recipientPubKey = await getRecipientPublicKey( + chronik, + destinationAddress, + optionalMockPubKeyResponse, + ); + + // if the API can't find a pub key, it is due to the wallet having no outbound tx + if (recipientPubKey === 'not found') { + throw new Error( + 'Cannot send an encrypted message to a wallet with no outgoing transactions', + ); + } + + // encrypt the message + const pubKeyBuf = Buffer.from(recipientPubKey, 'hex'); + const bufferedFile = Buffer.from(optionalOpReturnMsg); + const structuredEj = await ecies.encrypt( + pubKeyBuf, + bufferedFile, + { compressEpk: true }, + ); + + // Serialize the encrypted data object + encryptedEj = Buffer.concat([ + structuredEj.epk, + structuredEj.iv, + structuredEj.ct, + structuredEj.mac, + ]); + } catch (err) { + console.log(`sendXec() encryption error.`); + throw err; + } + } + + // Start of building the OP_RETURN output. + // only build the OP_RETURN output if the user supplied it + if ( + (optionalOpReturnMsg && + typeof optionalOpReturnMsg !== 'undefined' && + optionalOpReturnMsg.trim() !== '') || + airdropFlag + ) { + const opReturnData = generateOpReturnScript( + optionalOpReturnMsg, + encryptionFlag, + airdropFlag, + airdropTokenId, + encryptedEj, + ); + txBuilder.addOutput(opReturnData, 0); + } + + // generate the tx inputs and add to txBuilder instance + // returns the updated txBuilder, txFee, totalInputUtxoValue and inputUtxos + let txInputObj = generateTxInput( + isOneToMany, + utxos, + txBuilder, + destinationAddressAndValueArray, + satoshisToSend, + feeInSatsPerByte, + ); + + const changeAddress = getChangeAddressFromInputUtxos( + txInputObj.inputUtxos, + wallet, + ); + txBuilder = txInputObj.txBuilder; // update the local txBuilder with the generated tx inputs + + // generate the tx outputs and add to txBuilder instance + // returns the updated txBuilder + const txOutputObj = generateTxOutput( + isOneToMany, + value, + satoshisToSend, + txInputObj.totalInputUtxoValue, + destinationAddress, + destinationAddressAndValueArray, + changeAddress, + txInputObj.txFee, + txBuilder, + ); + txBuilder = txOutputObj; // update the local txBuilder with the generated tx outputs + + // sign the collated inputUtxos and build the raw tx hex + // returns the raw tx hex string + const rawTxHex = signAndBuildTx( + txInputObj.inputUtxos, + txBuilder, + wallet, + ); + + // Broadcast transaction to the network via the chronik client + // sample chronik.broadcastTx() response: + // {"txid":"0075130c9ecb342b5162bb1a8a870e69c935ea0c9b2353a967cda404401acf19"} + let broadcastResponse; + try { + broadcastResponse = await chronik.broadcastTx(rawTxHex); + if (!broadcastResponse) { + throw new Error('Empty chronik broadcast response'); + } + } catch (err) { + console.log('Error broadcasting tx to chronik client'); + throw err; + } + + // return the explorer link for the broadcasted tx + return `${currency.blockExplorerUrl}/tx/${broadcastResponse.txid}`; + } catch (err) { + if (err.error === 'insufficient priority (code 66)') { + err.code = SEND_XEC_ERRORS.INSUFFICIENT_PRIORITY; + } else if (err.error === 'txn-mempool-conflict (code 18)') { + err.code = SEND_XEC_ERRORS.DOUBLE_SPENDING; + } else if (err.error === 'Network Error') { + err.code = SEND_XEC_ERRORS.NETWORK_ERROR; + } else if ( + err.error === + 'too-long-mempool-chain, too many unconfirmed ancestors [limit: 25] (code 64)' + ) { + err.code = SEND_XEC_ERRORS.MAX_UNCONFIRMED_TXS; + } + console.log(`error: `, err); + throw err; + } +};