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 @@ -125,7 +125,8 @@ // Else set it as blank const ContextValue = React.useContext(WalletContext); const location = useLocation(); - const { wallet, fiatPrice, apiError, cashtabSettings } = ContextValue; + const { wallet, fiatPrice, apiError, cashtabSettings, chronik } = + ContextValue; const walletState = getWalletState(wallet); const { balances, slpBalancesAndUtxos } = walletState; // Modal settings @@ -360,6 +361,7 @@ const link = await sendXec( bchObj, + chronik, wallet, slpBalancesAndUtxos.nonSlpUtxos, currency.defaultFee, @@ -424,6 +426,7 @@ try { const link = await sendXec( bchObj, + chronik, wallet, slpBalancesAndUtxos.nonSlpUtxos, currency.defaultFee, @@ -440,6 +443,7 @@ handleSendXecError(e, isOneToManyXECSend); } } + passLoadingStatus(false); } const handleAddressChange = e => { diff --git a/web/cashtab/src/hooks/__tests__/useBCH.test.js b/web/cashtab/src/hooks/__tests__/useBCH.test.js --- a/web/cashtab/src/hooks/__tests__/useBCH.test.js +++ b/web/cashtab/src/hooks/__tests__/useBCH.test.js @@ -39,6 +39,7 @@ import { currency } from '../../components/Common/Ticker'; import BigNumber from 'bignumber.js'; import { fromSmallestDenomination } from 'utils/cashMethods'; +import { ChronikClient } from 'chronik-client'; // for mocking purposes describe('useBCH hook', () => { it('gets Rest Api Url on testnet', () => { @@ -122,6 +123,7 @@ it('sends XEC correctly', async () => { const { sendXec } = useBCH(); const BCH = new BCHJS(); + const chronik = new ChronikClient(currency.chronikUrl); const { expectedTxId, expectedHex, @@ -131,12 +133,13 @@ sendAmount, } = sendBCHMock; - BCH.RawTransactions.sendRawTransaction = jest + chronik.broadcastTx = jest .fn() - .mockResolvedValue(expectedTxId); + .mockResolvedValue({ txid: expectedTxId }); expect( await sendXec( BCH, + chronik, wallet, utxos, currency.defaultFee, @@ -147,14 +150,12 @@ sendAmount, ), ).toBe(`${currency.blockExplorerUrl}/tx/${expectedTxId}`); - expect(BCH.RawTransactions.sendRawTransaction).toHaveBeenCalledWith( - expectedHex, - ); }); it('sends XEC correctly with an encrypted OP_RETURN message', async () => { const { sendXec } = useBCH(); const BCH = new BCHJS(); + const chronik = new ChronikClient(currency.chronikUrl); const { expectedTxId, utxos, wallet, destinationAddress, sendAmount } = sendBCHMock; const expectedPubKeyResponse = { @@ -167,12 +168,13 @@ .fn() .mockResolvedValue(expectedPubKeyResponse); - BCH.RawTransactions.sendRawTransaction = jest + chronik.broadcastTx = jest .fn() - .mockResolvedValue(expectedTxId); + .mockResolvedValue({ txid: expectedTxId }); expect( await sendXec( BCH, + chronik, wallet, utxos, currency.defaultFee, @@ -189,6 +191,7 @@ it('sends one to many XEC correctly', async () => { const { sendXec } = useBCH(); const BCH = new BCHJS(); + const chronik = new ChronikClient(currency.chronikUrl); const { expectedTxId, expectedHex, @@ -205,12 +208,13 @@ 'bitcoincash:qrzuvj0vvnsz5949h4axercl5k420eygavv0awgz05,6', ]; - BCH.RawTransactions.sendRawTransaction = jest + chronik.broadcastTx = jest .fn() - .mockResolvedValue(expectedTxId); + .mockResolvedValue({ txid: expectedTxId }); expect( await sendXec( BCH, + chronik, wallet, utxos, currency.defaultFee, @@ -224,33 +228,47 @@ it(`Throws error if called trying to send one base unit ${currency.ticker} more than available in utxo set`, async () => { const { sendXec } = useBCH(); const BCH = new BCHJS(); + const chronik = new ChronikClient(currency.chronikUrl); const { expectedTxId, utxos, wallet, destinationAddress } = sendBCHMock; const expectedTxFeeInSats = 229; - BCH.RawTransactions.sendRawTransaction = jest - .fn() - .mockResolvedValue(expectedTxId); - const oneBaseUnitMoreThanBalance = new BigNumber(utxos[0].value) + // tally up the total utxo values + let totalInputUtxoValue = new BigNumber(0); + for (let i = 0; i < utxos.length; i++) { + totalInputUtxoValue = totalInputUtxoValue.plus( + new BigNumber(utxos[i].value), + ); + } + + const oneBaseUnitMoreThanBalance = totalInputUtxoValue .minus(expectedTxFeeInSats) .plus(1) .div(10 ** currency.cashDecimals) .toString(); - const failedSendBch = sendXec( - BCH, - wallet, - utxos, - currency.defaultFee, - '', - false, - null, - destinationAddress, - oneBaseUnitMoreThanBalance, - ); - expect(failedSendBch).rejects.toThrow(new Error('Insufficient funds')); - const nullValuesSendBch = await sendXec( + let errorThrown; + try { + await sendXec( + BCH, + chronik, + wallet, + utxos, + currency.defaultFee, + '', + false, + null, + destinationAddress, + oneBaseUnitMoreThanBalance, + ); + } catch (err) { + errorThrown = err; + } + expect(errorThrown.message).toStrictEqual('Insufficient funds'); + + const nullValuesSendBch = sendXec( BCH, + chronik, wallet, utxos, currency.defaultFee, @@ -260,18 +278,19 @@ destinationAddress, null, ); - expect(nullValuesSendBch).toBe(null); + expect(nullValuesSendBch).rejects.toThrow( + new Error('Invalid singleSendValue'), + ); }); it('Throws error on attempt to send one satoshi less than backend dust limit', async () => { const { sendXec } = useBCH(); const BCH = new BCHJS(); + const chronik = new ChronikClient(currency.chronikUrl); const { expectedTxId, utxos, wallet, destinationAddress } = sendBCHMock; - BCH.RawTransactions.sendRawTransaction = jest - .fn() - .mockResolvedValue(expectedTxId); const failedSendBch = sendXec( BCH, + chronik, wallet, utxos, currency.defaultFee, @@ -286,18 +305,6 @@ .toString(), ); expect(failedSendBch).rejects.toThrow(new Error('dust')); - const nullValuesSendBch = await sendXec( - BCH, - wallet, - utxos, - currency.defaultFee, - '', - false, - null, - destinationAddress, - null, - ); - expect(nullValuesSendBch).toBe(null); }); it("throws error attempting to burn an eToken ID that is not within the wallet's utxo", async () => { @@ -324,14 +331,14 @@ it('receives errors from the network and parses it', async () => { const { sendXec } = useBCH(); const BCH = new BCHJS(); + const chronik = new ChronikClient(currency.chronikUrl); const { sendAmount, utxos, wallet, destinationAddress } = sendBCHMock; - BCH.RawTransactions.sendRawTransaction = jest - .fn() - .mockImplementation(async () => { - throw new Error('insufficient priority (code 66)'); - }); + chronik.broadcastTx = jest.fn().mockImplementation(async () => { + throw new Error('insufficient priority (code 66)'); + }); const insufficientPriority = sendXec( BCH, + chronik, wallet, utxos, currency.defaultFee, @@ -345,13 +352,12 @@ new Error('insufficient priority (code 66)'), ); - BCH.RawTransactions.sendRawTransaction = jest - .fn() - .mockImplementation(async () => { - throw new Error('txn-mempool-conflict (code 18)'); - }); + chronik.broadcastTx = jest.fn().mockImplementation(async () => { + throw new Error('txn-mempool-conflict (code 18)'); + }); const txnMempoolConflict = sendXec( BCH, + chronik, wallet, utxos, currency.defaultFee, @@ -365,13 +371,12 @@ new Error('txn-mempool-conflict (code 18)'), ); - BCH.RawTransactions.sendRawTransaction = jest - .fn() - .mockImplementation(async () => { - throw new Error('Network Error'); - }); + chronik.broadcastTx = jest.fn().mockImplementation(async () => { + throw new Error('Network Error'); + }); const networkError = sendXec( BCH, + chronik, wallet, utxos, currency.defaultFee, @@ -383,17 +388,16 @@ ); 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; - }); + chronik.broadcastTx = jest.fn().mockImplementation(async () => { + const err = new Error( + 'too-long-mempool-chain, too many unconfirmed ancestors [limit: 25] (code 64)', + ); + throw err; + }); const tooManyAncestorsMempool = sendXec( BCH, + chronik, wallet, utxos, currency.defaultFee, diff --git a/web/cashtab/src/hooks/useBCH.js b/web/cashtab/src/hooks/useBCH.js --- a/web/cashtab/src/hooks/useBCH.js +++ b/web/cashtab/src/hooks/useBCH.js @@ -4,7 +4,6 @@ import SlpWallet from 'minimal-slp-wallet'; import { toSmallestDenomination, - fromSmallestDenomination, batchArray, flattenBatchedHydratedUtxos, isValidStoredWallet, @@ -13,6 +12,12 @@ convertToEncryptStruct, getPublicKey, parseOpReturn, + parseXecSendValue, + generateOpReturnScript, + generateTxInput, + generateTxOutput, + signAndBuildTx, + getChangeAddressFromInputUtxos, } from 'utils/cashMethods'; import cashaddr from 'ecashaddrjs'; import ecies from 'ecies-lite'; @@ -1396,6 +1401,7 @@ const sendXec = async ( BCH, + chronik, wallet, utxos, feeInSatsPerByte, @@ -1409,73 +1415,14 @@ airdropTokenId, ) => { try { - let value = new BigNumber(0); - - if (isOneToMany) { - // this is a one to many XEC transaction - if ( - !destinationAddressAndValueArray || - !destinationAddressAndValueArray.length - ) { - throw new Error('Invalid destinationAddressAndValueArray'); - } - const arrayLength = destinationAddressAndValueArray.length; - for (let i = 0; i < arrayLength; i++) { - // add the total value being sent in this array of recipients - value = BigNumber.sum( - value, - new BigNumber( - destinationAddressAndValueArray[i].split(',')[1], - ), - ); - } - - // If user is attempting to send an aggregate value that is less than minimum accepted by the backend - if ( - value.lt( - new BigNumber( - fromSmallestDenomination( - currency.dustSats, - ).toString(), - ), - ) - ) { - // Throw the same error given by the backend attempting to broadcast such a tx - throw new Error('dust'); - } - } else { - // this is a one to one XEC transaction then check sendAmount - // note: one to many transactions won't be sending a single sendAmount - - if (!sendAmount) { - return null; - } - - value = new BigNumber(sendAmount); - - // If user is attempting to send less than minimum accepted by the backend - if ( - value.lt( - new BigNumber( - fromSmallestDenomination( - currency.dustSats, - ).toString(), - ), - ) - ) { - // Throw the same error given by the backend attempting to broadcast such a tx - throw new Error('dust'); - } - } - - 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'); + let txBuilder = new BCH.TransactionBuilder(); + // parse the input value of XECs to send + const value = parseXecSendValue( + isOneToMany, + sendAmount, + destinationAddressAndValueArray, + ); const satoshisToSend = toSmallestDenomination(value); // Throw validation error if toSmallestDenomination returns false @@ -1486,7 +1433,44 @@ throw error; } - let script; + 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( + BCH, + destinationAddress, + ); + + // 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, + ); + + // 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 ( @@ -1495,192 +1479,78 @@ optionalOpReturnMsg.trim() !== '') || airdropFlag ) { - if (encryptionFlag) { - // if the user has opted to encrypt this message - let encryptedEj; - try { - encryptedEj = await handleEncryptedOpReturn( - BCH, - destinationAddress, - optionalOpReturnMsg, - ); - } catch (err) { - console.log(`useBCH.sendXec() encryption error.`); - throw err; - } - - // build the OP_RETURN script with the encryption prefix - script = [ - BCH.Script.opcodes.OP_RETURN, // 6a - Buffer.from( - currency.opReturn.appPrefixesHex.cashtabEncrypted, - 'hex', - ), // 65746162 - Buffer.from(encryptedEj), - ]; - } else { - // this is an un-encrypted message - - if (airdropFlag) { - // un-encrypted airdrop tx - if (optionalOpReturnMsg) { - // airdrop tx with message - script = [ - BCH.Script.opcodes.OP_RETURN, // 6a - Buffer.from( - currency.opReturn.appPrefixesHex.airdrop, - 'hex', - ), // drop - Buffer.from(airdropTokenId, 'hex'), - Buffer.from( - currency.opReturn.appPrefixesHex.cashtab, - 'hex', - ), // 00746162 - Buffer.from(optionalOpReturnMsg), - ]; - } else { - // airdrop tx with no message - script = [ - BCH.Script.opcodes.OP_RETURN, // 6a - Buffer.from( - currency.opReturn.appPrefixesHex.airdrop, - 'hex', - ), // drop - Buffer.from(airdropTokenId, 'hex'), - Buffer.from( - currency.opReturn.appPrefixesHex.cashtab, - 'hex', - ), // 00746162 - ]; - } - } else { - // non-airdrop un-encrypted message - script = [ - BCH.Script.opcodes.OP_RETURN, // 6a - Buffer.from( - currency.opReturn.appPrefixesHex.cashtab, - 'hex', - ), // 00746162 - Buffer.from(optionalOpReturnMsg), - ]; - } - } - const data = BCH.Script.encode(script); - transactionBuilder.addOutput(data, 0); + const opReturnData = generateOpReturnScript( + BCH, + optionalOpReturnMsg, + encryptionFlag, + airdropFlag, + airdropTokenId, + encryptedEj, + ); + txBuilder.addOutput(opReturnData, 0); } - // End of building the OP_RETURN output. - - let originalAmount = new BigNumber(0); - let txFee = 0; - // A normal tx will have 2 outputs, destination and change - // A one to many tx will have n outputs + 1 change output, where n is the number of recipients - const txOutputs = isOneToMany - ? destinationAddressAndValueArray.length + 1 - : 2; - for (let i = 0; i < utxos.length; i++) { - const utxo = utxos[i]; - 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 = calcFee(BCH, inputUtxos, txOutputs, feeInSatsPerByte); + // generate the tx inputs and add to txBuilder instance + // returns the updated txBuilder, txFee, totalInputUtxoValue and inputUtxos + let txInputObj = generateTxInput( + BCH, + isOneToMany, + utxos, + txBuilder, + destinationAddressAndValueArray, + satoshisToSend, + feeInSatsPerByte, + ); - if (originalAmount.minus(satoshisToSend).minus(txFee).gte(0)) { - break; - } - } + const changeAddress = getChangeAddressFromInputUtxos( + BCH, + txInputObj.inputUtxos, + wallet, + ); - // Get change address from sending utxos - // fall back to what is stored in wallet - let REMAINDER_ADDR; + txBuilder = txInputObj.txBuilder; // update the local txBuilder with the generated tx inputs - // Validate address - let isValidChangeAddress; - try { - REMAINDER_ADDR = inputUtxos[0].address; - isValidChangeAddress = - BCH.Address.isCashAddress(REMAINDER_ADDR); - } catch (err) { - isValidChangeAddress = false; - } - if (!isValidChangeAddress) { - REMAINDER_ADDR = wallet.Path1899.cashAddress; - } + // generate the tx outputs and add to txBuilder instance + // returns the updated txBuilder + const txOutputObj = generateTxOutput( + BCH, + isOneToMany, + value, + satoshisToSend, + txInputObj.totalInputUtxoValue, + destinationAddress, + destinationAddressAndValueArray, + changeAddress, + txInputObj.txFee, + txBuilder, + ); - // amount to send back to the remainder address. - const remainder = originalAmount.minus(satoshisToSend).minus(txFee); + txBuilder = txOutputObj; // update the local txBuilder with the generated tx outputs - if (remainder.lt(0)) { - const error = new Error(`Insufficient funds`); - error.code = SEND_BCH_ERRORS.INSUFFICIENT_FUNDS; - throw error; - } + // sign the collated inputUtxos and build the raw tx hex + // returns the raw tx hex string + const rawTxHex = signAndBuildTx( + BCH, + txInputObj.inputUtxos, + txBuilder, + ); - if (isOneToMany) { - // for one to many mode, add the multiple outputs from the array - let arrayLength = destinationAddressAndValueArray.length; - for (let i = 0; i < arrayLength; i++) { - // add each send tx from the array as an output - let outputAddress = - destinationAddressAndValueArray[i].split(',')[0]; - let outputValue = new BigNumber( - destinationAddressAndValueArray[i].split(',')[1], - ); - transactionBuilder.addOutput( - BCH.Address.toCashAddress(outputAddress), - parseInt(toSmallestDenomination(outputValue)), - ); + // 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'); } - } else { - // for one to one mode, add output w/ single address and amount to send - transactionBuilder.addOutput( - BCH.Address.toCashAddress(destinationAddress), - parseInt(toSmallestDenomination(value)), - ); - } - - if (remainder.gte(new BigNumber(currency.dustSats))) { - transactionBuilder.addOutput( - REMAINDER_ADDR, - parseInt(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 (process.env.REACT_APP_NETWORK === `mainnet`) { - link = `${currency.blockExplorerUrl}/tx/${txidStr}`; - } else { - link = `${currency.blockExplorerUrlTestnet}/tx/${txidStr}`; + } catch (err) { + console.log('Error broadcasting tx to chronik client'); + throw err; } - //console.log(`link`, link); - return link; + // 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_BCH_ERRORS.INSUFFICIENT_PRIORITY; diff --git a/web/cashtab/src/hooks/useWallet.js b/web/cashtab/src/hooks/useWallet.js --- a/web/cashtab/src/hooks/useWallet.js +++ b/web/cashtab/src/hooks/useWallet.js @@ -1457,6 +1457,7 @@ return { BCH, + chronik, wallet, fiatPrice, loading,