Page MenuHomePhabricator

No OneTemporary

diff --git a/web/cashtab/src/components/Send/Send.js b/web/cashtab/src/components/Send/Send.js
index 4be804938..09ae6c0ca 100644
--- a/web/cashtab/src/components/Send/Send.js
+++ b/web/cashtab/src/components/Send/Send.js
@@ -1,1340 +1,1340 @@
import React, { useState, useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import PropTypes from 'prop-types';
import { WalletContext } from 'utils/context';
import {
AntdFormWrapper,
SendBchInput,
DestinationAddressSingle,
DestinationAddressMulti,
DestinationAddressSingleWithoutQRScan,
} from 'components/Common/EnhancedInputs';
import { CustomCollapseCtn } from 'components/Common/StyledCollapse';
import { Form, message, Modal, Alert, Input } from 'antd';
import { Row, Col, Switch } from 'antd';
import PrimaryButton, {
DisabledButton,
SmartButton,
} from 'components/Common/PrimaryButton';
import useBCH from 'hooks/useBCH';
import useWindowDimensions from 'hooks/useWindowDimensions';
import {
sendXecNotification,
errorNotification,
messageSignedNotification,
generalNotification,
} from 'components/Common/Notifications';
import { isMobile, isIOS, isSafari } from 'react-device-detect';
import { currency, parseAddressForParams } from 'components/Common/Ticker.js';
import CopyToClipboard from 'components/Common/CopyToClipboard';
import { Event } from 'utils/GoogleAnalytics';
import {
fiatToCrypto,
shouldRejectAmountInput,
isValidXecAddress,
isValidEtokenAddress,
isValidXecSendAmount,
} from 'utils/validation';
import BalanceHeader from 'components/Common/BalanceHeader';
import BalanceHeaderFiat from 'components/Common/BalanceHeaderFiat';
import {
ZeroBalanceHeader,
ConvertAmount,
AlertMsg,
WalletInfoCtn,
SidePaddingCtn,
FormLabel,
} from 'components/Common/Atoms';
import {
getWalletState,
convertToEcashPrefix,
toLegacyCash,
toLegacyCashArray,
fromSatoshisToXec,
} from 'utils/cashMethods';
import ApiError from 'components/Common/ApiError';
import { formatFiatBalance, formatBalance } from 'utils/formatting';
import {
TokenParamLabel,
MessageVerificationParamLabel,
} from 'components/Common/Atoms';
import { PlusSquareOutlined } from '@ant-design/icons';
import styled from 'styled-components';
import WalletLabel from 'components/Common/WalletLabel.js';
import { ThemedCopySolid } from 'components/Common/CustomIcons';
const { TextArea } = Input;
const SignMessageLabel = styled.div`
text-align: left;
color: ${props => props.theme.forms.text};
`;
const SignatureValidation = styled.div`
color: ${props => props.theme.encryptionRed};
`;
const VerifyMessageLabel = styled.div`
text-align: left;
color: ${props => props.theme.forms.text};
`;
const TextAreaLabel = styled.div`
text-align: left;
color: ${props => props.theme.forms.text};
padding-left: 1px;
`;
const AmountPreviewCtn = styled.div`
margin-top: -30px;
`;
const SendInputCtn = styled.div`
.ant-form-item-with-help {
margin-bottom: 32px;
}
`;
const LocaleFormattedValue = styled.h3`
color: ${props => props.theme.contrast};
font-weight: bold;
margin-bottom: 0;
`;
const AddressCopyCtn = styled.div`
display: flex;
align-items: center;
gap: 0.5rem;
svg {
height: 30px;
width: 30px;
&:hover {
fill: ${props => props.theme.eCashBlue};
cursor: pointer;
}
}
`;
// Note jestBCH is only used for unit tests; BCHJS must be mocked for jest
const SendBCH = ({ jestBCH, passLoadingStatus }) => {
// use balance parameters from wallet.state object and not legacy balances parameter from walletState, if user has migrated wallet
// this handles edge case of user with old wallet who has not opened latest Cashtab version yet
// If the wallet object from ContextValue has a `state key`, then check which keys are in the wallet object
// Else set it as blank
const ContextValue = React.useContext(WalletContext);
const location = useLocation();
const { BCH, wallet, fiatPrice, apiError, cashtabSettings, chronik } =
ContextValue;
const walletState = getWalletState(wallet);
const { balances, slpBalancesAndUtxos } = walletState;
// Modal settings
const [showConfirmMsgToSign, setShowConfirmMsgToSign] = useState(false);
const [msgToSign, setMsgToSign] = useState('');
const [signMessageIsValid, setSignMessageIsValid] = useState(null);
const [isOneToManyXECSend, setIsOneToManyXECSend] = useState(false);
const [opReturnMsg, setOpReturnMsg] = useState(false);
const [isEncryptedOptionalOpReturnMsg, setIsEncryptedOptionalOpReturnMsg] =
useState(false);
const [bchObj, setBchObj] = useState(false);
// Get device window width
// If this is less than 769, the page will open with QR scanner open
const { width } = useWindowDimensions();
// Load with QR code open if device is mobile and NOT iOS + anything but safari
const scannerSupported =
cashtabSettings &&
cashtabSettings.autoCameraOn === true &&
width < 769 &&
isMobile &&
!(isIOS && !isSafari);
const [formData, setFormData] = useState({
value: '',
address: '',
airdropTokenId: '',
});
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 [messageSignature, setMessageSignature] = useState('');
const [sigCopySuccess, setSigCopySuccess] = useState('');
const [showConfirmMsgToVerify, setShowConfirmMsgToVerify] = useState(false);
const [messageVerificationAddr, setMessageVerificationAddr] = useState('');
const [messageVerificationSig, setMessageVerificationSig] = useState('');
const [messageVerificationMsg, setMessageVerificationMsg] = useState('');
const [messageVerificationAddrIsValid, setMessageVerificationAddrIsValid] =
useState(false);
const [messageVerificationSigIsValid, setMessageVerificationSigIsValid] =
useState(false);
const [messageVerificationMsgIsValid, setMessageVerificationMsgIsValid] =
useState(false);
const [messageVerificationAddrError, setMessageVerificationAddrError] =
useState(false);
const [messageVerificationSigError, setMessageVerificationSigError] =
useState(false);
const [airdropFlag, setAirdropFlag] = useState(false);
const userLocale = navigator.language;
const clearInputForms = () => {
setFormData({
value: '',
address: '',
});
setOpReturnMsg(''); // OP_RETURN message has its own state field
};
const checkForConfirmationBeforeSendXec = () => {
if (txInfoFromUrl) {
setIsModalVisible(true);
} else if (cashtabSettings.sendModal) {
setIsModalVisible(cashtabSettings.sendModal);
} else {
// if the user does not have the send confirmation enabled in settings then send directly
send();
}
};
const handleOk = () => {
setIsModalVisible(false);
send();
};
const handleCancel = () => {
setIsModalVisible(false);
};
const { getRestUrl, sendXec, calcFee } = 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(() => {
passLoadingStatus(false);
}, [balances.totalBalance]);
useEffect(() => {
// jestBCH is only ever specified for unit tests, otherwise app will use getBCH();
const activeBCH = jestBCH ? jestBCH : BCH;
// set the BCH instance to state, for other functions to reference
setBchObj(activeBCH);
}, [BCH]);
useEffect(() => {
// Manually parse for txInfo object on page load when Send.js is loaded with a query string
// if this was routed from Wallet screen's Reply to message link then prepopulate the address and value field
if (location && location.state && location.state.replyAddress) {
setFormData({
address: location.state.replyAddress,
value: `${fromSatoshisToXec(currency.dustSats).toString()}`,
});
}
// if this was routed from the Contact List
if (location && location.state && location.state.contactSend) {
setFormData({
address: location.state.contactSend,
});
}
// if this was routed from the Airdrop screen's Airdrop Calculator then
// switch to multiple recipient mode and prepopulate the recipients field
if (
location &&
location.state &&
location.state.airdropRecipients &&
location.state.airdropTokenId
) {
setIsOneToManyXECSend(true);
setFormData({
address: location.state.airdropRecipients,
airdropTokenId: location.state.airdropTokenId,
});
// validate the airdrop outputs from the calculator
handleMultiAddressChange({
target: {
value: location.state.airdropRecipients,
},
});
setAirdropFlag(true);
}
// 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,
});
}
}
function handleSendXecError(errorObj, oneToManyFlag) {
// Set loading to false here as well, as balance may not change depending on where error occured in try loop
passLoadingStatus(false);
let message;
if (!errorObj.error && !errorObj.message) {
message = `Transaction failed: no response from ${getRestUrl()}.`;
} else if (
/Could not communicate with full node or other external service/.test(
errorObj.error,
)
) {
message = 'Could not communicate with API. Please try again.';
} else if (
errorObj.error &&
errorObj.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 =
errorObj.message || errorObj.error || JSON.stringify(errorObj);
}
if (oneToManyFlag) {
errorNotification(errorObj, message, 'Sending XEC one to many');
} else {
errorNotification(errorObj, message, 'Sending XEC');
}
}
async function send() {
setFormData({
...formData,
});
if (isOneToManyXECSend) {
// this is a one to many XEC send transactions
// ensure multi-recipient input is not blank
if (!formData.address) {
return;
}
// Event("Category", "Action", "Label")
// Track number of XEC send-to-many transactions
Event('Send.js', 'SendToMany', selectedCurrency);
passLoadingStatus(true);
const { address } = formData;
//convert each line from TextArea input
let addressAndValueArray = address.split('\n');
try {
// construct array of XEC->BCH addresses due to bch-api constraint
let cleanAddressAndValueArray =
toLegacyCashArray(addressAndValueArray);
const link = await sendXec(
bchObj,
chronik,
wallet,
slpBalancesAndUtxos.nonSlpUtxos,
currency.defaultFee,
opReturnMsg,
true, // indicate send mode is one to many
cleanAddressAndValueArray,
null,
null,
false, // one to many tx msg can't be encrypted
airdropFlag,
formData.airdropTokenId,
);
sendXecNotification(link);
clearInputForms();
setAirdropFlag(false);
} catch (e) {
handleSendXecError(e, isOneToManyXECSend);
}
} else {
// standard one to one XEC send transaction
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);
passLoadingStatus(true);
const { address, value } = formData;
// Get the param-free address
let cleanAddress = address.split('?')[0];
// Ensure address has bitcoincash: prefix and checksum
cleanAddress = toLegacyCash(cleanAddress);
// Calculate the amount in BCH
let bchValue = value;
if (selectedCurrency !== 'XEC') {
bchValue = fiatToCrypto(value, fiatPrice);
}
// encrypted message limit truncation
let optionalOpReturnMsg;
if (isEncryptedOptionalOpReturnMsg) {
optionalOpReturnMsg = opReturnMsg.substring(
0,
currency.opReturn.encryptedMsgCharLimit,
);
} else {
optionalOpReturnMsg = opReturnMsg;
}
try {
const link = await sendXec(
bchObj,
chronik,
wallet,
slpBalancesAndUtxos.nonSlpUtxos,
currency.defaultFee,
optionalOpReturnMsg,
false, // sendToMany boolean flag
null, // address array not applicable for one to many tx
cleanAddress,
bchValue,
isEncryptedOptionalOpReturnMsg,
);
sendXecNotification(link);
clearInputForms();
} catch (e) {
handleSendXecError(e, isOneToManyXECSend);
}
}
}
const handleAddressChange = e => {
const { value, name } = e.target;
let error = false;
let addressString = value;
// parse address for parameters
const addressInfo = parseAddressForParams(addressString);
// validate address
const isValid = isValidXecAddress(addressInfo.address);
/*
Model
addressInfo =
{
address: '',
queryString: '',
amount: null,
};
*/
const { address, 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 = `Invalid ${currency.ticker} address`;
// If valid address but token format
if (isValidEtokenAddress(address)) {
error = `eToken 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 handleMessageVerificationAddrChange = e => {
const { value } = e.target;
let error = false;
let addressString = value;
// parse address for parameters
const addressInfo = parseAddressForParams(addressString);
// validate address
const isValid = isValidXecAddress(addressInfo.address);
const { address } = addressInfo;
// Is this valid address?
if (!isValid) {
error = `Invalid ${currency.ticker} address`;
// If valid address but token format
if (isValidEtokenAddress(address)) {
error = `eToken addresses are not supported for signature verifications`;
}
setMessageVerificationAddrIsValid(false);
} else {
setMessageVerificationAddrIsValid(true);
}
setMessageVerificationAddrError(error);
setMessageVerificationAddr(address);
};
const handleMultiAddressChange = e => {
const { value, name } = e.target;
let error;
if (!value) {
error = 'Input must not be blank';
setSendBchAddressError(error);
return setFormData(p => ({
...p,
[name]: value,
}));
}
//convert each line from the <TextArea> input into array
let addressStringArray = value.split('\n');
const arrayLength = addressStringArray.length;
// loop through each row in the <TextArea> input
for (let i = 0; i < arrayLength; i++) {
if (addressStringArray[i].trim() === '') {
// if this line is a line break or bunch of spaces
error = 'Empty spaces and rows must be removed';
setSendBchAddressError(error);
return setFormData(p => ({
...p,
[name]: value,
}));
}
let addressString = addressStringArray[i].split(',')[0];
let valueString = addressStringArray[i].split(',')[1];
const validAddress = isValidXecAddress(addressString);
const validValueString = isValidXecSendAmount(valueString);
if (!validAddress) {
error = `Invalid XEC address: ${addressString}${
valueString !== undefined ? `, ${valueString}` : ''
}`;
setSendBchAddressError(error);
return setFormData(p => ({
...p,
[name]: value,
}));
}
if (!validValueString) {
error = `Amount must be at least ${fromSatoshisToXec(
currency.dustSats,
).toString()} XEC: ${addressString}, ${valueString}`;
setSendBchAddressError(error);
return setFormData(p => ({
...p,
[name]: value,
}));
}
}
// If iterate to end of array with no errors, then there is no error msg
setSendBchAddressError(false);
// 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 handleSignMsgChange = e => {
const { value } = e.target;
// validation
if (value && value.length && value.length < 150) {
setMsgToSign(value);
setSignMessageIsValid(true);
} else {
setSignMessageIsValid(false);
}
};
const handleVerifyMsgChange = e => {
const { value } = e.target;
// validation
if (value && value.length && value.length < 150) {
setMessageVerificationMsgIsValid(true);
} else {
setMessageVerificationMsgIsValid(false);
}
setMessageVerificationMsg(value);
};
const handleVerifySigChange = e => {
const { value } = e.target;
// validation
if (value && value.length && value.length === 88) {
setMessageVerificationSigIsValid(true);
setMessageVerificationSigError(false);
} else {
setMessageVerificationSigIsValid(false);
setMessageVerificationSigError('Invalid signature');
}
setMessageVerificationSig(value);
};
const verifyMessageBySig = async () => {
let verification;
try {
verification = await bchObj.BitcoinCash.verifyMessage(
toLegacyCash(messageVerificationAddr),
messageVerificationSig,
messageVerificationMsg,
);
} catch (err) {
errorNotification(
'Error',
'Unable to execute signature verification',
);
}
if (verification) {
generalNotification('Signature successfully verified', 'Verified');
} else {
errorNotification(
'Error',
'Signature does not match address and message',
);
}
setShowConfirmMsgToVerify(false);
};
const signMessageByPk = async () => {
try {
const messageSignature =
await BCH.BitcoinCash.signMessageWithPrivKey(
wallet.Path1899.fundingWif,
msgToSign,
);
setMessageSignature(messageSignature);
messageSignedNotification(messageSignature);
} catch (err) {
let message;
if (!err.error && !err.message) {
message = err.message || err.error || JSON.stringify(err);
}
errorNotification(err, message, 'Message Signing Error');
throw err;
}
// Hide the modal
setShowConfirmMsgToSign(false);
setSigCopySuccess('');
};
const handleOnSigCopy = () => {
if (messageSignature != '') {
setSigCopySuccess('Signature copied to clipboard');
}
};
const onMax = async () => {
// Clear amt error
setSendBchAmountError(false);
// Set currency to BCH
setSelectedCurrency(currency.ticker);
try {
- const txFeeSats = calcFee(bchObj, slpBalancesAndUtxos.nonSlpUtxos);
+ const txFeeSats = calcFee(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) {
// calculate conversion to fiatPrice
fiatPriceString = `${(fiatPrice * Number(formData.value)).toFixed(
2,
)}`;
// formats to fiat locale style
fiatPriceString = formatFiatBalance(
Number(fiatPriceString),
userLocale,
);
// insert symbol and currency before/after the locale formatted fiat balance
fiatPriceString = `${
cashtabSettings
? `${
currency.fiatCurrencies[cashtabSettings.fiatCurrency]
.symbol
} `
: '$ '
} ${fiatPriceString} ${
cashtabSettings && cashtabSettings.fiatCurrency
? cashtabSettings.fiatCurrency.toUpperCase()
: 'USD'
}`;
} else {
fiatPriceString = `${
formData.value
? formatFiatBalance(
Number(fiatToCrypto(formData.value, fiatPrice)),
userLocale,
)
: formatFiatBalance(0, userLocale)
} ${currency.ticker}`;
}
}
const priceApiError = fiatPrice === null && selectedCurrency !== 'XEC';
return (
<>
<Modal
title="Confirm Send"
open={isModalVisible}
onOk={handleOk}
onCancel={handleCancel}
>
<p>
{isOneToManyXECSend
? `are you sure you want to send the following One to Many transaction?
${formData.address}`
: `Are you sure you want to send ${formData.value}${' '}
${selectedCurrency} to ${formData.address}?`}
</p>
</Modal>
<WalletInfoCtn>
<WalletLabel name={wallet.name}></WalletLabel>
{!balances.totalBalance ? (
<ZeroBalanceHeader>
You currently have 0 {currency.ticker}
<br />
Deposit some funds to use this feature
</ZeroBalanceHeader>
) : (
<>
<BalanceHeader
balance={balances.totalBalance}
ticker={currency.ticker}
/>
<BalanceHeaderFiat
balance={balances.totalBalance}
settings={cashtabSettings}
fiatPrice={fiatPrice}
/>
</>
)}
</WalletInfoCtn>
<SidePaddingCtn>
<Row type="flex">
<Col span={24}>
<Form
style={{
width: 'auto',
marginTop: '40px',
}}
>
{!isOneToManyXECSend ? (
<SendInputCtn>
<FormLabel>Send to</FormLabel>
<DestinationAddressSingle
style={{ marginBottom: '0px' }}
loadWithCameraOpen={
location &&
location.state &&
location.state.replyAddress
? false
: scannerSupported
}
validateStatus={
sendBchAddressError ? 'error' : ''
}
help={
sendBchAddressError
? sendBchAddressError
: ''
}
onScan={result =>
handleAddressChange({
target: {
name: 'address',
value: result,
},
})
}
inputProps={{
placeholder: `${currency.ticker} Address`,
name: 'address',
onChange: e =>
handleAddressChange(e),
required: true,
value: formData.address,
}}
></DestinationAddressSingle>
<FormLabel>Amount</FormLabel>
<SendBchInput
activeFiatCode={
cashtabSettings &&
cashtabSettings.fiatCurrency
? cashtabSettings.fiatCurrency.toUpperCase()
: 'USD'
}
validateStatus={
sendBchAmountError ? 'error' : ''
}
help={
sendBchAmountError
? sendBchAmountError
: ''
}
onMax={onMax}
inputProps={{
name: 'value',
dollar:
selectedCurrency === 'USD'
? 1
: 0,
placeholder: 'Amount',
onChange: e =>
handleBchAmountChange(e),
required: true,
value: formData.value,
disabled: priceApiError,
}}
selectProps={{
value: selectedCurrency,
disabled: queryStringText !== null,
onChange: e =>
handleSelectedCurrencyChange(e),
}}
></SendBchInput>
{priceApiError && (
<AlertMsg>
Error fetching fiat price. Setting
send by{' '}
{currency.fiatCurrencies[
cashtabSettings.fiatCurrency
].slug.toUpperCase()}{' '}
disabled
</AlertMsg>
)}
</SendInputCtn>
) : (
<>
<FormLabel>Send to</FormLabel>
<DestinationAddressMulti
validateStatus={
sendBchAddressError ? 'error' : ''
}
help={
sendBchAddressError
? sendBchAddressError
: ''
}
inputProps={{
placeholder: `One XEC address & value per line, separated by comma \ne.g. \necash:qpatql05s9jfavnu0tv6lkjjk25n6tmj9gkpyrlwu8,500 \necash:qzvydd4n3lm3xv62cx078nu9rg0e3srmqq0knykfed,700`,
name: 'address',
onChange: e =>
handleMultiAddressChange(e),
required: true,
value: formData.address,
}}
></DestinationAddressMulti>
</>
)}
{!priceApiError && !isOneToManyXECSend && (
<AmountPreviewCtn>
<LocaleFormattedValue>
{formatBalance(
formData.value,
userLocale,
)}{' '}
{selectedCurrency}
</LocaleFormattedValue>
<ConvertAmount>
{fiatPriceString !== '' && '='}{' '}
{fiatPriceString}
</ConvertAmount>
</AmountPreviewCtn>
)}
{queryStringText && (
<Alert
message={`You are sending a transaction to an address including query parameters "${queryStringText}." Only the "amount" parameter, in units of ${currency.ticker} satoshis, is currently supported.`}
type="warning"
/>
)}
<div
style={{
paddingTop: '12px',
}}
>
{!balances.totalBalance ||
apiError ||
sendBchAmountError ||
sendBchAddressError ||
priceApiError ? (
<DisabledButton>Send</DisabledButton>
) : (
<>
{txInfoFromUrl ? (
<PrimaryButton
onClick={() =>
checkForConfirmationBeforeSendXec()
}
>
Send
</PrimaryButton>
) : (
<PrimaryButton
onClick={() => {
checkForConfirmationBeforeSendXec();
}}
>
Send
</PrimaryButton>
)}
</>
)}
</div>
<CustomCollapseCtn
panelHeader="Advanced"
optionalDefaultActiveKey={
location &&
location.state &&
location.state.replyAddress
? ['1']
: ['0']
}
optionalKey="1"
>
<AntdFormWrapper
style={{
marginBottom: '20px',
}}
>
<TextAreaLabel>
Multiple Recipients:&nbsp;&nbsp;
<Switch
defaultunchecked="true"
checked={isOneToManyXECSend}
onChange={() => {
setIsOneToManyXECSend(
!isOneToManyXECSend,
);
setIsEncryptedOptionalOpReturnMsg(
false,
);
}}
style={{
marginBottom: '7px',
}}
/>
</TextAreaLabel>
<TextAreaLabel>
Message:&nbsp;&nbsp;
<Switch
disabled={isOneToManyXECSend}
style={{
marginBottom: '7px',
}}
checkedChildren="Private"
unCheckedChildren="Public"
defaultunchecked="true"
checked={
isEncryptedOptionalOpReturnMsg
}
onChange={() => {
setIsEncryptedOptionalOpReturnMsg(
prev => !prev,
);
setIsOneToManyXECSend(false);
}}
/>
</TextAreaLabel>
{isEncryptedOptionalOpReturnMsg ? (
<Alert
style={{
marginBottom: '10px',
}}
description="Please note encrypted messages can only be sent to wallets with at least 1 outgoing transaction."
type="warning"
showIcon
/>
) : (
<Alert
style={{
marginBottom: '10px',
}}
description="Please note this message will be public."
type="warning"
showIcon
/>
)}
<TextArea
name="opReturnMsg"
placeholder={
isEncryptedOptionalOpReturnMsg
? `(max ${currency.opReturn.encryptedMsgCharLimit} characters)`
: `(max ${currency.opReturn.unencryptedMsgCharLimit} characters)`
}
value={
opReturnMsg
? isEncryptedOptionalOpReturnMsg
? opReturnMsg.substring(
0,
currency.opReturn
.encryptedMsgCharLimit +
1,
)
: opReturnMsg
: ''
}
onChange={e =>
setOpReturnMsg(e.target.value)
}
showCount
maxLength={
isEncryptedOptionalOpReturnMsg
? currency.opReturn
.encryptedMsgCharLimit
: currency.opReturn
.unencryptedMsgCharLimit
}
onKeyDown={e =>
e.keyCode == 13
? e.preventDefault()
: ''
}
/>
</AntdFormWrapper>
</CustomCollapseCtn>
{apiError && <ApiError />}
</Form>
</Col>
</Row>
<Modal
title={`Please review and confirm your message to be signed using this wallet.`}
open={showConfirmMsgToSign}
onOk={signMessageByPk}
onCancel={() => setShowConfirmMsgToSign(false)}
>
<TokenParamLabel>Message:</TokenParamLabel> {msgToSign}
<br />
</Modal>
<CustomCollapseCtn panelHeader="Sign Message">
<AntdFormWrapper>
<Form
size="small"
style={{
width: 'auto',
}}
>
<Form.Item>
<SignMessageLabel>Message:</SignMessageLabel>
<TextArea
name="signMessage"
onChange={e => handleSignMsgChange(e)}
showCount
maxLength={150}
/>
</Form.Item>
<Form.Item>
<SignMessageLabel>Address:</SignMessageLabel>
{wallet &&
wallet.Path1899 &&
wallet.Path1899.cashAddress && (
<AddressCopyCtn>
<Input
name="signMessageAddress"
disabled={true}
value={
wallet &&
wallet.Path1899 &&
wallet.Path1899.cashAddress
? convertToEcashPrefix(
wallet.Path1899
.cashAddress,
)
: ''
}
/>
<CopyToClipboard
data={convertToEcashPrefix(
wallet.Path1899.cashAddress,
)}
optionalOnCopyNotification={{
title: 'Copied',
msg: `${convertToEcashPrefix(
wallet.Path1899
.cashAddress,
)} copied to clipboard`,
}}
>
<ThemedCopySolid />
</CopyToClipboard>
</AddressCopyCtn>
)}
</Form.Item>
<SmartButton
onClick={() => setShowConfirmMsgToSign(true)}
disabled={!signMessageIsValid}
>
<PlusSquareOutlined />
&nbsp;Sign Message
</SmartButton>
<CopyToClipboard
data={messageSignature}
optionalOnCopyNotification={{
title: 'Message signature copied to clipboard',
msg: `${messageSignature}`,
}}
>
<Form.Item>
<SignMessageLabel>
Signature:
</SignMessageLabel>
<TextArea
name="signMessageSignature"
placeholder="The signature will be generated upon signing of the message"
readOnly={true}
value={messageSignature}
onClick={() => handleOnSigCopy()}
/>
</Form.Item>
</CopyToClipboard>
{sigCopySuccess}
</Form>
</AntdFormWrapper>
</CustomCollapseCtn>
<Modal
title={`Please review and confirm your message, signature and address to be verified.`}
open={showConfirmMsgToVerify}
onOk={verifyMessageBySig}
onCancel={() => setShowConfirmMsgToVerify(false)}
>
<MessageVerificationParamLabel>
Message:
</MessageVerificationParamLabel>{' '}
{messageVerificationMsg}
<br />
<MessageVerificationParamLabel>
Address:
</MessageVerificationParamLabel>{' '}
{messageVerificationAddr}
<br />
<MessageVerificationParamLabel>
Signature:
</MessageVerificationParamLabel>{' '}
{messageVerificationSig}
<br />
</Modal>
<CustomCollapseCtn panelHeader="Verify Message">
<AntdFormWrapper>
<Form
size="small"
style={{
width: 'auto',
}}
>
<Form.Item>
<VerifyMessageLabel>
Message:
</VerifyMessageLabel>
<TextArea
name="verifyMessage"
onChange={e => handleVerifyMsgChange(e)}
showCount
maxLength={150}
/>
</Form.Item>
<Form.Item>
<VerifyMessageLabel>
Address:
</VerifyMessageLabel>
<DestinationAddressSingleWithoutQRScan
validateStatus={
messageVerificationAddrError
? 'error'
: ''
}
help={
messageVerificationAddrError
? messageVerificationAddrError
: ''
}
inputProps={{
placeholder: `${currency.ticker} Address`,
name: 'address',
onChange: e =>
handleMessageVerificationAddrChange(
e,
),
required: true,
}}
></DestinationAddressSingleWithoutQRScan>
</Form.Item>
<Form.Item>
<VerifyMessageLabel>
Signature:
</VerifyMessageLabel>
<TextArea
name="verifySignature"
onChange={e => handleVerifySigChange(e)}
showCount
/>
<SignatureValidation>
{messageVerificationSigError}
</SignatureValidation>
</Form.Item>
<SmartButton
onClick={() => setShowConfirmMsgToVerify(true)}
disabled={
!messageVerificationAddrIsValid ||
!messageVerificationSigIsValid ||
!messageVerificationMsgIsValid
}
>
<PlusSquareOutlined />
&nbsp;Verify Message
</SmartButton>
</Form>
</AntdFormWrapper>
</CustomCollapseCtn>
</SidePaddingCtn>
</>
);
};
/*
passLoadingStatus must receive a default prop that is a function
in order to pass the rendering unit test in Send.test.js
status => {console.log(status)} is an arbitrary stub function
*/
SendBCH.defaultProps = {
passLoadingStatus: status => {
console.log(status);
},
};
SendBCH.propTypes = {
jestBCH: PropTypes.object,
passLoadingStatus: PropTypes.func,
};
export default SendBCH;
diff --git a/web/cashtab/src/hooks/__tests__/useBCH.test.js b/web/cashtab/src/hooks/__tests__/useBCH.test.js
index 4f0fff340..9b20bf164 100644
--- a/web/cashtab/src/hooks/__tests__/useBCH.test.js
+++ b/web/cashtab/src/hooks/__tests__/useBCH.test.js
@@ -1,394 +1,393 @@
/* eslint-disable no-native-reassign */
import useBCH from '../useBCH';
import sendBCHMock from '../__mocks__/sendBCH';
import createTokenMock from '../__mocks__/createToken';
import { validStoredWallet } from '../../utils/__mocks__/mockStoredWallets';
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';
import { fromSatoshisToXec } from 'utils/cashMethods';
import { ChronikClient } from 'chronik-client'; // for mocking purposes
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);
+ expect(calcFee(utxosMock, 2, 1.01)).toBe(378);
});
it('sends XEC correctly', async () => {
const { sendXec } = useBCH();
const BCH = new BCHJS();
const chronik = new ChronikClient(
'https://FakeChronikUrlToEnsureMocksOnly.com',
);
const { expectedTxId, utxos, wallet, destinationAddress, sendAmount } =
sendBCHMock;
chronik.broadcastTx = jest
.fn()
.mockResolvedValue({ txid: expectedTxId });
expect(
await sendXec(
BCH,
chronik,
wallet,
utxos,
currency.defaultFee,
'',
false,
null,
destinationAddress,
sendAmount,
),
).toBe(`${currency.blockExplorerUrl}/tx/${expectedTxId}`);
});
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 expectedPubKey =
'03451a3e61ae8eb76b8d4cd6057e4ebaf3ef63ae3fe5f441b72c743b5810b6a389';
chronik.broadcastTx = jest
.fn()
.mockResolvedValue({ txid: expectedTxId });
expect(
await sendXec(
BCH,
chronik,
wallet,
utxos,
currency.defaultFee,
'This is an encrypted opreturn message',
false,
null,
destinationAddress,
sendAmount,
true, // encryption flag for the OP_RETURN message
false, // airdrop flag
'', // airdrop token id
expectedPubKey, //optionalMockPubKeyResponse
),
).toBe(`${currency.blockExplorerUrl}/tx/${expectedTxId}`);
});
it('sends one to many XEC correctly', async () => {
const { sendXec } = useBCH();
const BCH = new BCHJS();
const chronik = new ChronikClient(
'https://FakeChronikUrlToEnsureMocksOnly.com',
);
const { expectedTxId, utxos, wallet } = sendBCHMock;
const addressAndValueArray = [
'bitcoincash:qrzuvj0vvnsz5949h4axercl5k420eygavv0awgz05,6',
'bitcoincash:qrzuvj0vvnsz5949h4axercl5k420eygavv0awgz05,6.8',
'bitcoincash:qrzuvj0vvnsz5949h4axercl5k420eygavv0awgz05,7',
'bitcoincash:qrzuvj0vvnsz5949h4axercl5k420eygavv0awgz05,6',
];
chronik.broadcastTx = jest
.fn()
.mockResolvedValue({ txid: expectedTxId });
expect(
await sendXec(
BCH,
chronik,
wallet,
utxos,
currency.defaultFee,
'',
true,
addressAndValueArray,
),
).toBe(`${currency.blockExplorerUrl}/tx/${expectedTxId}`);
});
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 { utxos, wallet, destinationAddress } = sendBCHMock;
const expectedTxFeeInSats = 229;
// 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();
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,
'',
false,
null,
destinationAddress,
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 { utxos, wallet, destinationAddress } = sendBCHMock;
const failedSendBch = sendXec(
BCH,
chronik,
wallet,
utxos,
currency.defaultFee,
'',
false,
null,
destinationAddress,
new BigNumber(fromSatoshisToXec(currency.dustSats).toString())
.minus(new BigNumber('0.00000001'))
.toString(),
);
expect(failedSendBch).rejects.toThrow(new Error('dust'));
});
it("Throws error attempting to burn an eToken ID that is not within the wallet's utxo", async () => {
const { burnToken } = useBCH();
const BCH = new BCHJS();
const wallet = validStoredWallet;
const burnAmount = 10;
const eTokenId = '0203c768a66eba24affNOTVALID103b772de4d9f8f63ba79e';
const expectedError =
'No token UTXOs for the specified token could be found.';
const chronik = new ChronikClient(
'https://FakeChronikUrlToEnsureMocksOnly.com',
);
let thrownError;
try {
await burnToken(BCH, chronik, wallet, {
tokenId: eTokenId,
amount: burnAmount,
});
} catch (err) {
thrownError = err;
}
expect(thrownError).toStrictEqual(new Error(expectedError));
});
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;
chronik.broadcastTx = jest.fn().mockImplementation(async () => {
throw new Error('insufficient priority (code 66)');
});
const insufficientPriority = sendXec(
BCH,
chronik,
wallet,
utxos,
currency.defaultFee,
'',
false,
null,
destinationAddress,
sendAmount,
);
await expect(insufficientPriority).rejects.toThrow(
new Error('insufficient priority (code 66)'),
);
chronik.broadcastTx = jest.fn().mockImplementation(async () => {
throw new Error('txn-mempool-conflict (code 18)');
});
const txnMempoolConflict = sendXec(
BCH,
chronik,
wallet,
utxos,
currency.defaultFee,
'',
false,
null,
destinationAddress,
sendAmount,
);
await expect(txnMempoolConflict).rejects.toThrow(
new Error('txn-mempool-conflict (code 18)'),
);
chronik.broadcastTx = jest.fn().mockImplementation(async () => {
throw new Error('Network Error');
});
const networkError = sendXec(
BCH,
chronik,
wallet,
utxos,
currency.defaultFee,
'',
false,
null,
destinationAddress,
sendAmount,
);
await expect(networkError).rejects.toThrow(new Error('Network Error'));
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,
'',
false,
null,
destinationAddress,
sendAmount,
);
await expect(tooManyAncestorsMempool).rejects.toThrow(
new Error(
'too-long-mempool-chain, too many unconfirmed ancestors [limit: 25] (code 64)',
),
);
});
it('creates a token correctly', async () => {
const { createToken } = useBCH();
const BCH = new BCHJS();
const { expectedTxId, expectedHex, wallet, configObj } =
createTokenMock;
const chronik = new ChronikClient(
'https://FakeChronikUrlToEnsureMocksOnly.com',
);
chronik.broadcastTx = jest
.fn()
.mockResolvedValue({ txid: expectedTxId });
expect(await createToken(BCH, chronik, wallet, 5.01, configObj)).toBe(
`${currency.blockExplorerUrl}/tx/${expectedTxId}`,
);
});
it('Throws correct error if user attempts to create a token with an invalid wallet', async () => {
const { createToken } = useBCH();
const BCH = new BCHJS();
const { invalidWallet, configObj } = createTokenMock;
const chronik = new ChronikClient(
'https://FakeChronikUrlToEnsureMocksOnly.com',
);
const invalidWalletTokenCreation = createToken(
BCH,
chronik,
invalidWallet,
currency.defaultFee,
configObj,
);
await expect(invalidWalletTokenCreation).rejects.toThrow(
new Error('Invalid wallet'),
);
});
it(`getRecipientPublicKey() correctly retrieves the public key of a cash address`, async () => {
const { getRecipientPublicKey } = useBCH();
const BCH = new BCHJS();
const chronik = new ChronikClient(
'https://FakeChronikUrlToEnsureMocksOnly.com',
);
const expectedPubKey =
'03208c4f52229e021ddec5fc6e07a59fd66388ac52bc2a2c1e0f1afb24b0e275ac';
const destinationAddress =
'bitcoincash:qqvuj09f80sw9j7qru84ptxf0hyqffc38gstxfs5ru';
expect(
await getRecipientPublicKey(
BCH,
chronik,
destinationAddress,
expectedPubKey,
),
).toBe(expectedPubKey);
});
});
diff --git a/web/cashtab/src/hooks/useBCH.js b/web/cashtab/src/hooks/useBCH.js
index 995a452bc..e0001fed6 100644
--- a/web/cashtab/src/hooks/useBCH.js
+++ b/web/cashtab/src/hooks/useBCH.js
@@ -1,571 +1,568 @@
import { currency } from 'components/Common/Ticker';
import SlpWallet from 'minimal-slp-wallet';
import {
fromXecToSatoshis,
isValidStoredWallet,
parseXecSendValue,
generateOpReturnScript,
generateTxInput,
generateTxOutput,
generateTokenTxInput,
generateTokenTxOutput,
signAndBuildTx,
getChangeAddressFromInputUtxos,
+ getCashtabByteCount,
} from 'utils/cashMethods';
import ecies from 'ecies-lite';
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 calcFee = (
- BCH,
utxos,
p2pkhOutputNumber = 2,
satoshisPerByte = currency.defaultFee,
) => {
- const byteCount = BCH.BitcoinCash.getByteCount(
- { P2PKH: utxos.length },
- { P2PKH: p2pkhOutputNumber },
- );
+ const byteCount = getCashtabByteCount(utxos.length, p2pkhOutputNumber);
const txFee = Math.ceil(satoshisPerByte * byteCount);
return txFee;
};
const createToken = async (
BCH,
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.slpBalancesAndUtxos.nonSlpUtxos;
const CREATION_ADDR = wallet.Path1899.cashAddress;
let txBuilder = new BCH.TransactionBuilder();
let tokenTxInputObj = generateTokenTxInput(
BCH,
'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(
BCH,
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(
BCH,
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_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 sendToken = async (
BCH,
chronik,
wallet,
{ tokenId, amount, tokenReceiverAddress },
) => {
const slpBalancesAndUtxos = wallet.state.slpBalancesAndUtxos;
const xecUtxos = slpBalancesAndUtxos.nonSlpUtxos;
const tokenUtxos = slpBalancesAndUtxos.slpUtxos;
const CREATION_ADDR = wallet.Path1899.cashAddress;
// Handle error of user having no XEC
if (
!slpBalancesAndUtxos ||
!slpBalancesAndUtxos.nonSlpUtxos ||
slpBalancesAndUtxos.nonSlpUtxos.length === 0
) {
throw new Error(
`You need some ${currency.ticker} to send ${currency.tokenTicker}`,
);
}
// instance of transaction builder
let txBuilder = new BCH.TransactionBuilder();
let tokenTxInputObj = generateTokenTxInput(
BCH,
'SEND',
xecUtxos,
tokenUtxos,
tokenId,
amount,
currency.defaultFee,
txBuilder,
);
// update txBuilder object with inputs
txBuilder = tokenTxInputObj.txBuilder;
let tokenTxOutputObj = generateTokenTxOutput(
BCH,
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(
BCH,
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 (BCH, chronik, wallet, { tokenId, amount }) => {
const slpBalancesAndUtxos = wallet.state.slpBalancesAndUtxos;
const xecUtxos = slpBalancesAndUtxos.nonSlpUtxos;
const tokenUtxos = slpBalancesAndUtxos.slpUtxos;
const CREATION_ADDR = wallet.Path1899.cashAddress;
// Handle error of user having no XEC
if (
!slpBalancesAndUtxos ||
!slpBalancesAndUtxos.nonSlpUtxos ||
slpBalancesAndUtxos.nonSlpUtxos.length === 0
) {
throw new Error(`You need some ${currency.ticker} to burn eTokens`);
}
// instance of transaction builder
let txBuilder = new BCH.TransactionBuilder();
let tokenTxInputObj = generateTokenTxInput(
BCH,
'BURN',
xecUtxos,
tokenUtxos,
tokenId,
amount,
currency.defaultFee,
txBuilder,
);
// update txBuilder object with inputs
txBuilder = tokenTxInputObj.txBuilder;
let tokenTxOutputObj = generateTokenTxOutput(
BCH,
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(
BCH,
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 (
BCH,
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 = BCH.Address.toHash160(recipientAddress);
} catch (err) {
console.log(
`Error determining BCH.Address.toHash160(${recipientAddress} in getRecipientPublicKey())`,
err,
);
throw new Error(
`Error determining BCH.Address.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 (
BCH,
chronik,
wallet,
utxos,
feeInSatsPerByte,
optionalOpReturnMsg,
isOneToMany,
destinationAddressAndValueArray,
destinationAddress,
sendAmount,
encryptionFlag,
airdropFlag,
airdropTokenId,
optionalMockPubKeyResponse = false,
) => {
try {
let txBuilder = new BCH.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(
BCH,
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,
);
// 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(
BCH,
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(
BCH,
isOneToMany,
utxos,
txBuilder,
destinationAddressAndValueArray,
satoshisToSend,
feeInSatsPerByte,
);
const changeAddress = getChangeAddressFromInputUtxos(
BCH,
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(
BCH,
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(
BCH,
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_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,
getRestUrl,
sendXec,
sendToken,
createToken,
getRecipientPublicKey,
burnToken,
};
}
diff --git a/web/cashtab/src/utils/cashMethods.js b/web/cashtab/src/utils/cashMethods.js
index e22dadcf2..883825547 100644
--- a/web/cashtab/src/utils/cashMethods.js
+++ b/web/cashtab/src/utils/cashMethods.js
@@ -1,1072 +1,1067 @@
import { currency } from 'components/Common/Ticker';
import {
isValidXecAddress,
isValidEtokenAddress,
isValidContactList,
} from 'utils/validation';
import BigNumber from 'bignumber.js';
import cashaddr from 'ecashaddrjs';
import useBCH from '../hooks/useBCH';
export const getUtxoWif = (utxo, wallet) => {
if (!wallet) {
throw new Error('Invalid wallet parameter');
}
const accounts = [wallet.Path245, wallet.Path145, wallet.Path1899];
const wif = accounts
.filter(acc => acc.cashAddress === utxo.address)
.pop().fundingWif;
return wif;
};
export const signUtxosByAddress = (BCH, inputUtxos, wallet, txBuilder) => {
for (let i = 0; i < inputUtxos.length; i++) {
const utxo = inputUtxos[i];
const accounts = [wallet.Path245, wallet.Path145, wallet.Path1899];
const utxoEcPair = BCH.ECPair.fromWIF(
accounts.filter(acc => acc.cashAddress === utxo.address).pop()
.fundingWif,
);
txBuilder.sign(
i,
utxoEcPair,
undefined,
txBuilder.hashTypes.SIGHASH_ALL,
parseInt(utxo.value),
);
}
return txBuilder;
};
export const generateTokenTxOutput = (
BCH,
txBuilder,
tokenAction,
legacyCashOriginAddress,
tokenUtxosBeingSpent = [], // optional - send or burn tx only
remainderXecValue = new BigNumber(0), // optional - only if > dust
tokenConfigObj = {}, // optional - genesis only
tokenRecipientAddress = false, // optional - send tx only
tokenAmount = false, // optional - send or burn amount for send/burn tx only
) => {
try {
if (!BCH || !tokenAction || !legacyCashOriginAddress || !txBuilder) {
throw new Error('Invalid token tx output parameter');
}
let script, opReturnObj, destinationAddress;
switch (tokenAction) {
case 'GENESIS':
script =
BCH.SLP.TokenType1.generateGenesisOpReturn(tokenConfigObj);
destinationAddress = legacyCashOriginAddress;
break;
case 'SEND':
opReturnObj = BCH.SLP.TokenType1.generateSendOpReturn(
tokenUtxosBeingSpent,
tokenAmount.toString(),
);
script = opReturnObj.script;
destinationAddress = BCH.SLP.Address.toLegacyAddress(
tokenRecipientAddress,
);
break;
case 'BURN':
script = BCH.SLP.TokenType1.generateBurnOpReturn(
tokenUtxosBeingSpent,
tokenAmount,
);
destinationAddress = BCH.SLP.Address.toLegacyAddress(
legacyCashOriginAddress,
);
break;
default:
throw new Error('Invalid token transaction type');
}
// OP_RETURN needs to be the first output in the transaction.
txBuilder.addOutput(script, 0);
// add XEC dust output as fee for genesis, send or burn token output
txBuilder.addOutput(destinationAddress, parseInt(currency.etokenSats));
// Return any token change back to the sender for send and burn txs
if (
tokenAction !== 'GENESIS' ||
(opReturnObj && opReturnObj.outputs > 1)
) {
// add XEC dust output as fee
txBuilder.addOutput(
tokenUtxosBeingSpent[0].address, // etoken address
parseInt(currency.etokenSats),
);
}
// Send xec change to own address
if (remainderXecValue.gte(new BigNumber(currency.dustSats))) {
txBuilder.addOutput(
legacyCashOriginAddress,
parseInt(remainderXecValue),
);
}
} catch (err) {
console.log(`generateTokenTxOutput() error: ` + err);
throw err;
}
return txBuilder;
};
export const generateTxInput = (
BCH,
isOneToMany,
utxos,
txBuilder,
destinationAddressAndValueArray,
satoshisToSend,
feeInSatsPerByte,
) => {
const { calcFee } = useBCH();
let txInputObj = {};
const inputUtxos = [];
let txFee = 0;
let totalInputUtxoValue = new BigNumber(0);
try {
if (
!BCH ||
(isOneToMany && !destinationAddressAndValueArray) ||
!utxos ||
!txBuilder ||
!satoshisToSend ||
!feeInSatsPerByte
) {
throw new Error('Invalid tx input parameter');
}
// 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];
totalInputUtxoValue = totalInputUtxoValue.plus(utxo.value);
const vout = utxo.outpoint.outIdx;
const txid = utxo.outpoint.txid;
// add input with txid and index of vout
txBuilder.addInput(txid, vout);
inputUtxos.push(utxo);
- txFee = calcFee(BCH, inputUtxos, txOutputs, feeInSatsPerByte);
+ txFee = calcFee(inputUtxos, txOutputs, feeInSatsPerByte);
if (totalInputUtxoValue.minus(satoshisToSend).minus(txFee).gte(0)) {
break;
}
}
} catch (err) {
console.log(`generateTxInput() error: ` + err);
throw err;
}
txInputObj.txBuilder = txBuilder;
txInputObj.totalInputUtxoValue = totalInputUtxoValue;
txInputObj.inputUtxos = inputUtxos;
txInputObj.txFee = txFee;
return txInputObj;
};
export const generateTokenTxInput = (
BCH,
tokenAction, // GENESIS, SEND or BURN
totalXecUtxos,
totalTokenUtxos,
tokenId,
tokenAmount, // optional - only for sending or burning
feeInSatsPerByte,
txBuilder,
) => {
let totalXecInputUtxoValue = new BigNumber(0);
let remainderXecValue = new BigNumber(0);
let remainderTokenValue = new BigNumber(0);
let totalXecInputUtxos = [];
let txFee = 0;
const { calcFee } = useBCH();
let tokenUtxosBeingSpent = [];
try {
if (
!BCH ||
!tokenAction ||
!totalXecUtxos ||
(tokenAction !== 'GENESIS' && !tokenId) ||
!feeInSatsPerByte ||
!txBuilder
) {
throw new Error('Invalid token tx input parameter');
}
// collate XEC UTXOs for this token tx
const txOutputs =
tokenAction === 'GENESIS'
? 2 // one for genesis OP_RETURN output and one for change
: 4; // for SEND/BURN token txs see T2645 on why this is not dynamically generated
for (let i = 0; i < totalXecUtxos.length; i++) {
const thisXecUtxo = totalXecUtxos[i];
totalXecInputUtxoValue = totalXecInputUtxoValue.plus(
new BigNumber(thisXecUtxo.value),
);
const vout = thisXecUtxo.outpoint.outIdx;
const txid = thisXecUtxo.outpoint.txid;
// add input with txid and index of vout
txBuilder.addInput(txid, vout);
totalXecInputUtxos.push(thisXecUtxo);
- txFee = calcFee(
- BCH,
- totalXecInputUtxos,
- txOutputs,
- feeInSatsPerByte,
- );
+ txFee = calcFee(totalXecInputUtxos, txOutputs, feeInSatsPerByte);
remainderXecValue =
tokenAction === 'GENESIS'
? totalXecInputUtxoValue
.minus(new BigNumber(currency.etokenSats))
.minus(new BigNumber(txFee))
: totalXecInputUtxoValue
.minus(new BigNumber(currency.etokenSats * 2)) // one for token send/burn output, one for token change
.minus(new BigNumber(txFee));
if (remainderXecValue.gte(0)) {
break;
}
}
if (remainderXecValue.lt(0)) {
throw new Error(`Insufficient funds`);
}
let filteredTokenInputUtxos = [];
let finalTokenAmountSpent = new BigNumber(0);
let tokenAmountBeingSpent = new BigNumber(tokenAmount);
if (tokenAction === 'SEND' || tokenAction === 'BURN') {
// filter for token UTXOs matching the token being sent/burnt
filteredTokenInputUtxos = totalTokenUtxos.filter(utxo => {
if (
utxo && // UTXO is associated with a token.
utxo.slpMeta.tokenId === tokenId && // UTXO matches the token ID.
!utxo.slpToken.isMintBaton // UTXO is not a minting baton.
) {
return true;
}
return false;
});
if (filteredTokenInputUtxos.length === 0) {
throw new Error(
'No token UTXOs for the specified token could be found.',
);
}
// collate token UTXOs to cover the token amount being sent/burnt
for (let i = 0; i < filteredTokenInputUtxos.length; i++) {
finalTokenAmountSpent = finalTokenAmountSpent.plus(
new BigNumber(filteredTokenInputUtxos[i].tokenQty),
);
txBuilder.addInput(
filteredTokenInputUtxos[i].outpoint.txid,
filteredTokenInputUtxos[i].outpoint.outIdx,
);
tokenUtxosBeingSpent.push(filteredTokenInputUtxos[i]);
if (tokenAmountBeingSpent.lte(finalTokenAmountSpent)) {
break;
}
}
// calculate token change
remainderTokenValue = finalTokenAmountSpent.minus(
new BigNumber(tokenAmount),
);
if (remainderTokenValue.lt(0)) {
throw new Error(
'Insufficient token UTXOs for the specified token amount.',
);
}
}
} catch (err) {
console.log(`generateTokenTxInput() error: ` + err);
throw err;
}
return {
txBuilder: txBuilder,
inputXecUtxos: totalXecInputUtxos,
inputTokenUtxos: tokenUtxosBeingSpent,
remainderXecValue: remainderXecValue,
remainderTokenValue: remainderTokenValue,
};
};
export const getChangeAddressFromInputUtxos = (BCH, inputUtxos, wallet) => {
if (!BCH || !inputUtxos || !wallet) {
throw new Error('Invalid getChangeAddressFromWallet input parameter');
}
// Assume change address is input address of utxo at index 0
let changeAddress;
// Validate address
try {
changeAddress = inputUtxos[0].address;
BCH.Address.isCashAddress(changeAddress);
} catch (err) {
throw new Error('Invalid input utxo');
}
return changeAddress;
};
/*
* Parse the total value of a send XEC tx and checks whether it is more than dust
* One to many: isOneToMany is true, singleSendValue is null
* One to one: isOneToMany is false, destinationAddressAndValueArray is null
* Returns the aggregate send value in BigNumber format
*/
export const parseXecSendValue = (
isOneToMany,
singleSendValue,
destinationAddressAndValueArray,
) => {
let value = new BigNumber(0);
try {
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
// each array row is: 'eCash address, send value'
value = BigNumber.sum(
value,
new BigNumber(
destinationAddressAndValueArray[i].split(',')[1],
),
);
}
} else {
// this is a one to one XEC transaction then check singleSendValue
// note: one to many transactions won't be sending a singleSendValue param
if (!singleSendValue) {
throw new Error('Invalid singleSendValue');
}
value = new BigNumber(singleSendValue);
}
// 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');
}
} catch (err) {
console.log('Error in parseXecSendValue: ' + err);
throw err;
}
return value;
};
/*
* Generates an OP_RETURN script to reflect the various send XEC permutations
* involving messaging, encryption, eToken IDs and airdrop flags.
*
* Returns the final encoded script object
*/
export const generateOpReturnScript = (
BCH,
optionalOpReturnMsg,
encryptionFlag,
airdropFlag,
airdropTokenId,
encryptedEj,
) => {
// encrypted mesage is mandatory when encryptionFlag is true
// airdrop token id is mandatory when airdropFlag is true
if (
!BCH ||
(encryptionFlag && !encryptedEj) ||
(airdropFlag && !airdropTokenId)
) {
throw new Error('Invalid OP RETURN script input');
}
// Note: script.push(Buffer.from(currency.opReturn.opReturnPrefixHex, 'hex')); actually evaluates to '016a'
// instead of keeping the hex string intact. This behavour is specific to the initial script array element.
// To get around this, the bch-js approach of directly using the opReturn prefix in decimal form for the initial entry is used here.
let script = [currency.opReturn.opReturnPrefixDec]; // initialize script with the OP_RETURN op code (6a) in decimal form (106)
try {
if (encryptionFlag) {
// if the user has opted to encrypt this message
// add the encrypted cashtab messaging prefix and encrypted msg to script
script.push(
Buffer.from(
currency.opReturn.appPrefixesHex.cashtabEncrypted,
'hex',
), // 65746162
);
// add the encrypted message to script
script.push(Buffer.from(encryptedEj));
} else {
// this is an un-encrypted message
if (airdropFlag) {
// if this was routed from the airdrop component
// add the airdrop prefix to script
script.push(
Buffer.from(
currency.opReturn.appPrefixesHex.airdrop,
'hex',
), // drop
);
// add the airdrop token ID to script
script.push(Buffer.from(airdropTokenId, 'hex'));
}
// add the cashtab prefix to script
script.push(
Buffer.from(currency.opReturn.appPrefixesHex.cashtab, 'hex'), // 00746162
);
// add the un-encrypted message to script if supplied
if (optionalOpReturnMsg) {
script.push(Buffer.from(optionalOpReturnMsg));
}
}
} catch (err) {
console.log('Error in generateOpReturnScript(): ' + err);
throw err;
}
const data = BCH.Script.encode(script);
return data;
};
export const generateTxOutput = (
BCH,
isOneToMany,
singleSendValue,
satoshisToSend,
totalInputUtxoValue,
destinationAddress,
destinationAddressAndValueArray,
changeAddress,
txFee,
txBuilder,
) => {
try {
if (
!BCH ||
(isOneToMany && !destinationAddressAndValueArray) ||
(!isOneToMany && !destinationAddress && !singleSendValue) ||
!changeAddress ||
!satoshisToSend ||
!totalInputUtxoValue ||
!txFee ||
!txBuilder
) {
throw new Error('Invalid tx input parameter');
}
// amount to send back to the remainder address.
const remainder = new BigNumber(totalInputUtxoValue)
.minus(satoshisToSend)
.minus(txFee);
if (remainder.lt(0)) {
throw new Error(`Insufficient funds`);
}
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],
);
txBuilder.addOutput(
BCH.Address.toCashAddress(outputAddress),
parseInt(fromXecToSatoshis(outputValue)),
);
}
} else {
// for one to one mode, add output w/ single address and amount to send
txBuilder.addOutput(
BCH.Address.toCashAddress(destinationAddress),
parseInt(fromXecToSatoshis(singleSendValue)),
);
}
// if a remainder exists, return to change address as the final output
if (remainder.gte(new BigNumber(currency.dustSats))) {
txBuilder.addOutput(changeAddress, parseInt(remainder));
}
} catch (err) {
console.log('Error in generateTxOutput(): ' + err);
throw err;
}
return txBuilder;
};
export const signAndBuildTx = (BCH, inputUtxos, txBuilder, wallet) => {
if (
!BCH ||
!inputUtxos ||
inputUtxos.length === 0 ||
!txBuilder ||
!wallet ||
// txBuilder.transaction.tx.ins is empty until the inputUtxos are signed
txBuilder.transaction.tx.outs.length === 0
) {
throw new Error('Invalid buildTx parameter');
}
// Sign each XEC UTXO being consumed and refresh transactionBuilder
txBuilder = signUtxosByAddress(BCH, inputUtxos, wallet, txBuilder);
let hex;
try {
// build tx
const tx = txBuilder.build();
// output rawhex
hex = tx.toHex();
} catch (err) {
throw new Error('Transaction build failed');
}
return hex;
};
export function parseOpReturn(hexStr) {
if (
!hexStr ||
typeof hexStr !== 'string' ||
hexStr.substring(0, 2) !== currency.opReturn.opReturnPrefixHex
) {
return false;
}
hexStr = hexStr.slice(2); // remove the first byte i.e. 6a
/*
* @Return: resultArray is structured as follows:
* resultArray[0] is the transaction type i.e. eToken prefix, cashtab prefix, external message itself if unrecognized prefix
* resultArray[1] is the actual cashtab message or the 2nd part of an external message
* resultArray[2 - n] are the additional messages for future protcols
*/
let resultArray = [];
let message = '';
let hexStrLength = hexStr.length;
for (let i = 0; hexStrLength !== 0; i++) {
// part 1: check the preceding byte value for the subsequent message
let byteValue = hexStr.substring(0, 2);
let msgByteSize = 0;
if (byteValue === currency.opReturn.opPushDataOne) {
// if this byte is 4c then the next byte is the message byte size - retrieve the message byte size only
msgByteSize = parseInt(hexStr.substring(2, 4), 16); // hex base 16 to decimal base 10
hexStr = hexStr.slice(4); // strip the 4c + message byte size info
} else {
// take the byte as the message byte size
msgByteSize = parseInt(hexStr.substring(0, 2), 16); // hex base 16 to decimal base 10
hexStr = hexStr.slice(2); // strip the message byte size info
}
// part 2: parse the subsequent message based on bytesize
const msgCharLength = 2 * msgByteSize;
message = hexStr.substring(0, msgCharLength);
if (i === 0 && message === currency.opReturn.appPrefixesHex.eToken) {
// add the extracted eToken prefix to array then exit loop
resultArray[i] = currency.opReturn.appPrefixesHex.eToken;
break;
} else if (
i === 0 &&
message === currency.opReturn.appPrefixesHex.cashtab
) {
// add the extracted Cashtab prefix to array
resultArray[i] = currency.opReturn.appPrefixesHex.cashtab;
} else if (
i === 0 &&
message === currency.opReturn.appPrefixesHex.cashtabEncrypted
) {
// add the Cashtab encryption prefix to array
resultArray[i] = currency.opReturn.appPrefixesHex.cashtabEncrypted;
} else if (
i === 0 &&
message === currency.opReturn.appPrefixesHex.airdrop
) {
// add the airdrop prefix to array
resultArray[i] = currency.opReturn.appPrefixesHex.airdrop;
} else {
// this is either an external message or a subsequent cashtab message loop to extract the message
resultArray[i] = message;
}
// strip out the parsed message
hexStr = hexStr.slice(msgCharLength);
hexStrLength = hexStr.length;
}
return resultArray;
}
export const fromLegacyDecimals = (
amount,
cashDecimals = currency.cashDecimals,
) => {
// Input 0.00000546 BCH
// Output 5.46 XEC or 0.00000546 BCH, depending on currency.cashDecimals
const amountBig = new BigNumber(amount);
const conversionFactor = new BigNumber(10 ** (8 - cashDecimals));
const amountSmallestDenomination = amountBig
.times(conversionFactor)
.toNumber();
return amountSmallestDenomination;
};
export const fromSatoshisToXec = (
amount,
cashDecimals = currency.cashDecimals,
) => {
const amountBig = new BigNumber(amount);
const multiplier = new BigNumber(10 ** (-1 * cashDecimals));
const amountInBaseUnits = amountBig.times(multiplier);
return amountInBaseUnits;
};
export const fromXecToSatoshis = (
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;
};
export const flattenContactList = contactList => {
/*
Converts contactList from array of objects of type {address: <valid XEC address>, name: <string>} to array of addresses only
If contact list is invalid, returns and empty array
*/
if (!isValidContactList(contactList)) {
return [];
}
let flattenedContactList = [];
for (let i = 0; i < contactList.length; i += 1) {
const thisAddress = contactList[i].address;
flattenedContactList.push(thisAddress);
}
return flattenedContactList;
};
export const loadStoredWallet = walletStateFromStorage => {
// Accept cached tokens array that does not save BigNumber type of BigNumbers
// Return array with BigNumbers converted
// See BigNumber.js api for how to create a BigNumber object from an object
// https://mikemcl.github.io/bignumber.js/
const liveWalletState = walletStateFromStorage;
const { slpBalancesAndUtxos, tokens } = liveWalletState;
for (let i = 0; i < tokens.length; i += 1) {
const thisTokenBalance = tokens[i].balance;
thisTokenBalance._isBigNumber = true;
tokens[i].balance = new BigNumber(thisTokenBalance);
}
// Also confirm balance is correct
// Necessary step in case currency.decimals changed since last startup
const balancesRebased = getWalletBalanceFromUtxos(
slpBalancesAndUtxos.nonSlpUtxos,
);
liveWalletState.balances = balancesRebased;
return liveWalletState;
};
export const getWalletBalanceFromUtxos = nonSlpUtxos => {
const totalBalanceInSatoshis = nonSlpUtxos.reduce(
(previousBalance, utxo) =>
previousBalance.plus(new BigNumber(utxo.value)),
new BigNumber(0),
);
return {
totalBalanceInSatoshis: totalBalanceInSatoshis.toString(),
totalBalance: fromSatoshisToXec(totalBalanceInSatoshis).toString(),
};
};
export const isValidStoredWallet = walletStateFromStorage => {
return (
typeof walletStateFromStorage === 'object' &&
'state' in walletStateFromStorage &&
typeof walletStateFromStorage.state === 'object' &&
'balances' in walletStateFromStorage.state &&
'utxos' in walletStateFromStorage.state &&
!('hydratedUtxoDetails' in walletStateFromStorage.state) &&
'slpBalancesAndUtxos' in walletStateFromStorage.state &&
'tokens' in walletStateFromStorage.state
);
};
export const getWalletState = wallet => {
if (!wallet || !wallet.state) {
return {
balances: { totalBalance: 0, totalBalanceInSatoshis: 0 },
hydratedUtxoDetails: {},
tokens: [],
slpBalancesAndUtxos: {},
parsedTxHistory: [],
utxos: [],
};
}
return wallet.state;
};
export function convertEtokenToEcashAddr(eTokenAddress) {
if (!eTokenAddress) {
return new Error(
`cashMethods.convertToEcashAddr() error: No etoken address provided`,
);
}
// Confirm input is a valid eToken address
const isValidInput = isValidEtokenAddress(eTokenAddress);
if (!isValidInput) {
return new Error(
`cashMethods.convertToEcashAddr() error: ${eTokenAddress} is not a valid etoken address`,
);
}
// Check for etoken: prefix
const isPrefixedEtokenAddress = eTokenAddress.slice(0, 7) === 'etoken:';
// If no prefix, assume it is checksummed for an etoken: prefix
const testedEtokenAddr = isPrefixedEtokenAddress
? eTokenAddress
: `etoken:${eTokenAddress}`;
let ecashAddress;
try {
const { type, hash } = cashaddr.decode(testedEtokenAddr);
ecashAddress = cashaddr.encode('ecash', type, hash);
} catch (err) {
return err;
}
return ecashAddress;
}
export function convertToEcashPrefix(bitcoincashPrefixedAddress) {
// Prefix-less addresses may be valid, but the cashaddr.decode function used below
// will throw an error without a prefix. Hence, must ensure prefix to use that function.
const hasPrefix = bitcoincashPrefixedAddress.includes(':');
if (hasPrefix) {
// Is it bitcoincash: or simpleledger:
const { type, hash, prefix } = cashaddr.decode(
bitcoincashPrefixedAddress,
);
let newPrefix;
if (prefix === 'bitcoincash') {
newPrefix = 'ecash';
} else if (prefix === 'simpleledger') {
newPrefix = 'etoken';
} else {
return bitcoincashPrefixedAddress;
}
const convertedAddress = cashaddr.encode(newPrefix, type, hash);
return convertedAddress;
} else {
return bitcoincashPrefixedAddress;
}
}
export function convertEcashtoEtokenAddr(eCashAddress) {
const isValidInput = isValidXecAddress(eCashAddress);
if (!isValidInput) {
return new Error(`${eCashAddress} is not a valid ecash address`);
}
// Check for ecash: prefix
const isPrefixedEcashAddress = eCashAddress.slice(0, 6) === 'ecash:';
// If no prefix, assume it is checksummed for an ecash: prefix
const testedEcashAddr = isPrefixedEcashAddress
? eCashAddress
: `ecash:${eCashAddress}`;
let eTokenAddress;
try {
const { type, hash } = cashaddr.decode(testedEcashAddr);
eTokenAddress = cashaddr.encode('etoken', type, hash);
} catch (err) {
return new Error('eCash to eToken address conversion error');
}
return eTokenAddress;
}
export function toLegacyCash(addr) {
// Confirm input is a valid ecash address
const isValidInput = isValidXecAddress(addr);
if (!isValidInput) {
return new Error(`${addr} is not a valid ecash address`);
}
// Check for ecash: prefix
const isPrefixedXecAddress = addr.slice(0, 6) === 'ecash:';
// If no prefix, assume it is checksummed for an ecash: prefix
const testedXecAddr = isPrefixedXecAddress ? addr : `ecash:${addr}`;
let legacyCashAddress;
try {
const { type, hash } = cashaddr.decode(testedXecAddr);
legacyCashAddress = cashaddr.encode(currency.legacyPrefix, type, hash);
} catch (err) {
return err;
}
return legacyCashAddress;
}
export function toLegacyCashArray(addressArray) {
let cleanArray = []; // array of bch converted addresses to be returned
if (
addressArray === null ||
addressArray === undefined ||
!addressArray.length ||
addressArray === ''
) {
return new Error('Invalid addressArray input');
}
const arrayLength = addressArray.length;
for (let i = 0; i < arrayLength; i++) {
let addressValueArr = addressArray[i].split(',');
let address = addressValueArr[0];
let value = addressValueArr[1];
// NB that toLegacyCash() includes address validation; will throw error for invalid address input
const legacyAddress = toLegacyCash(address);
if (legacyAddress instanceof Error) {
return legacyAddress;
}
let convertedArrayData = legacyAddress + ',' + value + '\n';
cleanArray.push(convertedArrayData);
}
return cleanArray;
}
export function toLegacyToken(addr) {
// Confirm input is a valid ecash address
const isValidInput = isValidEtokenAddress(addr);
if (!isValidInput) {
return new Error(`${addr} is not a valid etoken address`);
}
// Check for ecash: prefix
const isPrefixedEtokenAddress = addr.slice(0, 7) === 'etoken:';
// If no prefix, assume it is checksummed for an ecash: prefix
const testedEtokenAddr = isPrefixedEtokenAddress ? addr : `etoken:${addr}`;
let legacyTokenAddress;
try {
const { type, hash } = cashaddr.decode(testedEtokenAddr);
legacyTokenAddress = cashaddr.encode('simpleledger', type, hash);
} catch (err) {
return err;
}
return legacyTokenAddress;
}
/* Converts a serialized buffer containing encrypted data into an object
* that can be interpreted by the ecies-lite library.
*
* For reference on the parsing logic in this function refer to the link below on the segment of
* ecies-lite's encryption function where the encKey, macKey, iv and cipher are sliced and concatenated
* https://github.com/tibetty/ecies-lite/blob/8fd97e80b443422269d0223ead55802378521679/index.js#L46-L55
*
* A similar PSF implmentation can also be found at:
* https://github.com/Permissionless-Software-Foundation/bch-encrypt-lib/blob/master/lib/encryption.js
*
* For more detailed overview on the ecies encryption scheme, see https://cryptobook.nakov.com/asymmetric-key-ciphers/ecies-public-key-encryption
*/
export const convertToEncryptStruct = encryptionBuffer => {
// based on ecies-lite's encryption logic, the encryption buffer is concatenated as follows:
// [ epk + iv + ct + mac ] whereby:
// - The first 32 or 64 chars of the encryptionBuffer is the epk
// - Both iv and ct params are 16 chars each, hence their combined substring is 32 chars from the end of the epk string
// - within this combined iv/ct substring, the first 16 chars is the iv param, and ct param being the later half
// - The mac param is appended to the end of the encryption buffer
// validate input buffer
if (!encryptionBuffer) {
throw new Error(
'cashmethods.convertToEncryptStruct() error: input must be a buffer',
);
}
try {
// variable tracking the starting char position for string extraction purposes
let startOfBuf = 0;
// *** epk param extraction ***
// The first char of the encryptionBuffer indicates the type of the public key
// If the first char is 4, then the public key is 64 chars
// If the first char is 3 or 2, then the public key is 32 chars
// Otherwise this is not a valid encryption buffer compatible with the ecies-lite library
let publicKey;
switch (encryptionBuffer[0]) {
case 4:
publicKey = encryptionBuffer.slice(0, 65); // extract first 64 chars as public key
break;
case 3:
case 2:
publicKey = encryptionBuffer.slice(0, 33); // extract first 32 chars as public key
break;
default:
throw new Error(`Invalid type: ${encryptionBuffer[0]}`);
}
// *** iv and ct param extraction ***
startOfBuf += publicKey.length; // sets the starting char position to the end of the public key (epk) in order to extract subsequent iv and ct substrings
const encryptionTagLength = 32; // the length of the encryption tag (i.e. mac param) computed from each block of ciphertext, and is used to verify no one has tampered with the encrypted data
const ivCtSubstring = encryptionBuffer.slice(
startOfBuf,
encryptionBuffer.length - encryptionTagLength,
); // extract the substring containing both iv and ct params, which is after the public key but before the mac param i.e. the 'encryption tag'
const ivbufParam = ivCtSubstring.slice(0, 16); // extract the first 16 chars of substring as the iv param
const ctbufParam = ivCtSubstring.slice(16); // extract the last 16 chars as substring the ct param
// *** mac param extraction ***
const macParam = encryptionBuffer.slice(
encryptionBuffer.length - encryptionTagLength,
encryptionBuffer.length,
); // extract the mac param appended to the end of the buffer
return {
iv: ivbufParam,
epk: publicKey,
ct: ctbufParam,
mac: macParam,
};
} catch (err) {
console.error(`useBCH.convertToEncryptStruct() error: `, err);
throw err;
}
};
export const isLegacyMigrationRequired = wallet => {
// If the wallet does not have Path1899,
// Or each Path1899, Path145, Path245 does not have a public key
// Then it requires migration
if (
!wallet.Path1899 ||
!wallet.Path1899.publicKey ||
!wallet.Path1899.hash160 ||
!wallet.Path145.publicKey ||
!wallet.Path145.hash160 ||
!wallet.Path245.publicKey ||
!wallet.Path245.hash160
) {
return true;
}
return false;
};
export const getHashArrayFromWallet = wallet => {
// If the wallet has wallet.Path1899.hash160, it's migrated and will have all of them
// Return false for an umigrated wallet
const hash160Array =
wallet && wallet.Path1899 && 'hash160' in wallet.Path1899
? [
wallet.Path245.hash160,
wallet.Path145.hash160,
wallet.Path1899.hash160,
]
: false;
return hash160Array;
};
export const isActiveWebsocket = ws => {
// Return true if websocket is connected and subscribed
// Otherwise return false
return (
ws !== null &&
ws &&
'_ws' in ws &&
'readyState' in ws._ws &&
ws._ws.readyState === 1 &&
'_subs' in ws &&
ws._subs.length > 0
);
};
export const getCashtabByteCount = (p2pkhInputCount, p2pkhOutputCount) => {
// Simplifying bch-js function for P2PKH txs only, as this is all Cashtab supports for now
// https://github.com/Permissionless-Software-Foundation/bch-js/blob/master/src/bitcoincash.js#L408
/*
const types = {
inputs: {
'P2PKH': 148 * 4,
},
outputs: {
P2PKH: 34 * 4,
},
};
*/
const inputCount = new BigNumber(p2pkhInputCount);
const outputCount = new BigNumber(p2pkhOutputCount);
const inputWeight = new BigNumber(148 * 4);
const outputWeight = new BigNumber(34 * 4);
const nonSegwitWeightConstant = new BigNumber(10 * 4);
let totalWeight = new BigNumber(0);
totalWeight = totalWeight
.plus(inputCount.times(inputWeight))
.plus(outputCount.times(outputWeight))
.plus(nonSegwitWeightConstant);
const byteCount = totalWeight.div(4).integerValue(BigNumber.ROUND_CEIL);
return Number(byteCount);
};

File Metadata

Mime Type
text/x-diff
Expires
Sun, Apr 27, 11:48 (1 d, 16 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
5573449
Default Alt Text
(128 KB)

Event Timeline