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 { BCH, wallet, fiatPrice, apiError, cashtabSettings } = ContextValue; + const { BCH, wallet, fiatPrice, apiError, cashtabSettings, chronik } = + ContextValue; const walletState = getWalletState(wallet); const { balances, slpBalancesAndUtxos } = walletState; // Modal settings @@ -367,6 +368,7 @@ const link = await sendXec( bchObj, + chronik, wallet, slpBalancesAndUtxos.nonSlpUtxos, currency.defaultFee, @@ -431,6 +433,7 @@ try { const link = await sendXec( bchObj, + chronik, wallet, slpBalancesAndUtxos.nonSlpUtxos, currency.defaultFee, 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 @@ -32,6 +32,8 @@ import { currency } from '../../components/Common/Ticker'; import BigNumber from 'bignumber.js'; import { fromSatoshisToXec } from 'utils/cashMethods'; +import { ChronikClient } from 'chronik-client'; // for mocking purposes +import { when } from 'jest-when'; describe('useBCH hook', () => { it('gets Rest Api Url on testnet', () => { @@ -73,6 +75,9 @@ it('sends XEC correctly', async () => { const { sendXec } = useBCH(); const BCH = new BCHJS(); + const chronik = new ChronikClient( + 'https://FakeChronikUrlToEnsureMocksOnly.com', + ); const { expectedTxId, expectedHex, @@ -82,12 +87,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, @@ -98,14 +104,14 @@ 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( + 'https://FakeChronikUrlToEnsureMocksOnly.com', + ); const { expectedTxId, utxos, wallet, destinationAddress, sendAmount } = sendBCHMock; const expectedPubKeyResponse = { @@ -118,12 +124,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, @@ -140,6 +147,9 @@ it('sends one to many XEC correctly', async () => { const { sendXec } = useBCH(); const BCH = new BCHJS(); + const chronik = new ChronikClient( + 'https://FakeChronikUrlToEnsureMocksOnly.com', + ); const { expectedTxId, expectedHex, @@ -156,12 +166,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, @@ -175,33 +186,49 @@ 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( + 'https://FakeChronikUrlToEnsureMocksOnly.com', + ); 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, @@ -211,18 +238,21 @@ 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( + 'https://FakeChronikUrlToEnsureMocksOnly.com', + ); const { expectedTxId, utxos, wallet, destinationAddress } = sendBCHMock; - BCH.RawTransactions.sendRawTransaction = jest - .fn() - .mockResolvedValue(expectedTxId); const failedSendBch = sendXec( BCH, + chronik, wallet, utxos, currency.defaultFee, @@ -235,18 +265,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 () => { @@ -273,14 +291,16 @@ it('receives errors from the network and parses it', async () => { const { sendXec } = useBCH(); const BCH = new BCHJS(); + const chronik = new ChronikClient( + 'https://FakeChronikUrlToEnsureMocksOnly.com', + ); 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, @@ -294,13 +314,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, @@ -314,13 +333,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, @@ -332,17 +350,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 @@ -3,11 +3,16 @@ import SlpWallet from 'minimal-slp-wallet'; import { fromXecToSatoshis, - fromSatoshisToXec, isValidStoredWallet, convertToEncryptStruct, getPublicKey, parseOpReturn, + parseXecSendValue, + generateOpReturnScript, + generateTxInput, + generateTxOutput, + signAndBuildTx, + getChangeAddressFromInputUtxos, signUtxosByAddress, getUtxoWif, } from 'utils/cashMethods'; @@ -1083,6 +1088,7 @@ const sendXec = async ( BCH, + chronik, wallet, utxos, feeInSatsPerByte, @@ -1096,68 +1102,14 @@ airdropTokenId, ) => { try { - let value = new BigNumber(0); + let txBuilder = new BCH.TransactionBuilder(); - 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( - fromSatoshisToXec(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( - fromSatoshisToXec(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'); + // parse the input value of XECs to send + const value = parseXecSendValue( + isOneToMany, + sendAmount, + destinationAddressAndValueArray, + ); const satoshisToSend = fromXecToSatoshis(value); @@ -1169,7 +1121,45 @@ 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 ( @@ -1178,188 +1168,77 @@ 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); - } - // 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.outpoint.outIdx; - const txid = utxo.outpoint.txid; - // add input with txid and index of vout - transactionBuilder.addInput(txid, vout); - - inputUtxos.push(utxo); - txFee = calcFee(BCH, inputUtxos, txOutputs, feeInSatsPerByte); - - if (originalAmount.minus(satoshisToSend).minus(txFee).gte(0)) { - break; - } - } - - // Get change address from sending utxos - // fall back to what is stored in wallet - let REMAINDER_ADDR; - - // 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; - } - - // amount to send back to the remainder address. - const remainder = originalAmount.minus(satoshisToSend).minus(txFee); - - if (remainder.lt(0)) { - const error = new Error(`Insufficient funds`); - error.code = SEND_BCH_ERRORS.INSUFFICIENT_FUNDS; - throw error; - } - - 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(fromXecToSatoshis(outputValue)), - ); - } - } else { - // for one to one mode, add output w/ single address and amount to send - transactionBuilder.addOutput( - BCH.Address.toCashAddress(destinationAddress), - parseInt(fromXecToSatoshis(value)), + const opReturnData = generateOpReturnScript( + BCH, + optionalOpReturnMsg, + encryptionFlag, + airdropFlag, + airdropTokenId, + encryptedEj, ); + txBuilder.addOutput(opReturnData, 0); } - if (remainder.gte(new BigNumber(currency.dustSats))) { - transactionBuilder.addOutput( - REMAINDER_ADDR, - parseInt(remainder), - ); - } + // 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, + ); - // Sign each XEC UTXO being consumed and refresh transactionBuilder - transactionBuilder = signUtxosByAddress( + const changeAddress = getChangeAddressFromInputUtxos( BCH, - inputUtxos, + txInputObj.inputUtxos, wallet, - transactionBuilder, ); + txBuilder = txInputObj.txBuilder; // update the local txBuilder with the generated tx inputs - // build tx - const tx = transactionBuilder.build(); - // output rawhex - const hex = tx.toHex(); + // 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, + ); + txBuilder = txOutputObj; // update the local txBuilder with the generated tx outputs - // Broadcast transaction to the network - const txidStr = await BCH.RawTransactions.sendRawTransaction([hex]); + // sign the collated inputUtxos and build the raw tx hex + // returns the raw tx hex string + const rawTxHex = signAndBuildTx( + BCH, + txInputObj.inputUtxos, + txBuilder, + wallet, + ); - 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}`; + // 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; } - //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/utils/cashMethods.js b/web/cashtab/src/utils/cashMethods.js --- a/web/cashtab/src/utils/cashMethods.js +++ b/web/cashtab/src/utils/cashMethods.js @@ -334,22 +334,8 @@ throw new Error('Invalid buildTx parameter'); } - // Sign the transactions with the HD node. - for (let i = 0; i < inputUtxos.length; i++) { - const utxo = inputUtxos[i]; - const wif = getUtxoWif(utxo, wallet); - try { - txBuilder.sign( - i, - BCH.ECPair.fromWIF(wif), - undefined, - txBuilder.hashTypes.SIGHASH_ALL, - utxo.value, - ); - } catch (err) { - throw new Error('Error signing input utxos'); - } - } + // Sign each XEC UTXO being consumed and refresh transactionBuilder + txBuilder = signUtxosByAddress(BCH, inputUtxos, wallet, txBuilder); let hex; try {