diff --git a/web/cashtab/src/components/App.js b/web/cashtab/src/components/App.js
index 211cb621d..6267a99d1 100644
--- a/web/cashtab/src/components/App.js
+++ b/web/cashtab/src/components/App.js
@@ -1,272 +1,291 @@
import React from 'react';
import 'antd/dist/antd.less';
import '../index.css';
import styled, { ThemeProvider, createGlobalStyle } from 'styled-components';
import { theme } from '@assets/styles/theme';
import {
FolderOpenFilled,
CaretRightOutlined,
SettingFilled,
+ AppstoreAddOutlined,
} from '@ant-design/icons';
import Wallet from '@components/Wallet/Wallet';
+import Tokens from '@components/Tokens/Tokens';
import Send from '@components/Send/Send';
import SendToken from '@components/Send/SendToken';
import Configure from '@components/Configure/Configure';
import NotFound from '@components/NotFound';
import CashTab from '@assets/cashtab.png';
import TabCash from '@assets/tabcash.png';
import ABC from '@assets/bitcoinabclogo.png';
import './App.css';
import { WalletContext } from '@utils/context';
import { checkForTokenById } from '@utils/tokenMethods.js';
import WalletLabel from '@components/Common/WalletLabel.js';
import {
Route,
Redirect,
Switch,
useLocation,
useHistory,
} from 'react-router-dom';
import fbt from 'fbt';
const GlobalStyle = createGlobalStyle`
.ant-modal-wrap > div > div.ant-modal-content > div > div > div.ant-modal-confirm-btns > button, .ant-modal > button, .ant-modal-confirm-btns > button, .ant-modal-footer > button {
border-radius: 8px;
background-color: ${props => props.theme.modals.buttons.background};
color: ${props => props.theme.wallet.text.secondary};
font-weight: bold;
}
.ant-modal-wrap > div > div.ant-modal-content > div > div > div.ant-modal-confirm-btns > button:hover,.ant-modal-confirm-btns > button:hover, .ant-modal-footer > button:hover {
color: ${props => props.theme.primary};
transition: color 0.3s;
background-color: ${props => props.theme.modals.buttons.background};
}
.selectedCurrencyOption {
text-align: left;
color: ${props => props.theme.wallet.text.secondary} !important;
background-color: ${props => props.theme.contrast} !important;
}
.cashLoadingIcon {
color: ${props => props.theme.primary} !important
font-size: 48px !important;
}
.selectedCurrencyOption:hover {
color: ${props => props.theme.contrast} !important;
background-color: ${props => props.theme.primary} !important;
}
`;
const CustomApp = styled.div`
text-align: center;
font-family: 'Gilroy', sans-serif;
background-color: ${props => props.theme.app.background};
`;
const Footer = styled.div`
background-color: ${props => props.theme.footer.background};
border-radius: 20px;
position: fixed;
bottom: 0;
width: 500px;
@media (max-width: 768px) {
width: 100%;
}
border-top: 1px solid ${props => props.theme.wallet.borders.color};
`;
export const NavButton = styled.button`
:focus,
:active {
outline: none;
}
cursor: pointer;
padding: 24px 12px 12px 12px;
margin: 0 28px;
- @media (max-width: 360px) {
+ @media (max-width: 475px) {
+ margin: 0 20px;
+ }
+ @media (max-width: 420px) {
margin: 0 12px;
}
+ @media (max-width: 350px) {
+ margin: 0 8px;
+ }
background-color: ${props => props.theme.footer.background};
border: none;
font-size: 12px;
font-weight: bold;
.anticon {
display: block;
color: ${props => props.theme.footer.navIconInactive};
font-size: 24px;
margin-bottom: 6px;
}
${({ active, ...props }) =>
active &&
`
color: ${props.theme.primary};
.anticon {
color: ${props.theme.primary};
}
`}
`;
export const WalletBody = styled.div`
display: flex;
align-items: center;
justify-content: center;
width: 100%;
min-height: 100vh;
background: ${props => props.theme.app.sidebars};
`;
export const WalletCtn = styled.div`
position: relative;
width: 500px;
background-color: ${props => props.theme.footerBackground};
min-height: 100vh;
padding: 10px 30px 120px 30px;
background: ${props => props.theme.wallet.background};
-webkit-box-shadow: 0px 0px 24px 1px ${props => props.theme.wallet.shadow};
-moz-box-shadow: 0px 0px 24px 1px ${props => props.theme.wallet.shadow};
box-shadow: 0px 0px 24px 1px ${props => props.theme.wallet.shadow};
@media (max-width: 768px) {
width: 100%;
-webkit-box-shadow: none;
-moz-box-shadow: none;
box-shadow: none;
}
`;
export const HeaderCtn = styled.div`
display: flex;
align-items: center;
justify-content: center;
width: 100%;
padding: 20px 0 30px;
margin-bottom: 20px;
justify-content: space-between;
border-bottom: 1px solid ${props => props.theme.wallet.borders.color};
@media (max-width: 768px) {
a {
font-size: 12px;
}
padding: 10px 0 20px;
}
`;
export const EasterEgg = styled.img`
position: fixed;
bottom: -195px;
margin: 0;
right: 10%;
transition-property: bottom;
transition-duration: 1.5s;
transition-timing-function: ease-out;
:hover {
bottom: 0;
}
@media screen and (max-width: 1250px) {
display: none;
}
`;
export const CashTabLogo = styled.img`
width: 120px;
@media (max-width: 768px) {
width: 110px;
}
`;
export const AbcLogo = styled.img`
width: 150px;
@media (max-width: 768px) {
width: 120px;
}
`;
const App = () => {
const ContextValue = React.useContext(WalletContext);
const { wallet, tokens } = ContextValue;
const hasTab = checkForTokenById(
tokens,
'50d8292c6255cda7afc6c8566fed3cf42a2794e9619740fe8f4c95431271410e',
);
const location = useLocation();
const history = useHistory();
const selectedKey =
location && location.pathname ? location.pathname.substr(1) : '';
return (
{hasTab && (
)}
+
+
+
(
)}
/>
{wallet ? (
) : null}
);
};
export default App;
diff --git a/web/cashtab/src/components/Common/Atoms.js b/web/cashtab/src/components/Common/Atoms.js
new file mode 100644
index 000000000..6a25ba1ed
--- /dev/null
+++ b/web/cashtab/src/components/Common/Atoms.js
@@ -0,0 +1,52 @@
+import styled from 'styled-components';
+
+export const LoadingCtn = styled.div`
+ width: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 400px;
+ flex-direction: column;
+
+ svg {
+ width: 50px;
+ height: 50px;
+ fill: ${props => props.theme.primary};
+ }
+`;
+
+export const BalanceHeader = styled.div`
+ color: ${props => props.theme.wallet.text.primary};
+ width: 100%;
+ font-size: 30px;
+ font-weight: bold;
+ @media (max-width: 768px) {
+ font-size: 23px;
+ }
+`;
+
+export const BalanceHeaderFiat = styled.div`
+ color: ${props => props.theme.wallet.text.secondary};
+ width: 100%;
+ font-size: 18px;
+ margin-bottom: 20px;
+ font-weight: bold;
+ @media (max-width: 768px) {
+ font-size: 16px;
+ }
+`;
+
+export const ZeroBalanceHeader = styled.div`
+ color: ${props => props.theme.wallet.text.primary};
+ width: 100%;
+ font-size: 14px;
+ margin-bottom: 5px;
+`;
+
+export const TokenParamLabel = styled.span`
+ font-weight: bold;
+`;
+
+export const AlertMsg = styled.p`
+ color: ${props => props.theme.forms.error};
+`;
diff --git a/web/cashtab/src/components/Common/PrimaryButton.js b/web/cashtab/src/components/Common/PrimaryButton.js
index 2ffb0c690..fc29bff92 100644
--- a/web/cashtab/src/components/Common/PrimaryButton.js
+++ b/web/cashtab/src/components/Common/PrimaryButton.js
@@ -1,103 +1,104 @@
import styled from 'styled-components';
const PrimaryButton = styled.button`
border: none;
color: ${props => props.theme.buttons.primary.color};
background-image: ${props => props.theme.buttons.primary.backgroundImage};
transition: all 0.5s ease;
background-size: 200% auto;
font-size: 18px;
width: 100%;
padding: 20px 0;
border-radius: 4px;
margin-bottom: 20px;
cursor: pointer;
:hover {
background-position: right center;
-webkit-box-shadow: ${props => props.theme.buttons.primary.hoverShadow};
-moz-box-shadow: ${props => props.theme.buttons.primary.hoverShadow};
box-shadow: ${props => props.theme.buttons.primary.hoverShadow};
}
svg {
fill: ${props => props.theme.buttons.primary.color};
}
@media (max-width: 768px) {
font-size: 16px;
padding: 15px 0;
}
`;
const SecondaryButton = styled.button`
border: none;
color: ${props => props.theme.buttons.secondary.color};
background: ${props => props.theme.buttons.secondary.background};
transition: all 0.5s ease;
font-size: 18px;
width: 100%;
padding: 15px 0;
border-radius: 4px;
cursor: pointer;
outline: none;
margin-bottom: 20px;
:hover {
-webkit-box-shadow: ${props =>
props.theme.buttons.secondary.hoverShadow};
-moz-box-shadow: ${props => props.theme.buttons.secondary.hoverShadow};
box-shadow: ${props => props.theme.buttons.secondary.hoverShadow};
}
svg {
fill: ${props => props.theme.buttons.secondary.color};
}
@media (max-width: 768px) {
font-size: 16px;
padding: 12px 0;
}
`;
const SmartButton = styled.button`
${({ disabled = false, ...props }) =>
disabled === true
? `
background-image: 'none';
color: ${props.theme.buttons.secondary.color};
background: ${props.theme.buttons.secondary.background};
:hover {
-webkit-box-shadow: 0px 3px 10px -5px rgba(0, 0, 0, 0.75);
-moz-box-shadow: 0px 3px 10px -5px rgba(0, 0, 0, 0.75);
box-shadow: 0px 3px 10px -5px rgba(0, 0, 0, 0.75);
}
svg {
fill: ${props.theme.buttons.secondary.color};
}
`
: `
background-image: ${props.theme.buttons.primary.backgroundImage};
color: ${props.theme.buttons.primary.color};
:hover {
background-position: right center;
-webkit-box-shadow: ${props.theme.buttons.primary.hoverShadow};
-moz-box-shadow: ${props.theme.buttons.primary.hoverShadow};
box-shadow: ${props.theme.buttons.primary.hoverShadow};
+ }
svg {
fill: ${props.theme.buttons.primary.color};
}
- }`}
+ `}
border: none;
transition: all 0.5s ease;
font-size: 18px;
width: 100%;
padding: 15px 0;
border-radius: 4px;
cursor: pointer;
outline: none;
margin-bottom: 20px;
@media (max-width: 768px) {
font-size: 16px;
padding: 12px 0;
}
`;
export default PrimaryButton;
export { SecondaryButton, SmartButton };
diff --git a/web/cashtab/src/components/Common/StyledCollapse.js b/web/cashtab/src/components/Common/StyledCollapse.js
index 38f0c248b..369c6fa07 100644
--- a/web/cashtab/src/components/Common/StyledCollapse.js
+++ b/web/cashtab/src/components/Common/StyledCollapse.js
@@ -1,20 +1,53 @@
import styled from 'styled-components';
import { Collapse } from 'antd';
export const StyledCollapse = styled(Collapse)`
background: ${props => props.theme.collapses.background} !important;
border: 1px solid ${props => props.theme.collapses.border} !important;
.ant-collapse-content {
border: 1px solid ${props => props.theme.collapses.border};
border-top: none;
}
.ant-collapse-item {
border-bottom: none !important;
}
- * {
+ *:not(button) {
color: ${props => props.theme.collapses.color} !important;
}
`;
+
+export const TokenCollapse = styled(Collapse)`
+ ${({ disabled = false, ...props }) =>
+ disabled === true
+ ? `
+ background: ${props.theme.buttons.secondary.background} !important;
+ .ant-collapse-header {
+ font-size: 18px;
+ font-weight: bold;
+ color: ${props.theme.buttons.secondary.color} !important;
+ svg {
+ color: ${props.theme.buttons.secondary.color} !important;
+ }
+ }
+ .ant-collapse-arrow {
+ font-size: 18px;
+ }
+ `
+ : `
+ background: ${props.theme.primary} !important;
+ .ant-collapse-header {
+ font-size: 18px;
+ font-weight: bold;
+ color: ${props.theme.contrast} !important;
+ svg {
+ color: ${props.theme.contrast} !important;
+ }
+ }
+ .ant-collapse-arrow {
+ font-size: 18px;
+ }
+ `}
+`;
diff --git a/web/cashtab/src/components/Common/Ticker.js b/web/cashtab/src/components/Common/Ticker.js
index 63b825365..ff6b2f304 100644
--- a/web/cashtab/src/components/Common/Ticker.js
+++ b/web/cashtab/src/components/Common/Ticker.js
@@ -1,143 +1,144 @@
import mainLogo from '@assets/12-bitcoin-cash-square-crop.svg';
import tokenLogo from '@assets/simple-ledger-protocol-logo.png';
import cashaddr from 'cashaddrjs';
import BigNumber from 'bignumber.js';
export const currency = {
name: 'Bitcoin ABC',
ticker: 'BCHA',
logo: mainLogo,
legacyPrefix: 'bitcoincash',
prefixes: ['bitcoincash', 'ecash'],
coingeckoId: 'bitcoin-cash-abc-2',
defaultFee: 5.01,
dust: '0.00000546', // The minimum amount of BCHA that can be sent by the app
cashDecimals: 8,
blockExplorerUrl: 'https://explorer.bitcoinabc.org',
+ tokenExplorerUrl: 'https://explorer.be.cash',
blockExplorerUrlTestnet: 'https://texplorer.bitcoinabc.org',
tokenName: 'Bitcoin ABC SLP',
tokenTicker: 'SLPA',
tokenLogo: tokenLogo,
tokenPrefixes: ['simpleledger', 'etoken'],
tokenIconsUrl: '', //https://tokens.bitcoin.com/32 for BCH SLP
useBlockchainWs: false,
txHistoryCount: 5,
hydrateUtxoBatchSize: 20,
};
export function isValidCashPrefix(addressString) {
// Note that this function validates prefix only
// Check for prefix included in currency.prefixes array
// For now, validation is handled by converting to bitcoincash: prefix and checksum
// and relying on legacy validation methods of bitcoincash: prefix addresses
// Also accept an address with no prefix, as some exchanges provide these
for (let i = 0; i < currency.prefixes.length; i += 1) {
// If the addressString being tested starts with an accepted prefix or no prefix at all
if (
addressString.startsWith(currency.prefixes[i] + ':') ||
!addressString.includes(':')
) {
return true;
}
}
return false;
}
export function isValidTokenPrefix(addressString) {
// Check for prefix included in currency.tokenPrefixes array
// For now, validation is handled by converting to simpleledger: prefix and checksum
// and relying on legacy validation methods of simpleledger: prefix addresses
// For token addresses, do not accept an address with no prefix
for (let i = 0; i < currency.tokenPrefixes.length; i += 1) {
if (addressString.startsWith(currency.tokenPrefixes[i] + ':')) {
return true;
}
}
return false;
}
export function toLegacy(address) {
let testedAddress;
let legacyAddress;
try {
if (isValidCashPrefix(address)) {
// 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 = address.includes(':');
if (!hasPrefix) {
testedAddress = currency.legacyPrefix + ':' + address;
} else {
testedAddress = address;
}
// Note: an `ecash:` checksum address with no prefix will not be validated by
// parseAddress in Send.js
// Only handle the case of prefixless address that is valid `bitcoincash:` address
const { type, hash } = cashaddr.decode(testedAddress);
legacyAddress = cashaddr.encode(currency.legacyPrefix, type, hash);
} else {
console.log(`Error: ${address} is not a cash address`);
throw new Error(
'Address prefix is not a valid cash address with a prefix from the Ticker.prefixes array',
);
}
} catch (err) {
return err;
}
return legacyAddress;
}
export function parseAddress(BCH, addressString) {
// Build return obj
const addressInfo = {
address: '',
isValid: false,
queryString: null,
amount: null,
};
// Parse address string for parameters
const paramCheck = addressString.split('?');
let cleanAddress = paramCheck[0];
addressInfo.address = cleanAddress;
// Validate address
let isValidAddress;
try {
isValidAddress = BCH.Address.isCashAddress(cleanAddress);
} catch (err) {
isValidAddress = false;
}
addressInfo.isValid = isValidAddress;
// Check for parameters
// only the amount param is currently supported
let queryString = null;
let amount = null;
if (paramCheck.length > 1) {
queryString = paramCheck[1];
addressInfo.queryString = queryString;
const addrParams = new URLSearchParams(queryString);
if (addrParams.has('amount')) {
// Amount in satoshis
try {
amount = new BigNumber(parseInt(addrParams.get('amount')))
.div(1e8)
.toString();
} catch (err) {
amount = null;
}
}
}
addressInfo.amount = amount;
return addressInfo;
}
diff --git a/web/cashtab/src/components/Common/__tests__/__snapshots__/StyledCollapse.test.js.snap b/web/cashtab/src/components/Common/__tests__/__snapshots__/StyledCollapse.test.js.snap
index dae14d353..70267950b 100644
--- a/web/cashtab/src/components/Common/__tests__/__snapshots__/StyledCollapse.test.js.snap
+++ b/web/cashtab/src/components/Common/__tests__/__snapshots__/StyledCollapse.test.js.snap
@@ -1,8 +1,8 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
+// Jest Snapshot v1, https://goo.gl/fbAQLP @generated
exports[`Render StyledCollapse component 1`] = `
`;
diff --git a/web/cashtab/src/components/Configure/__tests__/__snapshots__/Configure.test.js.snap b/web/cashtab/src/components/Configure/__tests__/__snapshots__/Configure.test.js.snap
index 5acbcb771..e4827ba31 100644
--- a/web/cashtab/src/components/Configure/__tests__/__snapshots__/Configure.test.js.snap
+++ b/web/cashtab/src/components/Configure/__tests__/__snapshots__/Configure.test.js.snap
@@ -1,437 +1,437 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
+// Jest Snapshot v1, https://goo.gl/fbAQLP @generated
exports[`Configure with a wallet 1`] = `
Backup your wallet
Your seed phrase is the only way to restore your wallet. Write it down. Keep it safe.
Click to reveal seed phrase
Manage Wallets
[
Documentation
]
`;
exports[`Configure without a wallet 1`] = `
Backup your wallet
Your seed phrase is the only way to restore your wallet. Write it down. Keep it safe.
Manage Wallets
[
Documentation
]
`;
diff --git a/web/cashtab/src/components/Tokens/CreateTokenForm.js b/web/cashtab/src/components/Tokens/CreateTokenForm.js
new file mode 100644
index 000000000..c20af0fce
--- /dev/null
+++ b/web/cashtab/src/components/Tokens/CreateTokenForm.js
@@ -0,0 +1,372 @@
+import React, { useState } from 'react';
+import { AntdFormWrapper } from '@components/Common/EnhancedInputs';
+import { TokenCollapse } from '@components/Common/StyledCollapse';
+import { currency } from '@components/Common/Ticker.js';
+import { WalletContext } from '@utils/context';
+import {
+ isValidTokenName,
+ isValidTokenTicker,
+ isValidTokenDecimals,
+ isValidTokenInitialQty,
+ isValidTokenDocumentUrl,
+} from '@utils/validation';
+import { PlusSquareOutlined } from '@ant-design/icons';
+import { SmartButton } from '@components/Common/PrimaryButton';
+import { Collapse, Form, Input, Modal, notification, Spin } from 'antd';
+const { Panel } = Collapse;
+import Paragraph from 'antd/lib/typography/Paragraph';
+import { TokenParamLabel } from '@components/Common/Atoms';
+
+import { CashLoadingIcon } from '@components/Common/CustomIcons';
+
+const CreateTokenForm = ({ BCH, getRestUrl, createToken, disabled }) => {
+ const { wallet } = React.useContext(WalletContext);
+
+ //const { getBCH, getRestUrl, createToken } = useBCH();
+
+ // New Token Name
+ const [newTokenName, setNewTokenName] = useState('');
+ const [newTokenNameIsValid, setNewTokenNameIsValid] = useState(null);
+ const handleNewTokenNameInput = e => {
+ const { value } = e.target;
+ // validation
+ setNewTokenNameIsValid(isValidTokenName(value));
+ setNewTokenName(value);
+ };
+
+ // New Token Ticker
+ const [newTokenTicker, setNewTokenTicker] = useState('');
+ const [newTokenTickerIsValid, setNewTokenTickerIsValid] = useState(null);
+ const handleNewTokenTickerInput = e => {
+ const { value } = e.target;
+ // validation
+ setNewTokenTickerIsValid(isValidTokenTicker(value));
+ setNewTokenTicker(value);
+ };
+
+ // New Token Decimals
+ const [newTokenDecimals, setNewTokenDecimals] = useState(0);
+ const [newTokenDecimalsIsValid, setNewTokenDecimalsIsValid] = useState(
+ true,
+ );
+ const handleNewTokenDecimalsInput = e => {
+ const { value } = e.target;
+ // validation
+ setNewTokenDecimalsIsValid(isValidTokenDecimals(value));
+ // Also validate the supply here if it has not yet been set
+ if (newTokenInitialQtyIsValid !== null) {
+ setNewTokenInitialQtyIsValid(
+ isValidTokenInitialQty(value, newTokenDecimals),
+ );
+ }
+
+ setNewTokenDecimals(value);
+ };
+
+ // New Token Initial Quantity
+ const [newTokenInitialQty, setNewTokenInitialQty] = useState('');
+ const [newTokenInitialQtyIsValid, setNewTokenInitialQtyIsValid] = useState(
+ null,
+ );
+ const handleNewTokenInitialQtyInput = e => {
+ const { value } = e.target;
+ // validation
+ setNewTokenInitialQtyIsValid(
+ isValidTokenInitialQty(value, newTokenDecimals),
+ );
+ setNewTokenInitialQty(value);
+ };
+ // New Token document URL
+ const [newTokenDocumentUrl, setNewTokenDocumentUrl] = useState('');
+ // Start with this as true, field is not required
+ const [
+ newTokenDocumentUrlIsValid,
+ setNewTokenDocumentUrlIsValid,
+ ] = useState(true);
+
+ const handleNewTokenDocumentUrlInput = e => {
+ const { value } = e.target;
+ // validation
+ setNewTokenDocumentUrlIsValid(isValidTokenDocumentUrl(value));
+ setNewTokenDocumentUrl(value);
+ };
+
+ // New Token fixed supply
+ // Only allow creation of fixed supply tokens until Minting support is added
+
+ // New Token document hash
+ // Do not include this; questionable value to casual users and requires significant complication
+
+ // Only enable CreateToken button if all form entries are valid
+ let tokenGenesisDataIsValid =
+ newTokenNameIsValid &&
+ newTokenTickerIsValid &&
+ newTokenDecimalsIsValid &&
+ newTokenInitialQtyIsValid &&
+ newTokenDocumentUrlIsValid;
+
+ // Modal settings
+ const [showConfirmCreateToken, setShowConfirmCreateToken] = useState(false);
+
+ // Token creation loading
+ const [genesisLoading, setGenesisLoading] = useState(false);
+
+ const createPreviewedToken = async () => {
+ setGenesisLoading(true);
+ // If data is for some reason not valid here, bail out
+ if (!tokenGenesisDataIsValid) {
+ return;
+ }
+
+ // data must be valid and user reviewed to get here
+ const configObj = {
+ name: newTokenName,
+ ticker: newTokenTicker,
+ documentUrl:
+ newTokenDocumentUrl === ''
+ ? 'https://cashtabapp.com/'
+ : newTokenDocumentUrl,
+ decimals: newTokenDecimals,
+ initialQty: newTokenInitialQty,
+ documentHash: '',
+ };
+
+ // create token with data in state fields
+ try {
+ const link = await createToken(
+ BCH,
+ wallet,
+ currency.defaultFee,
+ configObj,
+ );
+
+ notification.success({
+ message: 'Success',
+ description: (
+
+
+ Token created! Click or tap here for more details
+
+
+ ),
+ duration: 5,
+ });
+ } catch (e) {
+ // Set loading to false here as well, as balance may not change depending on where error occured in try loop
+ setGenesisLoading(false);
+ let message;
+
+ if (!e.error && !e.message) {
+ message = `Transaction failed: no response from ${getRestUrl()}.`;
+ } else if (
+ /Could not communicate with full node or other external service/.test(
+ e.error,
+ )
+ ) {
+ message = 'Could not communicate with API. Please try again.';
+ } else if (
+ e.error &&
+ e.error.includes(
+ 'too-long-mempool-chain, too many unconfirmed ancestors [limit: 50] (code 64)',
+ )
+ ) {
+ message = `The ${currency.ticker} you are trying to send has too many unconfirmed ancestors to send (limit 50). Sending will be possible after a block confirmation. Try again in about 10 minutes.`;
+ } else {
+ message = e.message || e.error || JSON.stringify(e);
+ }
+
+ notification.error({
+ message: 'Error',
+ description: message,
+ duration: 5,
+ });
+ console.error(e);
+ }
+ // Hide the modal
+ setShowConfirmCreateToken(false);
+ };
+ return (
+ <>
+ setShowConfirmCreateToken(false)}
+ >
+ Name: {newTokenName}
+
+ Ticker: {newTokenTicker}
+
+ Decimals: {newTokenDecimals}
+
+ Supply: {newTokenInitialQty}
+
+ Document URL:{' '}
+ {newTokenDocumentUrl === ''
+ ? 'https://cashtabapp.com/'
+ : newTokenDocumentUrl}
+
+
+ <>
+
+
+
+
+
+
+ handleNewTokenNameInput(e)
+ }
+ />
+
+
+
+ handleNewTokenTickerInput(e)
+ }
+ />
+
+
+
+ handleNewTokenDecimalsInput(e)
+ }
+ />
+
+
+
+ handleNewTokenInitialQtyInput(e)
+ }
+ />
+
+
+
+ handleNewTokenDocumentUrlInput(
+ e,
+ )
+ }
+ />
+
+
+
+ setShowConfirmCreateToken(true)}
+ disabled={!tokenGenesisDataIsValid}
+ >
+
+ Create Token
+
+
+
+
+ >
+ >
+ );
+};
+
+export default CreateTokenForm;
diff --git a/web/cashtab/src/components/Tokens/Tokens.js b/web/cashtab/src/components/Tokens/Tokens.js
new file mode 100644
index 000000000..6a8cdc7cc
--- /dev/null
+++ b/web/cashtab/src/components/Tokens/Tokens.js
@@ -0,0 +1,131 @@
+import React from 'react';
+import { LoadingOutlined } from '@ant-design/icons';
+import { CashLoader } from '@components/Common/CustomIcons';
+import { WalletContext } from '@utils/context';
+import { formatBalance, isValidStoredWallet } from '@utils/cashMethods';
+import CreateTokenForm from '@components/Tokens/CreateTokenForm';
+import { currency } from '@components/Common/Ticker.js';
+import TokenList from '@components/Wallet/TokenList';
+import useBCH from '@hooks/useBCH';
+import {
+ LoadingCtn,
+ BalanceHeader,
+ BalanceHeaderFiat,
+ ZeroBalanceHeader,
+ AlertMsg,
+} from '@components/Common/Atoms';
+
+const Tokens = ({ jestBCH }) => {
+ /*
+ Dev note
+
+ This is the first new page created after the wallet migration to include state in storage
+
+ As such, it will only load this type of wallet
+
+ If any user is still migrating at this point, this page will display a loading spinner until
+ their wallet has updated (ETA within 10 seconds)
+
+ Going forward, this approach will be the model for Wallet, Send, and SendToken, as the legacy
+ wallet state parameters not stored in the wallet object are deprecated
+ */
+
+ const { loading, wallet, apiError, fiatPrice } = React.useContext(
+ WalletContext,
+ );
+
+ // If wallet is unmigrated, do not show page until it has migrated
+ // An invalid wallet will be validated/populated after the next API call, ETA 10s
+ let validWallet = isValidStoredWallet(wallet);
+
+ // Get wallet state variables
+ let balances, tokens;
+ if (validWallet) {
+ balances = wallet.state.balances;
+ tokens = wallet.state.tokens;
+ }
+
+ const { getBCH, getRestUrl, createToken } = useBCH();
+
+ // Support using locally installed bchjs for unit tests
+ const BCH = jestBCH ? jestBCH : getBCH();
+ return (
+ <>
+ {loading || !validWallet ? (
+
+
+
+ ) : (
+ <>
+ {!balances.totalBalance ? (
+ <>
+
+ You need some {currency.ticker} in your wallet
+ to create tokens.
+
+ 0 {currency.ticker}
+ >
+ ) : (
+ <>
+
+ {formatBalance(balances.totalBalance)}{' '}
+ {currency.ticker}
+
+ {fiatPrice !== null &&
+ !isNaN(balances.totalBalance) && (
+
+ $
+ {(
+ balances.totalBalance * fiatPrice
+ ).toFixed(2)}{' '}
+ USD
+
+ )}
+ >
+ )}
+ {apiError && (
+ <>
+
+ An error occurred on our end.
+
Re-establishing connection...
+
+
+ >
+ )}
+
+ {balances.totalBalanceInSatoshis < 546 && (
+
+ You need at least {currency.dust} {currency.ticker}{' '}
+ ($
+ {(currency.dust * fiatPrice).toFixed(4)} USD) to
+ create a token
+
+ )}
+
+ {tokens && tokens.length > 0 ? (
+ <>
+
+ >
+ ) : (
+ <>No {currency.tokenTicker} tokens in this wallet>
+ )}
+ >
+ )}
+ >
+ );
+};
+
+export default Tokens;
diff --git a/web/cashtab/src/components/Tokens/__tests__/CreateTokenForm.test.js b/web/cashtab/src/components/Tokens/__tests__/CreateTokenForm.test.js
new file mode 100644
index 000000000..6abb19f40
--- /dev/null
+++ b/web/cashtab/src/components/Tokens/__tests__/CreateTokenForm.test.js
@@ -0,0 +1,58 @@
+import React from 'react';
+import renderer from 'react-test-renderer';
+import { ThemeProvider } from 'styled-components';
+import { theme } from '@assets/styles/theme';
+import CreateTokenForm from '@components/Tokens/CreateTokenForm';
+import BCHJS from '@psf/bch-js';
+import useBCH from '@hooks/useBCH';
+import { walletWithBalancesAndTokensWithCorrectState } from '../../Wallet/__mocks__/walletAndBalancesMock';
+
+let useContextMock;
+let realUseContext;
+
+beforeEach(() => {
+ realUseContext = React.useContext;
+ useContextMock = React.useContext = jest.fn();
+
+ // Mock method not implemented in JSDOM
+ // See reference at https://jestjs.io/docs/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom
+ Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ value: jest.fn().mockImplementation(query => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: jest.fn(), // Deprecated
+ removeListener: jest.fn(), // Deprecated
+ addEventListener: jest.fn(),
+ removeEventListener: jest.fn(),
+ dispatchEvent: jest.fn(),
+ })),
+ });
+});
+
+afterEach(() => {
+ React.useContext = realUseContext;
+});
+
+test('Wallet with BCH balances and tokens and state field', () => {
+ useContextMock.mockReturnValue(walletWithBalancesAndTokensWithCorrectState);
+
+ const testBCH = new BCHJS();
+ const { getRestUrl, createToken } = useBCH();
+ const component = renderer.create(
+
+
+ ,
+ );
+ let tree = component.toJSON();
+ expect(tree).toMatchSnapshot();
+});
diff --git a/web/cashtab/src/components/Tokens/__tests__/Tokens.test.js b/web/cashtab/src/components/Tokens/__tests__/Tokens.test.js
new file mode 100644
index 000000000..8aa372409
--- /dev/null
+++ b/web/cashtab/src/components/Tokens/__tests__/Tokens.test.js
@@ -0,0 +1,130 @@
+import React from 'react';
+import renderer from 'react-test-renderer';
+import { ThemeProvider } from 'styled-components';
+import { theme } from '@assets/styles/theme';
+import Tokens from '@components/Tokens/Tokens';
+import BCHJS from '@psf/bch-js';
+import {
+ walletWithBalancesAndTokens,
+ walletWithBalancesMock,
+ walletWithoutBalancesMock,
+ walletWithBalancesAndTokensWithCorrectState,
+ walletWithBalancesAndTokensWithEmptyState,
+} from '../../Wallet/__mocks__/walletAndBalancesMock';
+import { BrowserRouter as Router } from 'react-router-dom';
+
+let realUseContext;
+let useContextMock;
+
+beforeEach(() => {
+ realUseContext = React.useContext;
+ useContextMock = React.useContext = jest.fn();
+
+ // Mock method not implemented in JSDOM
+ // See reference at https://jestjs.io/docs/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom
+ Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ value: jest.fn().mockImplementation(query => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: jest.fn(), // Deprecated
+ removeListener: jest.fn(), // Deprecated
+ addEventListener: jest.fn(),
+ removeEventListener: jest.fn(),
+ dispatchEvent: jest.fn(),
+ })),
+ });
+});
+
+afterEach(() => {
+ React.useContext = realUseContext;
+});
+
+test('Wallet without BCH balance', () => {
+ useContextMock.mockReturnValue(walletWithoutBalancesMock);
+ const testBCH = new BCHJS();
+ const component = renderer.create(
+
+
+
+
+ ,
+ );
+ let tree = component.toJSON();
+ expect(tree).toMatchSnapshot();
+});
+
+test('Wallet with BCH balances', () => {
+ useContextMock.mockReturnValue(walletWithBalancesMock);
+ const testBCH = new BCHJS();
+ const component = renderer.create(
+
+
+
+
+ ,
+ );
+ let tree = component.toJSON();
+ expect(tree).toMatchSnapshot();
+});
+
+test('Wallet with BCH balances and tokens', () => {
+ useContextMock.mockReturnValue(walletWithBalancesAndTokens);
+ const testBCH = new BCHJS();
+ const component = renderer.create(
+
+
+
+
+ ,
+ );
+ let tree = component.toJSON();
+ expect(tree).toMatchSnapshot();
+});
+
+test('Wallet with BCH balances and tokens and state field', () => {
+ useContextMock.mockReturnValue(walletWithBalancesAndTokensWithCorrectState);
+ const testBCH = new BCHJS();
+ const component = renderer.create(
+
+
+
+
+ ,
+ );
+ let tree = component.toJSON();
+ expect(tree).toMatchSnapshot();
+});
+
+test('Wallet with BCH balances and tokens and state field, but no params in state', () => {
+ useContextMock.mockReturnValue(walletWithBalancesAndTokensWithEmptyState);
+ const testBCH = new BCHJS();
+ const component = renderer.create(
+
+
+
+
+ ,
+ );
+ let tree = component.toJSON();
+ expect(tree).toMatchSnapshot();
+});
+
+test('Without wallet defined', () => {
+ useContextMock.mockReturnValue({
+ wallet: {},
+ balances: { totalBalance: 0 },
+ loading: false,
+ });
+ const testBCH = new BCHJS();
+ const component = renderer.create(
+
+
+
+
+ ,
+ );
+ let tree = component.toJSON();
+ expect(tree).toMatchSnapshot();
+});
diff --git a/web/cashtab/src/components/Tokens/__tests__/__snapshots__/CreateTokenForm.test.js.snap b/web/cashtab/src/components/Tokens/__tests__/__snapshots__/CreateTokenForm.test.js.snap
new file mode 100644
index 000000000..728350c93
--- /dev/null
+++ b/web/cashtab/src/components/Tokens/__tests__/__snapshots__/CreateTokenForm.test.js.snap
@@ -0,0 +1,55 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP @generated
+
+exports[`Wallet with BCH balances and tokens and state field 1`] = `
+
+
+
+
+
+
+
+
+ Create Token
+
+
+
+
+
+`;
diff --git a/web/cashtab/src/components/Tokens/__tests__/__snapshots__/Tokens.test.js.snap b/web/cashtab/src/components/Tokens/__tests__/__snapshots__/Tokens.test.js.snap
new file mode 100644
index 000000000..e34cecb5c
--- /dev/null
+++ b/web/cashtab/src/components/Tokens/__tests__/__snapshots__/Tokens.test.js.snap
@@ -0,0 +1,157 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP @generated
+
+exports[`Wallet with BCH balances 1`] = `
+
+`;
+
+exports[`Wallet with BCH balances and tokens 1`] = `
+
+`;
+
+exports[`Wallet with BCH balances and tokens and state field 1`] = `
+
+`;
+
+exports[`Wallet with BCH balances and tokens and state field, but no params in state 1`] = `
+
+`;
+
+exports[`Wallet without BCH balance 1`] = `
+
+`;
+
+exports[`Without wallet defined 1`] = `
+
+`;
diff --git a/web/cashtab/src/components/Wallet/Tx.js b/web/cashtab/src/components/Wallet/Tx.js
index 5a7d9e24d..586a187eb 100644
--- a/web/cashtab/src/components/Wallet/Tx.js
+++ b/web/cashtab/src/components/Wallet/Tx.js
@@ -1,258 +1,304 @@
import React from 'react';
import styled from 'styled-components';
-import { ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/icons';
+import {
+ ArrowUpOutlined,
+ ArrowDownOutlined,
+ ExperimentOutlined,
+} from '@ant-design/icons';
import { currency } from '@components/Common/Ticker';
import makeBlockie from 'ethereum-blockies-base64';
import { Img } from 'react-image';
const SentTx = styled(ArrowUpOutlined)`
color: ${props => props.theme.secondary} !important;
`;
const ReceivedTx = styled(ArrowDownOutlined)`
color: ${props => props.theme.primary} !important;
`;
+const GenesisTx = styled(ExperimentOutlined)`
+ color: ${props => props.theme.primary} !important;
+`;
const DateType = styled.div`
text-align: left;
padding: 12px;
@media screen and (max-width: 500px) {
font-size: 0.8rem;
}
`;
const SentLabel = styled.span`
font-weight: bold;
color: ${props => props.theme.secondary} !important;
`;
const ReceivedLabel = styled.span`
font-weight: bold;
color: ${props => props.theme.primary} !important;
`;
const TxIcon = styled.div`
svg {
width: 32px;
height: 32px;
}
height: 32px;
width: 32px;
@media screen and (max-width: 500px) {
svg {
width: 24px;
height: 24px;
}
height: 24px;
width: 24px;
}
`;
const TxInfo = styled.div`
padding: 12px;
font-size: 1rem;
text-align: right;
color: ${props =>
props.outgoing ? props.theme.secondary : props.theme.primary};
@media screen and (max-width: 500px) {
font-size: 0.8rem;
}
`;
const TxFiatPrice = styled.span`
font-size: 0.8rem;
`;
const TokenInfo = styled.div`
display: grid;
grid-template-rows: 50%;
grid-template-columns: 24px auto;
padding: 12px;
font-size: 1rem;
color: ${props =>
props.outgoing ? props.theme.secondary : props.theme.primary};
@media screen and (max-width: 500px) {
font-size: 0.8rem;
grid-template-columns: 16px auto;
}
`;
const TxTokenIcon = styled.div`
img {
height: 24px;
width: 24px;
}
@media screen and (max-width: 500px) {
img {
height: 16px;
width: 16px;
}
}
grid-column-start: 1;
grid-column-end: span 1;
grid-row-start: 1;
grid-row-end: span 2;
align-self: center;
`;
const TokenTxAmt = styled.div`
padding-left: 12px;
text-align: right;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`;
const TokenName = styled.div`
padding-left: 12px;
font-size: 0.8rem;
@media screen and (max-width: 500px) {
font-size: 0.6rem;
}
text-align: right;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`;
const TxWrapper = styled.div`
display: grid;
grid-template-columns: 36px 30% 50%;
justify-content: space-between;
align-items: center;
padding: 15px 25px;
border-radius: 3px;
background: ${props => props.theme.tokenListItem.background};
margin-bottom: 3px;
box-shadow: ${props => props.theme.tokenListItem.boxShadow};
border: 1px solid ${props => props.theme.tokenListItem.border};
:hover {
border-color: ${props => props.theme.primary};
}
@media screen and (max-width: 500px) {
grid-template-columns: 24px 30% 50%;
padding: 12px 12px;
}
`;
const Tx = ({ data, fiatPrice }) => {
const txDate =
typeof data.blocktime === 'undefined'
? new Date().toLocaleDateString()
: new Date(data.blocktime * 1000).toLocaleDateString();
return (
- {data.outgoingTx ? : }
+
+ {data.outgoingTx ? (
+ <>
+ {data.tokenTx &&
+ data.tokenInfo.transactionType === 'GENESIS' ? (
+
+ ) : (
+
+ )}
+ >
+ ) : (
+
+ )}
+
{data.outgoingTx ? (
- Sent
+ <>
+ {data.tokenTx &&
+ data.tokenInfo.transactionType === 'GENESIS' ? (
+ Genesis
+ ) : (
+ Sent
+ )}
+ >
) : (
Received
)}
{txDate}
{data.tokenTx ? (
{data.tokenTx && data.tokenInfo ? (
<>
{currency.tokenIconsUrl !== '' ? (
}
/>
) : (
)}
{data.outgoingTx ? (
<>
-
- - {data.tokenInfo.qtySent.toString()}
- {data.tokenInfo.tokenTicker}
-
-
- {data.tokenInfo.tokenName}
-
+ {data.tokenInfo.transactionType ===
+ 'GENESIS' ? (
+ <>
+
+ +{' '}
+ {data.tokenInfo.qtyReceived.toString()}
+
+ {data.tokenInfo.tokenTicker}
+
+
+ {data.tokenInfo.tokenName}
+
+ >
+ ) : (
+ <>
+
+ -{' '}
+ {data.tokenInfo.qtySent.toString()}
+
+ {data.tokenInfo.tokenTicker}
+
+
+ {data.tokenInfo.tokenName}
+
+ >
+ )}
>
) : (
<>
+{' '}
{data.tokenInfo.qtyReceived.toString()}
{data.tokenInfo.tokenTicker}
{data.tokenInfo.tokenName}
>
)}
>
) : (
Token Tx
)}
) : (
<>
{data.outgoingTx ? (
<>
- {data.amountSent.toFixed(8)}
{currency.ticker}
{fiatPrice !== null &&
!isNaN(data.amountSent) && (
- $
{(
data.amountSent * fiatPrice
).toFixed(2)}{' '}
USD
)}
>
) : (
<>
+ {data.amountReceived.toFixed(8)}
{currency.ticker}
{fiatPrice !== null &&
!isNaN(data.amountReceived) && (
+ $
{(
data.amountReceived * fiatPrice
).toFixed(2)}{' '}
USD
)}
>
)}
>
)}
);
};
export default Tx;
diff --git a/web/cashtab/src/components/Wallet/Wallet.js b/web/cashtab/src/components/Wallet/Wallet.js
index 80faacd82..0ad3a4a9a 100644
--- a/web/cashtab/src/components/Wallet/Wallet.js
+++ b/web/cashtab/src/components/Wallet/Wallet.js
@@ -1,391 +1,358 @@
import React from 'react';
import styled from 'styled-components';
import { LinkOutlined, LoadingOutlined } from '@ant-design/icons';
import { WalletContext } from '@utils/context';
import { OnBoarding } from '@components/OnBoarding/OnBoarding';
import { QRCode } from '@components/Common/QRCode';
import { currency } from '@components/Common/Ticker.js';
import { Link } from 'react-router-dom';
import TokenList from './TokenList';
import TxHistory from './TxHistory';
import { CashLoader } from '@components/Common/CustomIcons';
import { formatBalance } from '@utils/cashMethods';
-
-export const LoadingCtn = styled.div`
- width: 100%;
- display: flex;
- align-items: center;
- justify-content: center;
- height: 400px;
- flex-direction: column;
-
- svg {
- width: 50px;
- height: 50px;
- fill: ${props => props.theme.primary};
- }
-`;
-
-export const BalanceHeader = styled.div`
- color: ${props => props.theme.wallet.text.primary};
- width: 100%;
- font-size: 30px;
- font-weight: bold;
- @media (max-width: 768px) {
- font-size: 23px;
- }
-`;
-
-export const BalanceHeaderFiat = styled.div`
- color: ${props => props.theme.wallet.text.secondary};
- width: 100%;
- font-size: 18px;
- margin-bottom: 20px;
- font-weight: bold;
- @media (max-width: 768px) {
- font-size: 16px;
- }
-`;
-
-export const ZeroBalanceHeader = styled.div`
- color: ${props => props.theme.wallet.text.primary};
- width: 100%;
- font-size: 14px;
- margin-bottom: 5px;
-`;
+import {
+ LoadingCtn,
+ BalanceHeader,
+ BalanceHeaderFiat,
+ ZeroBalanceHeader,
+} from '@components/Common/Atoms';
export const Tabs = styled.div`
margin: auto;
margin-bottom: 12px;
display: inline-block;
text-align: center;
`;
export const TabLabel = styled.button`
:focus,
:active {
outline: none;
}
border: none;
background: none;
font-size: 20px;
cursor: pointer;
@media (max-width: 400px) {
font-size: 16px;
}
${({ active, ...props }) =>
active &&
`
color: ${props.theme.primary};
`}
`;
export const TabLine = styled.div`
margin: auto;
transition: margin-left 0.5s ease-in-out, width 0.5s 0.1s;
height: 4px;
border-radius: 5px;
background-color: ${props => props.theme.primary};
pointer-events: none;
margin-left: 72%;
width: 28%;
${({ left, ...props }) =>
left &&
`
margin-left: 1%
width: 69%;
`}
`;
export const TabPane = styled.div`
${({ active }) =>
!active &&
`
display: none;
`}
`;
export const SwitchBtnCtn = styled.div`
display: flex;
align-items: center;
justify-content: center;
align-content: space-between;
margin-bottom: 15px;
.nonactiveBtn {
color: ${props => props.theme.wallet.text.secondary};
background: ${props =>
props.theme.wallet.switch.inactive.background} !important;
box-shadow: none !important;
}
.slpActive {
background: ${props =>
props.theme.wallet.switch.activeToken.background} !important;
box-shadow: ${props =>
props.theme.wallet.switch.activeToken.shadow} !important;
}
`;
export const SwitchBtn = styled.div`
font-weight: bold;
display: inline-block;
cursor: pointer;
color: ${props => props.theme.contrast};
font-size: 14px;
padding: 6px 0;
width: 100px;
margin: 0 1px;
text-decoration: none;
background: ${props => props.theme.primary};
box-shadow: ${props => props.theme.wallet.switch.activeCash.shadow};
user-select: none;
:first-child {
border-radius: 100px 0 0 100px;
}
:nth-child(2) {
border-radius: 0 100px 100px 0;
}
`;
export const Links = styled(Link)`
color: ${props => props.theme.wallet.text.secondary};
width: 100%;
font-size: 16px;
margin: 10px 0 20px 0;
border: 1px solid ${props => props.theme.wallet.text.secondary};
padding: 14px 0;
display: inline-block;
border-radius: 3px;
transition: all 200ms ease-in-out;
svg {
fill: ${props => props.theme.wallet.text.secondary};
}
:hover {
color: ${props => props.theme.primary};
border-color: ${props => props.theme.primary};
svg {
fill: ${props => props.theme.primary};
}
}
@media (max-width: 768px) {
padding: 10px 0;
font-size: 14px;
}
`;
export const ExternalLink = styled.a`
color: ${props => props.theme.wallet.text.secondary};
width: 100%;
font-size: 16px;
margin: 0 0 20px 0;
border: 1px solid ${props => props.theme.wallet.text.secondary};
padding: 14px 0;
display: inline-block;
border-radius: 3px;
transition: all 200ms ease-in-out;
svg {
fill: ${props => props.theme.wallet.text.secondary};
transition: all 200ms ease-in-out;
}
:hover {
color: ${props => props.theme.primary};
border-color: ${props => props.theme.primary};
svg {
fill: ${props => props.theme.primary};
}
}
@media (max-width: 768px) {
padding: 10px 0;
font-size: 14px;
}
`;
const WalletInfo = () => {
const ContextValue = React.useContext(WalletContext);
const { wallet, fiatPrice, apiError } = ContextValue;
let balances;
let parsedTxHistory;
let tokens;
// use parameters from wallet.state object and not legacy separate parameters, if they are in state
// handle 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 paramsInWalletState = wallet.state ? Object.keys(wallet.state) : [];
// If wallet.state includes balances and parsedTxHistory params, use these
// These are saved in indexedDb in the latest version of the app, hence accessible more quickly
if (
paramsInWalletState.includes('balances') &&
paramsInWalletState.includes('parsedTxHistory') &&
paramsInWalletState.includes('tokens')
) {
balances = wallet.state.balances;
parsedTxHistory = wallet.state.parsedTxHistory;
tokens = wallet.state.tokens;
} else {
// If balances and parsedTxHistory are not in the wallet.state object, load them from Context
// This is how the app used to work
balances = ContextValue.balances;
parsedTxHistory = ContextValue.parsedTxHistory;
tokens = ContextValue.tokens;
}
const [address, setAddress] = React.useState('cashAddress');
const [activeTab, setActiveTab] = React.useState('txHistory');
const hasHistory = parsedTxHistory && parsedTxHistory.length > 0;
const handleChangeAddress = () => {
setAddress(address === 'cashAddress' ? 'slpAddress' : 'cashAddress');
};
return (
<>
{!balances.totalBalance && !apiError && !hasHistory ? (
<>
🎉
Congratulations on your new wallet!{' '}
🎉
Start using the wallet immediately to receive{' '}
{currency.ticker} payments, or load it up with{' '}
{currency.ticker} to send to others
0 {currency.ticker}
>
) : (
<>
{formatBalance(balances.totalBalance)} {currency.ticker}
{fiatPrice !== null && !isNaN(balances.totalBalance) && (
${(balances.totalBalance * fiatPrice).toFixed(2)}{' '}
USD
)}
>
)}
{apiError && (
<>
An error occurred on our end.
Re-establishing connection...
>
)}
{wallet &&
((wallet.Path245 && wallet.Path145) || wallet.Path1899) && (
<>
{wallet.Path1899 ? (
) : (
)}
>
)}
handleChangeAddress()}
className={
address !== 'cashAddress' ? 'nonactiveBtn' : null
}
>
{currency.ticker}
handleChangeAddress()}
className={
address === 'cashAddress' ? 'nonactiveBtn' : 'slpActive'
}
>
{currency.tokenTicker}
{hasHistory && parsedTxHistory && (
<>
setActiveTab('txHistory')}
>
Transaction History
setActiveTab('tokens')}
>
Tokens
More transactions
{tokens && tokens.length > 0 ? (
-
+
) : (
Tokens sent to your {currency.tokenTicker}{' '}
address will appear here
)}
>
)}
>
);
};
const Wallet = () => {
const ContextValue = React.useContext(WalletContext);
const { wallet, loading } = ContextValue;
return (
<>
{loading ? (
) : (
<>{wallet.Path1899 ? : }>
)}
>
);
};
export default Wallet;
diff --git a/web/cashtab/src/components/Wallet/__tests__/__snapshots__/Wallet.test.js.snap b/web/cashtab/src/components/Wallet/__tests__/__snapshots__/Wallet.test.js.snap
index e59dd5e81..cfe1dac86 100644
--- a/web/cashtab/src/components/Wallet/__tests__/__snapshots__/Wallet.test.js.snap
+++ b/web/cashtab/src/components/Wallet/__tests__/__snapshots__/Wallet.test.js.snap
@@ -1,609 +1,609 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP @generated
exports[`Wallet with BCH balances 1`] = `
Array [
0.06047469
BCHA
,
$
NaN
USD
,
,
,
]
`;
exports[`Wallet with BCH balances and tokens 1`] = `
Array [
0.06047469
BCHA
,
$
NaN
USD
,
,
,
]
`;
exports[`Wallet with BCH balances and tokens and state field 1`] = `
Array [
0.06047469
BCHA
,
$
NaN
USD
,
,
,
]
`;
exports[`Wallet with BCH balances and tokens and state field, but no params in state 1`] = `
Array [
0.06047469
BCHA
,
$
NaN
USD
,
,
,
]
`;
exports[`Wallet without BCH balance 1`] = `
Array [
🎉
Congratulations on your new wallet!
🎉
Start using the wallet immediately to receive
BCHA
payments, or load it up with
BCHA
to send to others
,
0
BCHA
,
,
,
]
`;
exports[`Without wallet defined 1`] = `
Array [
Welcome to Cashtab!
,
Cashtab is an
open source,
non-custodial web wallet for
Bitcoin ABC
.
Want to learn more?
Check out the Cashtab documentation.
,
,
,
]
`;
diff --git a/web/cashtab/src/hooks/__mocks__/createToken.js b/web/cashtab/src/hooks/__mocks__/createToken.js
new file mode 100644
index 000000000..d5e5cb03b
--- /dev/null
+++ b/web/cashtab/src/hooks/__mocks__/createToken.js
@@ -0,0 +1,56 @@
+// @generated
+export default {
+ invalidWallet: {},
+ wallet: {
+ Path1899: {
+ cashAddress:
+ 'bitcoincash:qpuvjl7l3crt3apc62gmtf49pfsluu7s9gsex3qnhn',
+ slpAddress:
+ 'simpleledger:qpuvjl7l3crt3apc62gmtf49pfsluu7s9guzd24nfd',
+ fundingWif: 'L2gH81AegmBdnvEZuUpnd3robG8NjBaVjPddWrVD4169wS6Mqyxn',
+ fundingAddress:
+ 'simpleledger:qpuvjl7l3crt3apc62gmtf49pfsluu7s9guzd24nfd',
+ legacyAddress: '1C1fUT99KT4SjbKjCE2fSCdhc6Bvj5gQjG',
+ },
+ tokens: [],
+ state: {
+ balances: [],
+ utxos: [],
+ hydratedUtxoDetails: [],
+ tokens: [],
+ slpBalancesAndUtxos: {
+ nonSlpUtxos: [
+ {
+ height: 0,
+ tx_hash:
+ 'e0d6d7d46d5fc6aaa4512a7aca9223c6d7ca30b8253dee1b40b8978fe7dc501e',
+ tx_pos: 0,
+ value: 1000000,
+ txid:
+ 'e0d6d7d46d5fc6aaa4512a7aca9223c6d7ca30b8253dee1b40b8978fe7dc501e',
+ vout: 0,
+ isValid: false,
+ address:
+ 'bitcoincash:qpuvjl7l3crt3apc62gmtf49pfsluu7s9gsex3qnhn',
+ wif:
+ 'L2gH81AegmBdnvEZuUpnd3robG8NjBaVjPddWrVD4169wS6Mqyxn',
+ },
+ ],
+ },
+ },
+ },
+ configObj: {
+ name: 'Cashtab Unit Test Token',
+ ticker: 'CUTT',
+ documentUrl: 'https://cashtabapp.com/',
+ decimals: '2',
+ initialQty: '100',
+ documentHash: '',
+ mintBatonVout: null,
+ },
+ expectedTxId:
+ '9e9738e9ac3ff202736bf7775f875ebae6f812650df577a947c20c52475e43da',
+ expectedHex: [
+ '02000000011e50dce78f97b8401bee3d25b830cad7c62392ca7a2a51a4aac65f6dd4d7d6e0000000006a4730440220150c19f4274b30415174c7517ff5e3e79c224ac6aff6967537a8e1ab71880bbb0220537a8c7a91672fe3dc2f703dcb319c94a1717e220b52111f406f0d80adeb4c15412102322fe90c5255fe37ab321c386f9446a86e80c3940701d430f22325094fdcec60ffffffff030000000000000000546a04534c500001010747454e455349530443555454174361736874616220556e6974205465737420546f6b656e1768747470733a2f2f636173687461626170702e636f6d2f4c0001024c0008000000000000271022020000000000001976a91478c97fdf8e06b8f438d291b5a6a50a61fe73d02a88ac283d0f00000000001976a91478c97fdf8e06b8f438d291b5a6a50a61fe73d02a88ac00000000',
+ ],
+};
diff --git a/web/cashtab/src/hooks/__mocks__/mockParseTokenInfoForTxHistory.js b/web/cashtab/src/hooks/__mocks__/mockParseTokenInfoForTxHistory.js
index fcc89b818..4dac1b090 100644
--- a/web/cashtab/src/hooks/__mocks__/mockParseTokenInfoForTxHistory.js
+++ b/web/cashtab/src/hooks/__mocks__/mockParseTokenInfoForTxHistory.js
@@ -1,139 +1,200 @@
+// @generated
+
export const tokenSendWdt = {
txid: 'b923fba5b011df438c96f7f8f715fcf2b9ac2f96ea73139885e00aee4361f98f',
parsedTx: {
txid:
'b923fba5b011df438c96f7f8f715fcf2b9ac2f96ea73139885e00aee4361f98f',
confirmations: 15,
height: 679246,
blocktime: 1616790444,
amountSent: 4.94409725,
amountReceived: 0,
tokenTx: true,
outgoingTx: true,
destinationAddress:
'bitcoincash:qpmytrdsakt0axrrlswvaj069nat3p9s7ct4lsf8k9',
},
tokenInfo: {
versionType: 1,
tokenName:
'Test Token With Exceptionally Long Name For CSS And Style Revisions',
tokenTicker: 'WDT',
transactionType: 'SEND',
tokenIdHex:
'7443f7c831cdf2b2b04d5f0465ed0bcf348582675b0e4f17906438c232c22f3d',
sendOutputs: ['0', '22200000000', '47658742120385570'],
sendInputsFull: [
{
address:
'simpleledger:qpmytrdsakt0axrrlswvaj069nat3p9s7c8w5tu8gm',
},
{
address:
'simpleledger:qpv9fx6mjdpgltygudnpw3tvmxdyzx7savm6ushssz',
},
{
address:
'simpleledger:qpmytrdsakt0axrrlswvaj069nat3p9s7c8w5tu8gm',
},
{
address:
'simpleledger:qpmytrdsakt0axrrlswvaj069nat3p9s7c8w5tu8gm',
},
{
address:
'simpleledger:qpmytrdsakt0axrrlswvaj069nat3p9s7c8w5tu8gm',
},
{
address:
'simpleledger:qpmytrdsakt0axrrlswvaj069nat3p9s7c8w5tu8gm',
},
],
sendOutputsFull: [
{
address:
'simpleledger:qq7h7thq7seggqawtnlus5f2k62m7l07vucv9ux0ss',
amount: '222',
},
{
address:
'simpleledger:qpmytrdsakt0axrrlswvaj069nat3p9s7c8w5tu8gm',
amount: '476587421.2038557',
},
],
},
cashtabTokenInfo: {
qtyReceived: '0',
qtySent: '222',
tokenId:
'7443f7c831cdf2b2b04d5f0465ed0bcf348582675b0e4f17906438c232c22f3d',
tokenName:
'Test Token With Exceptionally Long Name For CSS And Style Revisions',
tokenTicker: 'WDT',
+ transactionType: 'SEND',
},
};
export const tokenSendHonk = {
txid: '82845d3c814d715b2c36aea0b076cc03815469a9c172c5bab7fc6a9f71b1906d',
parsedTx: '',
tokenInfo: {},
cashtabTokenInfo: '',
};
export const tokenReceiveTBS = {
txid: '82845d3c814d715b2c36aea0b076cc03815469a9c172c5bab7fc6a9f71b1906d',
parsedTx: {
txid:
'82845d3c814d715b2c36aea0b076cc03815469a9c172c5bab7fc6a9f71b1906d',
confirmations: 269,
height: 678992,
blocktime: 1616610925,
amountSent: 0,
amountReceived: 0.00000546,
tokenTx: true,
outgoingTx: false,
destinationAddress:
'bitcoincash:qpmytrdsakt0axrrlswvaj069nat3p9s7ct4lsf8k9',
},
tokenInfo: {
versionType: 1,
tokenName: 'Cash Tab Points',
tokenTicker: 'CTP',
transactionType: 'SEND',
tokenIdHex:
'bef614aac85c0c866f4d39e4d12a96851267d38d1bca5bdd6488bbd42e28b6b1',
sendOutputs: ['0', '112345678.9', '30887654321.100002'],
sendInputsFull: [
{
address:
'simpleledger:qphpmfj0qn7znklqhrfn5dq7qh36l3vxavs27peqxp',
},
{
address:
'simpleledger:qphpmfj0qn7znklqhrfn5dq7qh36l3vxavs27peqxp',
},
{
address:
'simpleledger:qphpmfj0qn7znklqhrfn5dq7qh36l3vxavs27peqxp',
},
],
sendOutputsFull: [
{
address:
'simpleledger:qpmytrdsakt0axrrlswvaj069nat3p9s7c8w5tu8gm',
amount: '1.123456789',
},
{
address:
'simpleledger:qphpmfj0qn7znklqhrfn5dq7qh36l3vxavs27peqxp',
amount: '308.876543211',
},
],
},
cashtabTokenInfo: {
qtySent: '0',
qtyReceived: '1.123456789',
tokenId:
'bef614aac85c0c866f4d39e4d12a96851267d38d1bca5bdd6488bbd42e28b6b1',
tokenName: 'Cash Tab Points',
tokenTicker: 'CTP',
+ transactionType: 'SEND',
+ },
+};
+
+export const tokenGenesisCashtabMintAlpha = {
+ txid: '45f0ff5cae7e89da6b96c26c8c48a959214c5f0e983e78d0925f8956ca8848c6',
+ parsedTx: {
+ txid:
+ '45f0ff5cae7e89da6b96c26c8c48a959214c5f0e983e78d0925f8956ca8848c6',
+ confirmations: 11,
+ height: 685170,
+ blocktime: 1620250206,
+ amountSent: 0,
+ amountReceived: 0,
+ tokenTx: true,
+ outgoingTx: true,
+ destinationAddress:
+ 'bitcoincash:qz2708636snqhsxu8wnlka78h6fdp77ar5ulhz04hr',
+ tokenInfo: {
+ qtySent: '0',
+ qtyReceived: '55.55555',
+ tokenId:
+ '45f0ff5cae7e89da6b96c26c8c48a959214c5f0e983e78d0925f8956ca8848c6',
+ tokenName: 'CashtabMintAlpha',
+ tokenTicker: 'CMA',
+ transactionType: 'GENESIS',
+ },
+ },
+ tokenInfo: {
+ versionType: 1,
+ tokenName: 'CashtabMintAlpha',
+ tokenTicker: 'CMA',
+ transactionType: 'GENESIS',
+ tokenIdHex:
+ '45f0ff5cae7e89da6b96c26c8c48a959214c5f0e983e78d0925f8956ca8848c6',
+ sendOutputs: ['0', '5555555000'],
+ sendInputsFull: [
+ {
+ address:
+ 'simpleledger:qz2708636snqhsxu8wnlka78h6fdp77ar5syue64fa',
+ },
+ ],
+ sendOutputsFull: [
+ {
+ address:
+ 'simpleledger:qz2708636snqhsxu8wnlka78h6fdp77ar5syue64fa',
+ amount: '55.55555',
+ },
+ ],
+ },
+ cashtabTokenInfo: {
+ qtyReceived: '55.55555',
+ qtySent: '0',
+ tokenId:
+ '45f0ff5cae7e89da6b96c26c8c48a959214c5f0e983e78d0925f8956ca8848c6',
+ tokenName: 'CashtabMintAlpha',
+ tokenTicker: 'CMA',
+ transactionType: 'GENESIS',
},
};
diff --git a/web/cashtab/src/hooks/__tests__/useBCH.test.js b/web/cashtab/src/hooks/__tests__/useBCH.test.js
index 8164052be..be4131166 100644
--- a/web/cashtab/src/hooks/__tests__/useBCH.test.js
+++ b/web/cashtab/src/hooks/__tests__/useBCH.test.js
@@ -1,361 +1,410 @@
/* eslint-disable no-native-reassign */
import useBCH from '../useBCH';
import mockReturnGetHydratedUtxoDetails from '../__mocks__/mockReturnGetHydratedUtxoDetails';
import mockReturnGetSlpBalancesAndUtxos from '../__mocks__/mockReturnGetSlpBalancesAndUtxos';
import mockReturnGetHydratedUtxoDetailsWithZeroBalance from '../__mocks__/mockReturnGetHydratedUtxoDetailsWithZeroBalance';
import mockReturnGetSlpBalancesAndUtxosNoZeroBalance from '../__mocks__/mockReturnGetSlpBalancesAndUtxosNoZeroBalance';
import sendBCHMock from '../__mocks__/sendBCH';
+import createTokenMock from '../__mocks__/createToken';
import mockTxHistory from '../__mocks__/mockTxHistory';
import mockFlatTxHistory from '../__mocks__/mockFlatTxHistory';
import mockTxDataWithPassthrough from '../__mocks__/mockTxDataWithPassthrough';
import {
flattenedHydrateUtxosResponse,
legacyHydrateUtxosResponse,
} from '../__mocks__/mockHydrateUtxosBatched';
import {
tokenSendWdt,
tokenReceiveTBS,
+ tokenGenesisCashtabMintAlpha,
} from '../__mocks__/mockParseTokenInfoForTxHistory';
import {
mockSentCashTx,
mockReceivedCashTx,
mockSentTokenTx,
mockReceivedTokenTx,
} from '../__mocks__/mockParsedTxs';
import BCHJS from '@psf/bch-js'; // TODO: should be removed when external lib not needed anymore
import { currency } from '../../components/Common/Ticker';
import BigNumber from 'bignumber.js';
describe('useBCH hook', () => {
it('gets Rest Api Url on testnet', () => {
process = {
env: {
REACT_APP_NETWORK: `testnet`,
REACT_APP_BCHA_APIS:
'https://rest.kingbch.com/v3/,https://wallet-service-prod.bitframe.org/v3/,notevenaurl,https://rest.kingbch.com/v3/',
REACT_APP_BCHA_APIS_TEST:
'https://free-test.fullstack.cash/v3/',
},
};
const { getRestUrl } = useBCH();
const expectedApiUrl = `https://free-test.fullstack.cash/v3/`;
expect(getRestUrl(0)).toBe(expectedApiUrl);
});
it('gets primary Rest API URL on mainnet', () => {
process = {
env: {
REACT_APP_BCHA_APIS:
'https://rest.kingbch.com/v3/,https://wallet-service-prod.bitframe.org/v3/,notevenaurl,https://rest.kingbch.com/v3/',
REACT_APP_NETWORK: 'mainnet',
},
};
const { getRestUrl } = useBCH();
const expectedApiUrl = `https://rest.kingbch.com/v3/`;
expect(getRestUrl(0)).toBe(expectedApiUrl);
});
it('calculates fee correctly for 2 P2PKH outputs', () => {
const { calcFee } = useBCH();
const BCH = new BCHJS();
const utxosMock = [{}, {}];
expect(calcFee(BCH, utxosMock, 2, 1.01)).toBe(378);
});
it('gets SLP and BCH balances and utxos from hydrated utxo details', async () => {
const { getSlpBalancesAndUtxos } = useBCH();
const result = await getSlpBalancesAndUtxos(
mockReturnGetHydratedUtxoDetails,
);
expect(result).toStrictEqual(mockReturnGetSlpBalancesAndUtxos);
});
it(`Ignores SLP utxos with utxo.tokenQty === '0'`, async () => {
const { getSlpBalancesAndUtxos } = useBCH();
const result = await getSlpBalancesAndUtxos(
mockReturnGetHydratedUtxoDetailsWithZeroBalance,
);
expect(result).toStrictEqual(
mockReturnGetSlpBalancesAndUtxosNoZeroBalance,
);
});
it(`Parses flattened batched hydrateUtxosResponse to yield same result as legacy unbatched hydrateUtxosResponse`, async () => {
const { getSlpBalancesAndUtxos } = useBCH();
const batchedResult = await getSlpBalancesAndUtxos(
flattenedHydrateUtxosResponse,
);
const legacyResult = await getSlpBalancesAndUtxos(
legacyHydrateUtxosResponse,
);
expect(batchedResult).toStrictEqual(legacyResult);
});
it('sends BCH correctly', async () => {
const { sendBch } = useBCH();
const BCH = new BCHJS();
const {
expectedTxId,
expectedHex,
utxos,
wallet,
destinationAddress,
sendAmount,
} = sendBCHMock;
BCH.RawTransactions.sendRawTransaction = jest
.fn()
.mockResolvedValue(expectedTxId);
expect(
await sendBch(
BCH,
wallet,
utxos,
destinationAddress,
sendAmount,
1.01,
),
).toBe(`${currency.blockExplorerUrl}/tx/${expectedTxId}`);
expect(BCH.RawTransactions.sendRawTransaction).toHaveBeenCalledWith(
expectedHex,
);
});
it('sends BCH correctly with callback', async () => {
const { sendBch } = useBCH();
const BCH = new BCHJS();
const callback = jest.fn();
const {
expectedTxId,
expectedHex,
utxos,
wallet,
destinationAddress,
sendAmount,
} = sendBCHMock;
BCH.RawTransactions.sendRawTransaction = jest
.fn()
.mockResolvedValue(expectedTxId);
expect(
await sendBch(
BCH,
wallet,
utxos,
destinationAddress,
sendAmount,
1.01,
callback,
),
).toBe(`${currency.blockExplorerUrl}/tx/${expectedTxId}`);
expect(BCH.RawTransactions.sendRawTransaction).toHaveBeenCalledWith(
expectedHex,
);
expect(callback).toHaveBeenCalledWith(expectedTxId);
});
it(`Throws error if called trying to send one base unit ${currency.ticker} more than available in utxo set`, async () => {
const { sendBch } = useBCH();
const BCH = new BCHJS();
const { expectedTxId, utxos, wallet, destinationAddress } = sendBCHMock;
const expectedTxFeeInSats = 229;
BCH.RawTransactions.sendRawTransaction = jest
.fn()
.mockResolvedValue(expectedTxId);
const oneBaseUnitMoreThanBalance = new BigNumber(utxos[0].value)
.minus(expectedTxFeeInSats)
.plus(1)
.div(10 ** currency.cashDecimals)
.toString();
const failedSendBch = sendBch(
BCH,
wallet,
utxos,
destinationAddress,
oneBaseUnitMoreThanBalance,
1.01,
);
expect(failedSendBch).rejects.toThrow(new Error('Insufficient funds'));
const nullValuesSendBch = await sendBch(
BCH,
wallet,
utxos,
destinationAddress,
null,
1.01,
);
expect(nullValuesSendBch).toBe(null);
});
it('Throws error on attempt to send one satoshi less than backend dust limit', async () => {
const { sendBch } = useBCH();
const BCH = new BCHJS();
const { expectedTxId, utxos, wallet, destinationAddress } = sendBCHMock;
BCH.RawTransactions.sendRawTransaction = jest
.fn()
.mockResolvedValue(expectedTxId);
const failedSendBch = sendBch(
BCH,
wallet,
utxos,
destinationAddress,
new BigNumber(currency.dust)
.minus(new BigNumber('0.00000001'))
.toString(),
1.01,
);
expect(failedSendBch).rejects.toThrow(new Error('dust'));
const nullValuesSendBch = await sendBch(
BCH,
wallet,
utxos,
destinationAddress,
null,
1.01,
);
expect(nullValuesSendBch).toBe(null);
});
it('receives errors from the network and parses it', async () => {
const { sendBch } = useBCH();
const BCH = new BCHJS();
const { sendAmount, utxos, wallet, destinationAddress } = sendBCHMock;
BCH.RawTransactions.sendRawTransaction = jest
.fn()
.mockImplementation(async () => {
throw new Error('insufficient priority (code 66)');
});
const insufficientPriority = sendBch(
BCH,
wallet,
utxos,
destinationAddress,
sendAmount,
1.01,
);
await expect(insufficientPriority).rejects.toThrow(
new Error('insufficient priority (code 66)'),
);
BCH.RawTransactions.sendRawTransaction = jest
.fn()
.mockImplementation(async () => {
throw new Error('txn-mempool-conflict (code 18)');
});
const txnMempoolConflict = sendBch(
BCH,
wallet,
utxos,
destinationAddress,
sendAmount,
1.01,
);
await expect(txnMempoolConflict).rejects.toThrow(
new Error('txn-mempool-conflict (code 18)'),
);
BCH.RawTransactions.sendRawTransaction = jest
.fn()
.mockImplementation(async () => {
throw new Error('Network Error');
});
const networkError = sendBch(
BCH,
wallet,
utxos,
destinationAddress,
sendAmount,
1.01,
);
await expect(networkError).rejects.toThrow(new Error('Network Error'));
BCH.RawTransactions.sendRawTransaction = jest
.fn()
.mockImplementation(async () => {
const err = new Error(
'too-long-mempool-chain, too many unconfirmed ancestors [limit: 25] (code 64)',
);
throw err;
});
const tooManyAncestorsMempool = sendBch(
BCH,
wallet,
utxos,
destinationAddress,
sendAmount,
1.01,
);
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;
+
+ BCH.RawTransactions.sendRawTransaction = jest
+ .fn()
+ .mockResolvedValue(expectedTxId);
+ expect(
+ await createToken(BCH, wallet, currency.defaultFee, configObj),
+ ).toBe(`${currency.tokenExplorerUrl}/tx/${expectedTxId}`);
+ expect(BCH.RawTransactions.sendRawTransaction).toHaveBeenCalledWith(
+ expectedHex,
+ );
+ });
+
+ 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 invalidWalletTokenCreation = createToken(
+ BCH,
+ invalidWallet,
+ currency.defaultFee,
+ configObj,
+ );
+ await expect(invalidWalletTokenCreation).rejects.toThrow(
+ new Error('Invalid wallet'),
+ );
+ });
+
it('Correctly flattens transaction history', () => {
const { flattenTransactions } = useBCH();
expect(flattenTransactions(mockTxHistory, 10)).toStrictEqual(
mockFlatTxHistory,
);
});
it(`Correctly parses a "send ${currency.ticker}" transaction`, () => {
const { parseTxData } = useBCH();
expect(parseTxData([mockTxDataWithPassthrough[0]])).toStrictEqual(
mockSentCashTx,
);
});
it(`Correctly parses a "receive ${currency.ticker}" transaction`, () => {
const { parseTxData } = useBCH();
expect(parseTxData([mockTxDataWithPassthrough[5]])).toStrictEqual(
mockReceivedCashTx,
);
});
it(`Correctly parses a "send ${currency.tokenTicker}" transaction`, () => {
const { parseTxData } = useBCH();
expect(parseTxData([mockTxDataWithPassthrough[1]])).toStrictEqual(
mockSentTokenTx,
);
});
it(`Correctly parses a "receive ${currency.tokenTicker}" transaction`, () => {
const { parseTxData } = useBCH();
expect(parseTxData([mockTxDataWithPassthrough[3]])).toStrictEqual(
mockReceivedTokenTx,
);
});
it(`Correctly parses a "send ${currency.tokenTicker}" transaction with token details`, () => {
const { parseTokenInfoForTxHistory } = useBCH();
expect(
parseTokenInfoForTxHistory(
tokenSendWdt.parsedTx,
tokenSendWdt.tokenInfo,
),
).toStrictEqual(tokenSendWdt.cashtabTokenInfo);
});
it(`Correctly parses a "receive ${currency.tokenTicker}" transaction with token details and 9 decimals of precision`, () => {
const { parseTokenInfoForTxHistory } = useBCH();
expect(
parseTokenInfoForTxHistory(
tokenReceiveTBS.parsedTx,
tokenReceiveTBS.tokenInfo,
),
).toStrictEqual(tokenReceiveTBS.cashtabTokenInfo);
});
+
+ it(`Correctly parses a "GENESIS ${currency.tokenTicker}" transaction with token details`, () => {
+ const { parseTokenInfoForTxHistory } = useBCH();
+ expect(
+ parseTokenInfoForTxHistory(
+ tokenGenesisCashtabMintAlpha.parsedTx,
+ tokenGenesisCashtabMintAlpha.tokenInfo,
+ ),
+ ).toStrictEqual(tokenGenesisCashtabMintAlpha.cashtabTokenInfo);
+ });
});
diff --git a/web/cashtab/src/hooks/useBCH.js b/web/cashtab/src/hooks/useBCH.js
index 1627b1ec6..54bc4de26 100644
--- a/web/cashtab/src/hooks/useBCH.js
+++ b/web/cashtab/src/hooks/useBCH.js
@@ -1,853 +1,987 @@
import BigNumber from 'bignumber.js';
import { currency } from '@components/Common/Ticker';
import {
toSmallestDenomination,
batchArray,
flattenBatchedHydratedUtxos,
+ isValidStoredWallet,
} from '@utils/cashMethods';
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 flattenTransactions = (
txHistory,
txCount = currency.txHistoryCount,
) => {
/*
Convert txHistory, format
[{address: '', transactions: [{height: '', tx_hash: ''}, ...{}]}, {}, {}]
to flatTxHistory
[{txid: '', blockheight: '', address: ''}]
sorted by blockheight, newest transactions to oldest transactions
*/
let flatTxHistory = [];
let includedTxids = [];
for (let i = 0; i < txHistory.length; i += 1) {
const { address, transactions } = txHistory[i];
for (let j = transactions.length - 1; j >= 0; j -= 1) {
let flatTx = {};
flatTx.address = address;
// If tx is unconfirmed, give arbitrarily high blockheight
flatTx.height =
transactions[j].height <= 0
? 10000000
: transactions[j].height;
flatTx.txid = transactions[j].tx_hash;
// Only add this tx if the same transaction is not already in the array
// This edge case can happen with older wallets, txs can be on multiple paths
if (!includedTxids.includes(flatTx.txid)) {
includedTxids.push(flatTx.txid);
flatTxHistory.push(flatTx);
}
}
}
// Sort with most recent transaction at index 0
flatTxHistory.sort((a, b) => b.height - a.height);
// Only return 10
return flatTxHistory.splice(0, txCount);
};
const parseTxData = txData => {
/*
Desired output
[
{
txid: '',
type: send, receive
receivingAddress: '',
quantity: amount bcha
token: true/false
tokenInfo: {
tokenId:
tokenQty:
txType: mint, send, other
}
}
]
*/
const parsedTxHistory = [];
for (let i = 0; i < txData.length; i += 1) {
const tx = txData[i];
const parsedTx = {};
// Move over info that does not need to be calculated
parsedTx.txid = tx.txid;
parsedTx.confirmations = tx.confirmations;
parsedTx.height = tx.height;
parsedTx.blocktime = tx.blocktime;
let amountSent = 0;
let amountReceived = 0;
// Assume an incoming transaction
let outgoingTx = false;
let tokenTx = false;
let destinationAddress = tx.address;
// If vin includes tx address, this is an outgoing tx
// Note that with bch-input data, we do not have input amounts
for (let j = 0; j < tx.vin.length; j += 1) {
const thisInput = tx.vin[j];
if (thisInput.address === tx.address) {
// This is an outgoing transaction
outgoingTx = true;
}
}
// Iterate over vout to find how much was sent or received
for (let j = 0; j < tx.vout.length; j += 1) {
const thisOutput = tx.vout[j];
// If there is no addresses object in the output, OP_RETURN or token tx
if (
!Object.keys(thisOutput.scriptPubKey).includes('addresses')
) {
// For now, assume this is a token tx
tokenTx = true;
continue;
}
if (
thisOutput.scriptPubKey.addresses &&
thisOutput.scriptPubKey.addresses[0] === tx.address
) {
if (outgoingTx) {
// This amount is change
continue;
}
amountReceived += thisOutput.value;
} else if (outgoingTx) {
amountSent += thisOutput.value;
// Assume there's only one destination address, i.e. it was sent by a Cashtab wallet
destinationAddress = thisOutput.scriptPubKey.addresses[0];
}
}
// Construct parsedTx
parsedTx.txid = tx.txid;
parsedTx.amountSent = amountSent;
parsedTx.amountReceived = amountReceived;
parsedTx.tokenTx = tokenTx;
parsedTx.outgoingTx = outgoingTx;
parsedTx.destinationAddress = destinationAddress;
parsedTxHistory.push(parsedTx);
}
return parsedTxHistory;
};
const getTxHistory = async (BCH, addresses) => {
let txHistoryResponse;
try {
//console.log(`API Call: BCH.Electrumx.utxo(addresses)`);
//console.log(addresses);
txHistoryResponse = await BCH.Electrumx.transactions(addresses);
//console.log(`BCH.Electrumx.transactions(addresses) succeeded`);
//console.log(`txHistoryResponse`, txHistoryResponse);
if (txHistoryResponse.success && txHistoryResponse.transactions) {
return txHistoryResponse.transactions;
} else {
// eslint-disable-next-line no-throw-literal
throw new Error('Error in getTxHistory');
}
} catch (err) {
console.log(`Error in BCH.Electrumx.transactions(addresses):`);
console.log(err);
return err;
}
};
const getTxDataWithPassThrough = async (BCH, flatTx) => {
// necessary as BCH.RawTransactions.getTxData does not return address or blockheight
const txDataWithPassThrough = await BCH.RawTransactions.getTxData(
flatTx.txid,
);
txDataWithPassThrough.height = flatTx.height;
txDataWithPassThrough.address = flatTx.address;
return txDataWithPassThrough;
};
const getTxData = async (BCH, txHistory) => {
// Flatten tx history
let flatTxs = flattenTransactions(txHistory);
// Build array of promises to get tx data for all 10 transactions
let txDataPromises = [];
for (let i = 0; i < flatTxs.length; i += 1) {
const txDataPromise = await getTxDataWithPassThrough(
BCH,
flatTxs[i],
);
txDataPromises.push(txDataPromise);
}
// Get txData for the 10 most recent transactions
let txDataPromiseResponse;
try {
txDataPromiseResponse = await Promise.all(txDataPromises);
const parsed = parseTxData(txDataPromiseResponse);
+
return parsed;
} catch (err) {
console.log(`Error in Promise.all(txDataPromises):`);
console.log(err);
return err;
}
};
const parseTokenInfoForTxHistory = (parsedTx, tokenInfo) => {
// Scan over inputs to find out originating addresses
- const { sendInputsFull, sendOutputsFull } = tokenInfo;
+ const { transactionType, sendInputsFull, sendOutputsFull } = tokenInfo;
const sendingTokenAddresses = [];
for (let i = 0; i < sendInputsFull.length; i += 1) {
const sendingAddress = sendInputsFull[i].address;
sendingTokenAddresses.push(sendingAddress);
}
// Scan over outputs to find out how much was sent
let qtySent = new BigNumber(0);
let qtyReceived = new BigNumber(0);
for (let i = 0; i < sendOutputsFull.length; i += 1) {
if (sendingTokenAddresses.includes(sendOutputsFull[i].address)) {
- // token change
+ // token change and should be ignored, unless it's a genesis transaction
+ // then this is the amount created
+ if (transactionType === 'GENESIS') {
+ qtyReceived = qtyReceived.plus(
+ new BigNumber(sendOutputsFull[i].amount),
+ );
+ }
continue;
}
if (parsedTx.outgoingTx) {
qtySent = qtySent.plus(
new BigNumber(sendOutputsFull[i].amount),
);
} else {
qtyReceived = qtyReceived.plus(
new BigNumber(sendOutputsFull[i].amount),
);
}
}
const cashtabTokenInfo = {};
cashtabTokenInfo.qtySent = qtySent.toString();
cashtabTokenInfo.qtyReceived = qtyReceived.toString();
cashtabTokenInfo.tokenId = tokenInfo.tokenIdHex;
cashtabTokenInfo.tokenName = tokenInfo.tokenName;
cashtabTokenInfo.tokenTicker = tokenInfo.tokenTicker;
+ cashtabTokenInfo.transactionType = transactionType;
return cashtabTokenInfo;
};
const addTokenTxDataToSingleTx = async (BCH, parsedTx) => {
// Accept one parsedTx
// If it's a token tx, do an API call to get token info and return it
// If it's not a token tx, just return it
if (!parsedTx.tokenTx) {
return parsedTx;
}
const tokenData = await BCH.SLP.Utils.txDetails(parsedTx.txid);
const { tokenInfo } = tokenData;
parsedTx.tokenInfo = parseTokenInfoForTxHistory(parsedTx, tokenInfo);
return parsedTx;
};
const addTokenTxData = async (BCH, parsedTxs) => {
// Collect all txids for token transactions into array of promises
// Promise.all to get their tx history
// Add a tokeninfo object to parsedTxs for token txs
// Get txData for the 10 most recent transactions
// Build array of promises to get tx data for all 10 transactions
let tokenTxDataPromises = [];
for (let i = 0; i < parsedTxs.length; i += 1) {
const txDataPromise = await addTokenTxDataToSingleTx(
BCH,
parsedTxs[i],
);
tokenTxDataPromises.push(txDataPromise);
}
let tokenTxDataPromiseResponse;
try {
tokenTxDataPromiseResponse = await Promise.all(tokenTxDataPromises);
return tokenTxDataPromiseResponse;
} catch (err) {
console.log(`Error in Promise.all(tokenTxDataPromises):`);
console.log(err);
return err;
}
};
// Split out the BCH.Electrumx.utxo(addresses) call from the getSlpBalancesandUtxos function
// If utxo set has not changed, you do not need to hydrate the utxo set
// This drastically reduces calls to the API
const getUtxos = async (BCH, addresses) => {
let utxosResponse;
try {
//console.log(`API Call: BCH.Electrumx.utxo(addresses)`);
//console.log(addresses);
utxosResponse = await BCH.Electrumx.utxo(addresses);
//console.log(`BCH.Electrumx.utxo(addresses) succeeded`);
//console.log(`utxosResponse`, utxosResponse);
return utxosResponse.utxos;
} catch (err) {
console.log(`Error in BCH.Electrumx.utxo(addresses):`);
return err;
}
};
const getHydratedUtxoDetails = async (BCH, utxos) => {
const hydrateUtxosPromises = [];
for (let i = 0; i < utxos.length; i += 1) {
let thisAddress = utxos[i].address;
let theseUtxos = utxos[i].utxos;
const batchedUtxos = batchArray(
theseUtxos,
currency.hydrateUtxoBatchSize,
);
// Iterate over each utxo in this address field
for (let j = 0; j < batchedUtxos.length; j += 1) {
const utxoSetForThisPromise = [
{ utxos: batchedUtxos[j], address: thisAddress },
];
const thisPromise = BCH.SLP.Utils.hydrateUtxos(
utxoSetForThisPromise,
);
hydrateUtxosPromises.push(thisPromise);
}
}
let hydratedUtxoDetails;
try {
hydratedUtxoDetails = await Promise.all(hydrateUtxosPromises);
const flattenedBatchedHydratedUtxos = flattenBatchedHydratedUtxos(
hydratedUtxoDetails,
);
return flattenedBatchedHydratedUtxos;
} catch (err) {
console.log(`Error in Promise.all(hydrateUtxosPromises)`);
console.log(err);
return err;
}
};
const getSlpBalancesAndUtxos = hydratedUtxoDetails => {
const hydratedUtxos = [];
for (let i = 0; i < hydratedUtxoDetails.slpUtxos.length; i += 1) {
const hydratedUtxosAtAddress = hydratedUtxoDetails.slpUtxos[i];
for (let j = 0; j < hydratedUtxosAtAddress.utxos.length; j += 1) {
const hydratedUtxo = hydratedUtxosAtAddress.utxos[j];
hydratedUtxo.address = hydratedUtxosAtAddress.address;
hydratedUtxos.push(hydratedUtxo);
}
}
//console.log(`hydratedUtxos`, hydratedUtxos);
// WARNING
// If you hit rate limits, your above utxos object will come back with `isValid` as null, but otherwise ok
// You need to throw an error before setting nonSlpUtxos and slpUtxos in this case
const nullUtxos = hydratedUtxos.filter(utxo => utxo.isValid === null);
//console.log(`nullUtxos`, nullUtxos);
if (nullUtxos.length > 0) {
console.log(
`${nullUtxos.length} null utxos found, ignoring results`,
);
throw new Error('Null utxos found, ignoring results');
}
// Prevent app from treating slpUtxos as nonSlpUtxos
// Must enforce === false as api will occasionally return utxo.isValid === null
// Do not classify utxos with 546 satoshis as nonSlpUtxos as a precaution
// Do not classify any utxos that include token information as nonSlpUtxos
const nonSlpUtxos = hydratedUtxos.filter(
utxo =>
utxo.isValid === false && utxo.value !== 546 && !utxo.tokenName,
);
// To be included in slpUtxos, the utxo must
// have utxo.isValid = true
// If utxo has a utxo.tokenQty field, i.e. not a minting baton, then utxo.tokenQty !== '0'
const slpUtxos = hydratedUtxos.filter(
utxo => utxo.isValid && !(utxo.tokenQty === '0'),
);
let tokensById = {};
slpUtxos.forEach(slpUtxo => {
let token = tokensById[slpUtxo.tokenId];
if (token) {
// Minting baton does nto have a slpUtxo.tokenQty type
if (slpUtxo.tokenQty) {
token.balance = token.balance.plus(
new BigNumber(slpUtxo.tokenQty),
);
}
//token.hasBaton = slpUtxo.transactionType === "genesis";
if (slpUtxo.utxoType && !token.hasBaton) {
token.hasBaton = slpUtxo.utxoType === 'minting-baton';
}
// Examples of slpUtxo
/*
Genesis transaction:
{
address: "bitcoincash:qrhzv5t79e2afc3rdutcu0d3q20gl7ul3ue58whah6"
decimals: 9
height: 617564
isValid: true
satoshis: 546
tokenDocumentHash: ""
tokenDocumentUrl: "developer.bitcoin.com"
tokenId: "6c41f244676ecfcbe3b4fabee2c72c2dadf8d74f8849afabc8a549157db69199"
tokenName: "PiticoLaunch"
tokenTicker: "PTCL"
tokenType: 1
tx_hash: "6c41f244676ecfcbe3b4fabee2c72c2dadf8d74f8849afabc8a549157db69199"
tx_pos: 2
txid: "6c41f244676ecfcbe3b4fabee2c72c2dadf8d74f8849afabc8a549157db69199"
utxoType: "minting-baton"
value: 546
vout: 2
}
Send transaction:
{
address: "bitcoincash:qrhzv5t79e2afc3rdutcu0d3q20gl7ul3ue58whah6"
decimals: 9
height: 655115
isValid: true
satoshis: 546
tokenDocumentHash: ""
tokenDocumentUrl: "developer.bitcoin.com"
tokenId: "6c41f244676ecfcbe3b4fabee2c72c2dadf8d74f8849afabc8a549157db69199"
tokenName: "PiticoLaunch"
tokenQty: 1.123456789
tokenTicker: "PTCL"
tokenType: 1
transactionType: "send"
tx_hash: "dea400f963bc9f51e010f88533010f8d1f82fc2bcc485ff8500c3a82b25abd9e"
tx_pos: 1
txid: "dea400f963bc9f51e010f88533010f8d1f82fc2bcc485ff8500c3a82b25abd9e"
utxoType: "token"
value: 546
vout: 1
}
*/
} else {
token = {};
token.info = slpUtxo;
token.tokenId = slpUtxo.tokenId;
if (slpUtxo.tokenQty) {
token.balance = new BigNumber(slpUtxo.tokenQty);
} else {
token.balance = new BigNumber(0);
}
if (slpUtxo.utxoType) {
token.hasBaton = slpUtxo.utxoType === 'minting-baton';
} else {
token.hasBaton = false;
}
tokensById[slpUtxo.tokenId] = token;
}
});
const tokens = Object.values(tokensById);
// console.log(`tokens`, tokens);
return {
tokens,
nonSlpUtxos,
slpUtxos,
};
};
const calcFee = (
BCH,
utxos,
p2pkhOutputNumber = 2,
satoshisPerByte = currency.defaultFee,
) => {
const byteCount = BCH.BitcoinCash.getByteCount(
{ P2PKH: utxos.length },
{ P2PKH: p2pkhOutputNumber },
);
const txFee = Math.ceil(satoshisPerByte * byteCount);
return txFee;
};
+ const createToken = async (BCH, 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;
+ const inputUtxos = [];
+ let transactionBuilder;
+
+ // instance of transaction builder
+ if (process.env.REACT_APP_NETWORK === `mainnet`)
+ transactionBuilder = new BCH.TransactionBuilder();
+ else transactionBuilder = new BCH.TransactionBuilder('testnet');
+
+ let originalAmount = new BigNumber(0);
+ const tokenOutputDust = new BigNumber(currency.dust);
+ let txFee = 0;
+ for (let i = 0; i < utxos.length; i++) {
+ const utxo = utxos[i];
+ originalAmount = originalAmount.plus(utxo.value);
+ const vout = utxo.vout;
+ const txid = utxo.txid;
+ // add input with txid and index of vout
+ transactionBuilder.addInput(txid, vout);
+
+ inputUtxos.push(utxo);
+ txFee = calcFee(BCH, inputUtxos, 3, feeInSatsPerByte);
+
+ if (originalAmount.minus(tokenOutputDust).minus(txFee).gte(0)) {
+ break;
+ }
+ }
+
+ // amount to send back to the remainder address.
+ const remainder = originalAmount
+ .minus(tokenOutputDust)
+ .minus(txFee);
+
+ if (remainder.lt(0)) {
+ const error = new Error(`Insufficient funds`);
+ error.code = SEND_BCH_ERRORS.INSUFFICIENT_FUNDS;
+ throw error;
+ }
+
+ // Generate the OP_RETURN entry for an SLP GENESIS transaction.
+ const script = BCH.SLP.TokenType1.generateGenesisOpReturn(
+ configObj,
+ );
+ // OP_RETURN needs to be the first output in the transaction.
+ transactionBuilder.addOutput(script, 0);
+
+ // add output w/ address and amount to send
+ transactionBuilder.addOutput(
+ CREATION_ADDR,
+ parseInt(toSmallestDenomination(tokenOutputDust)),
+ );
+
+ // Send change to own address
+ if (
+ remainder.gte(
+ toSmallestDenomination(new BigNumber(currency.dust)),
+ )
+ ) {
+ transactionBuilder.addOutput(
+ CREATION_ADDR,
+ parseInt(remainder),
+ );
+ }
+
+ // Sign the transactions with the HD node.
+ for (let i = 0; i < inputUtxos.length; i++) {
+ const utxo = inputUtxos[i];
+ transactionBuilder.sign(
+ i,
+ BCH.ECPair.fromWIF(utxo.wif),
+ undefined,
+ transactionBuilder.hashTypes.SIGHASH_ALL,
+ utxo.value,
+ );
+ }
+
+ // build tx
+ const tx = transactionBuilder.build();
+ // output rawhex
+ const hex = tx.toHex();
+
+ // Broadcast transaction to the network
+ const txidStr = await BCH.RawTransactions.sendRawTransaction([hex]);
+
+ if (txidStr && txidStr[0]) {
+ console.log(`${currency.ticker} txid`, txidStr[0]);
+ }
+ let link;
+
+ if (process.env.REACT_APP_NETWORK === `mainnet`) {
+ link = `${currency.tokenExplorerUrl}/tx/${txidStr}`;
+ } else {
+ link = `${currency.blockExplorerUrlTestnet}/tx/${txidStr}`;
+ }
+ //console.log(`link`, link);
+
+ return link;
+ } catch (err) {
+ if (err.error === 'insufficient priority (code 66)') {
+ err.code = SEND_BCH_ERRORS.INSUFFICIENT_PRIORITY;
+ } else if (err.error === 'txn-mempool-conflict (code 18)') {
+ err.code = SEND_BCH_ERRORS.DOUBLE_SPENDING;
+ } else if (err.error === 'Network Error') {
+ err.code = SEND_BCH_ERRORS.NETWORK_ERROR;
+ } else if (
+ err.error ===
+ 'too-long-mempool-chain, too many unconfirmed ancestors [limit: 25] (code 64)'
+ ) {
+ err.code = SEND_BCH_ERRORS.MAX_UNCONFIRMED_TXS;
+ }
+ console.log(`error: `, err);
+ throw err;
+ }
+ };
+
const sendToken = async (
BCH,
wallet,
slpBalancesAndUtxos,
{ tokenId, amount, tokenReceiverAddress },
) => {
// Handle error of user having no BCH
if (slpBalancesAndUtxos.nonSlpUtxos.length === 0) {
throw new Error(
`You need some ${currency.ticker} to send ${currency.tokenTicker}`,
);
}
const largestBchUtxo = slpBalancesAndUtxos.nonSlpUtxos.reduce(
(previous, current) =>
previous.value > current.value ? previous : current,
);
const bchECPair = BCH.ECPair.fromWIF(largestBchUtxo.wif);
const tokenUtxos = slpBalancesAndUtxos.slpUtxos.filter(
(utxo, index) => {
if (
utxo && // UTXO is associated with a token.
utxo.tokenId === tokenId && // UTXO matches the token ID.
utxo.utxoType === 'token' // UTXO is not a minting baton.
) {
return true;
}
return false;
},
);
if (tokenUtxos.length === 0) {
throw new Error(
'No token UTXOs for the specified token could be found.',
);
}
// BEGIN transaction construction.
// instance of transaction builder
let transactionBuilder;
if (process.env.REACT_APP_NETWORK === 'mainnet') {
transactionBuilder = new BCH.TransactionBuilder();
} else transactionBuilder = new BCH.TransactionBuilder('testnet');
const originalAmount = largestBchUtxo.value;
transactionBuilder.addInput(
largestBchUtxo.tx_hash,
largestBchUtxo.tx_pos,
);
let finalTokenAmountSent = new BigNumber(0);
let tokenAmountBeingSentToAddress = new BigNumber(amount);
let tokenUtxosBeingSpent = [];
for (let i = 0; i < tokenUtxos.length; i++) {
finalTokenAmountSent = finalTokenAmountSent.plus(
new BigNumber(tokenUtxos[i].tokenQty),
);
transactionBuilder.addInput(
tokenUtxos[i].tx_hash,
tokenUtxos[i].tx_pos,
);
tokenUtxosBeingSpent.push(tokenUtxos[i]);
if (tokenAmountBeingSentToAddress.lte(finalTokenAmountSent)) {
break;
}
}
const slpSendObj = BCH.SLP.TokenType1.generateSendOpReturn(
tokenUtxosBeingSpent,
tokenAmountBeingSentToAddress.toString(),
);
const slpData = slpSendObj.script;
// Add OP_RETURN as first output.
transactionBuilder.addOutput(slpData, 0);
// Send dust transaction representing tokens being sent.
transactionBuilder.addOutput(
BCH.SLP.Address.toLegacyAddress(tokenReceiverAddress),
546,
);
// Return any token change back to the sender.
if (slpSendObj.outputs > 1) {
// Try to send this to Path1899 to move all utxos off legacy addresses
if (wallet.Path1899.legacyAddress) {
transactionBuilder.addOutput(
wallet.Path1899.legacyAddress,
546,
);
} else {
// If you can't, send it back from whence it came
transactionBuilder.addOutput(
BCH.SLP.Address.toLegacyAddress(
tokenUtxosBeingSpent[0].address,
),
546,
);
}
}
// get byte count to calculate fee. paying 1 sat
// Note: This may not be totally accurate. Just guessing on the byteCount size.
const txFee = calcFee(
BCH,
tokenUtxosBeingSpent,
5,
1.1 * currency.defaultFee,
);
// amount to send back to the sending address. It's the original amount - 1 sat/byte for tx size
const remainder = originalAmount - txFee - 546 * 2;
if (remainder < 1) {
throw new Error('Selected UTXO does not have enough satoshis');
}
// Last output: send the BCH change back to the wallet.
// If Path1899, send it to Path1899 address
if (wallet.Path1899.legacyAddress) {
transactionBuilder.addOutput(
wallet.Path1899.legacyAddress,
remainder,
);
} else {
// Otherwise send it back from whence it came
transactionBuilder.addOutput(
BCH.Address.toLegacyAddress(largestBchUtxo.address),
remainder,
);
}
// Sign the transaction with the private key for the BCH UTXO paying the fees.
let redeemScript;
transactionBuilder.sign(
0,
bchECPair,
redeemScript,
transactionBuilder.hashTypes.SIGHASH_ALL,
originalAmount,
);
// Sign each token UTXO being consumed.
for (let i = 0; i < tokenUtxosBeingSpent.length; i++) {
const thisUtxo = tokenUtxosBeingSpent[i];
const accounts = [wallet.Path245, wallet.Path145, wallet.Path1899];
const utxoEcPair = BCH.ECPair.fromWIF(
accounts
.filter(acc => acc.cashAddress === thisUtxo.address)
.pop().fundingWif,
);
transactionBuilder.sign(
1 + i,
utxoEcPair,
redeemScript,
transactionBuilder.hashTypes.SIGHASH_ALL,
thisUtxo.value,
);
}
// build tx
const tx = transactionBuilder.build();
// output rawhex
const hex = tx.toHex();
// console.log(`Transaction raw hex: `, hex);
// END transaction construction.
const txidStr = await BCH.RawTransactions.sendRawTransaction([hex]);
if (txidStr && txidStr[0]) {
console.log(`${currency.tokenTicker} txid`, txidStr[0]);
}
let link;
if (process.env.REACT_APP_NETWORK === `mainnet`) {
link = `${currency.blockExplorerUrl}/tx/${txidStr}`;
} else {
link = `${currency.blockExplorerUrlTestnet}/tx/${txidStr}`;
}
//console.log(`link`, link);
return link;
};
const sendBch = async (
BCH,
wallet,
utxos,
destinationAddress,
sendAmount,
feeInSatsPerByte,
callbackTxId,
encodedOpReturn,
) => {
// Note: callbackTxId is a callback function that accepts a txid as its only parameter
try {
if (!sendAmount) {
return null;
}
const value = new BigNumber(sendAmount);
// If user is attempting to send less than minimum accepted by the backend
if (value.lt(new BigNumber(currency.dust))) {
// Throw the same error given by the backend attempting to broadcast such a tx
throw new Error('dust');
}
const REMAINDER_ADDR = wallet.Path1899.cashAddress;
const inputUtxos = [];
let transactionBuilder;
// instance of transaction builder
if (process.env.REACT_APP_NETWORK === `mainnet`)
transactionBuilder = new BCH.TransactionBuilder();
else transactionBuilder = new BCH.TransactionBuilder('testnet');
const satoshisToSend = toSmallestDenomination(value);
// Throw validation error if toSmallestDenomination returns false
if (!satoshisToSend) {
const error = new Error(
`Invalid decimal places for send amount`,
);
throw error;
}
let originalAmount = new BigNumber(0);
let txFee = 0;
for (let i = 0; i < utxos.length; i++) {
const utxo = utxos[i];
originalAmount = originalAmount.plus(utxo.value);
const vout = utxo.vout;
const txid = utxo.txid;
// add input with txid and index of vout
transactionBuilder.addInput(txid, vout);
inputUtxos.push(utxo);
txFee = encodedOpReturn
? calcFee(BCH, inputUtxos, 3, feeInSatsPerByte)
: calcFee(BCH, inputUtxos, 2, feeInSatsPerByte);
if (originalAmount.minus(satoshisToSend).minus(txFee).gte(0)) {
break;
}
}
// amount to send back to the remainder address.
const remainder = 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 (encodedOpReturn) {
transactionBuilder.addOutput(encodedOpReturn, 0);
}
// add output w/ address and amount to send
transactionBuilder.addOutput(
BCH.Address.toCashAddress(destinationAddress),
parseInt(toSmallestDenomination(value)),
);
if (
remainder.gte(
toSmallestDenomination(new BigNumber(currency.dust)),
)
) {
transactionBuilder.addOutput(
REMAINDER_ADDR,
parseInt(remainder),
);
}
// Sign the transactions with the HD node.
for (let i = 0; i < inputUtxos.length; i++) {
const utxo = inputUtxos[i];
transactionBuilder.sign(
i,
BCH.ECPair.fromWIF(utxo.wif),
undefined,
transactionBuilder.hashTypes.SIGHASH_ALL,
utxo.value,
);
}
// build tx
const tx = transactionBuilder.build();
// output rawhex
const hex = tx.toHex();
// Broadcast transaction to the network
const txidStr = await BCH.RawTransactions.sendRawTransaction([hex]);
if (txidStr && txidStr[0]) {
console.log(`${currency.ticker} txid`, txidStr[0]);
}
let link;
if (callbackTxId) {
callbackTxId(txidStr);
}
if (process.env.REACT_APP_NETWORK === `mainnet`) {
link = `${currency.blockExplorerUrl}/tx/${txidStr}`;
} else {
link = `${currency.blockExplorerUrlTestnet}/tx/${txidStr}`;
}
//console.log(`link`, link);
return link;
} catch (err) {
if (err.error === 'insufficient priority (code 66)') {
err.code = SEND_BCH_ERRORS.INSUFFICIENT_PRIORITY;
} else if (err.error === 'txn-mempool-conflict (code 18)') {
err.code = SEND_BCH_ERRORS.DOUBLE_SPENDING;
} else if (err.error === 'Network Error') {
err.code = SEND_BCH_ERRORS.NETWORK_ERROR;
} else if (
err.error ===
'too-long-mempool-chain, too many unconfirmed ancestors [limit: 25] (code 64)'
) {
err.code = SEND_BCH_ERRORS.MAX_UNCONFIRMED_TXS;
}
console.log(`error: `, err);
throw err;
}
};
const getBCH = (apiIndex = 0, fromWindowObject = true) => {
if (fromWindowObject && window.SlpWallet) {
const SlpWallet = new window.SlpWallet('', {
restURL: getRestUrl(apiIndex),
});
return SlpWallet.bchjs;
}
};
return {
getBCH,
calcFee,
getUtxos,
getHydratedUtxoDetails,
getSlpBalancesAndUtxos,
getTxHistory,
flattenTransactions,
parseTxData,
addTokenTxData,
parseTokenInfoForTxHistory,
getTxData,
getRestUrl,
sendBch,
sendToken,
+ createToken,
};
}
diff --git a/web/cashtab/src/utils/__tests__/validation.test.js b/web/cashtab/src/utils/__tests__/validation.test.js
index b644dcd18..8fd79deb8 100644
--- a/web/cashtab/src/utils/__tests__/validation.test.js
+++ b/web/cashtab/src/utils/__tests__/validation.test.js
@@ -1,82 +1,176 @@
-import { shouldRejectAmountInput, fiatToCrypto } from '../validation';
+import {
+ shouldRejectAmountInput,
+ fiatToCrypto,
+ isValidTokenName,
+ isValidTokenTicker,
+ isValidTokenDecimals,
+ isValidTokenInitialQty,
+ isValidTokenDocumentUrl,
+} from '../validation';
import { currency } from '@components/Common/Ticker.js';
describe('Validation utils', () => {
it(`Returns 'false' if ${currency.ticker} send amount is a valid send amount`, () => {
expect(shouldRejectAmountInput('10', currency.ticker, 20.0, 300)).toBe(
false,
);
});
it(`Returns 'false' if ${currency.ticker} send amount is a valid send amount in USD`, () => {
// Here, user is trying to send $170 USD, where 1 BCHA = $20 USD, and the user has a balance of 15 BCHA or $300
expect(shouldRejectAmountInput('170', 'USD', 20.0, 15)).toBe(false);
});
it(`Returns not a number if ${currency.ticker} send amount is not a number`, () => {
const expectedValidationError = `Amount must be a number`;
expect(
shouldRejectAmountInput('Not a number', currency.ticker, 20.0, 3),
).toBe(expectedValidationError);
});
it(`Returns amount must be greater than 0 if ${currency.ticker} send amount is 0`, () => {
const expectedValidationError = `Amount must be greater than 0`;
expect(shouldRejectAmountInput('0', currency.ticker, 20.0, 3)).toBe(
expectedValidationError,
);
});
it(`Returns amount must be greater than 0 if ${currency.ticker} send amount is less than 0`, () => {
const expectedValidationError = `Amount must be greater than 0`;
expect(
shouldRejectAmountInput('-0.031', currency.ticker, 20.0, 3),
).toBe(expectedValidationError);
});
it(`Returns balance error if ${currency.ticker} send amount is greater than user balance`, () => {
const expectedValidationError = `Amount cannot exceed your ${currency.ticker} balance`;
expect(shouldRejectAmountInput('17', currency.ticker, 20.0, 3)).toBe(
expectedValidationError,
);
});
it(`Returns balance error if ${currency.ticker} send amount is greater than user balance`, () => {
const expectedValidationError = `Amount cannot exceed your ${currency.ticker} balance`;
expect(shouldRejectAmountInput('17', currency.ticker, 20.0, 3)).toBe(
expectedValidationError,
);
});
it(`Returns error if ${currency.ticker} send amount is less than ${currency.dust} minimum`, () => {
const expectedValidationError = `Send amount must be at least ${currency.dust} ${currency.ticker}`;
expect(
shouldRejectAmountInput(
(currency.dust - 0.00000001).toString(),
currency.ticker,
20.0,
3,
),
).toBe(expectedValidationError);
});
it(`Returns error if ${currency.ticker} send amount is less than ${currency.dust} minimum in fiat currency`, () => {
const expectedValidationError = `Send amount must be at least ${currency.dust} ${currency.ticker}`;
expect(
shouldRejectAmountInput('0.0000005', 'USD', 14.63, 0.52574662),
).toBe(expectedValidationError);
});
it(`Returns balance error if ${currency.ticker} send amount is greater than user balance with fiat currency selected`, () => {
const expectedValidationError = `Amount cannot exceed your ${currency.ticker} balance`;
// Here, user is trying to send $170 USD, where 1 BCHA = $20 USD, and the user has a balance of 5 BCHA or $100
expect(shouldRejectAmountInput('170', 'USD', 20.0, 5)).toBe(
expectedValidationError,
);
});
it(`Returns precision error if ${currency.ticker} send amount has more than ${currency.cashDecimals} decimal places`, () => {
const expectedValidationError = `${currency.ticker} transactions do not support more than ${currency.cashDecimals} decimal places`;
expect(
shouldRejectAmountInput('17.123456789', currency.ticker, 20.0, 35),
).toBe(expectedValidationError);
});
it(`Returns expected crypto amount with ${currency.cashDecimals} decimals of precision even if inputs have higher precision`, () => {
expect(fiatToCrypto('10.97231694823432', 20.3231342349234234)).toBe(
'0.53989295',
);
});
it(`Returns expected crypto amount with ${currency.cashDecimals} decimals of precision even if inputs have lower precision`, () => {
expect(fiatToCrypto('10.94', 10)).toBe('1.09400000');
});
+ it(`Accepts a valid ${currency.tokenTicker} token name`, () => {
+ expect(isValidTokenName('Valid token name')).toBe(true);
+ });
+ it(`Accepts a valid ${currency.tokenTicker} token name that is a stringified number`, () => {
+ expect(isValidTokenName('123456789')).toBe(true);
+ });
+ it(`Rejects ${currency.tokenTicker} token name if longer than 68 characters`, () => {
+ expect(
+ isValidTokenName(
+ 'This token name is not valid because it is longer than 68 characters which is really pretty long for a token name when you think about it and all',
+ ),
+ ).toBe(false);
+ });
+ it(`Rejects ${currency.tokenTicker} token name if empty string`, () => {
+ expect(isValidTokenName('')).toBe(false);
+ });
+ it(`Accepts a 4-char ${currency.tokenTicker} token ticker`, () => {
+ expect(isValidTokenTicker('DOGE')).toBe(true);
+ });
+ it(`Accepts a 12-char ${currency.tokenTicker} token ticker`, () => {
+ expect(isValidTokenTicker('123456789123')).toBe(true);
+ });
+ it(`Rejects ${currency.tokenTicker} token ticker if empty string`, () => {
+ expect(isValidTokenTicker('')).toBe(false);
+ });
+ it(`Rejects ${currency.tokenTicker} token ticker if > 12 chars`, () => {
+ expect(isValidTokenTicker('1234567891234')).toBe(false);
+ });
+ it(`Accepts ${currency.tokenDecimals} if zero`, () => {
+ expect(isValidTokenDecimals('0')).toBe(true);
+ });
+ it(`Accepts ${currency.tokenDecimals} if between 0 and 9 inclusive`, () => {
+ expect(isValidTokenDecimals('9')).toBe(true);
+ });
+ it(`Rejects ${currency.tokenDecimals} if empty string`, () => {
+ expect(isValidTokenDecimals('')).toBe(false);
+ });
+ it(`Rejects ${currency.tokenDecimals} if non-integer`, () => {
+ expect(isValidTokenDecimals('1.7')).toBe(false);
+ });
+ it(`Accepts ${currency.tokenDecimals} initial genesis quantity at minimum amount for 3 decimal places`, () => {
+ expect(isValidTokenInitialQty('0.001', '3')).toBe(true);
+ });
+ it(`Accepts ${currency.tokenDecimals} initial genesis quantity at minimum amount for 9 decimal places`, () => {
+ expect(isValidTokenInitialQty('0.000000001', '9')).toBe(true);
+ });
+ it(`Accepts ${currency.tokenDecimals} initial genesis quantity at amount below 100 billion`, () => {
+ expect(isValidTokenInitialQty('1000', '0')).toBe(true);
+ });
+ it(`Accepts highest possible ${currency.tokenDecimals} initial genesis quantity at amount below 100 billion`, () => {
+ expect(isValidTokenInitialQty('99999999999.999999999', '9')).toBe(true);
+ });
+ it(`Accepts ${currency.tokenDecimals} initial genesis quantity if decimal places equal tokenDecimals`, () => {
+ expect(isValidTokenInitialQty('0.123', '3')).toBe(true);
+ });
+ it(`Accepts ${currency.tokenDecimals} initial genesis quantity if decimal places are less than tokenDecimals`, () => {
+ expect(isValidTokenInitialQty('0.12345', '9')).toBe(true);
+ });
+ it(`Rejects ${currency.tokenDecimals} initial genesis quantity of zero`, () => {
+ expect(isValidTokenInitialQty('0', '9')).toBe(false);
+ });
+ it(`Rejects ${currency.tokenDecimals} initial genesis quantity if tokenDecimals is not valid`, () => {
+ expect(isValidTokenInitialQty('0', '')).toBe(false);
+ });
+ it(`Rejects ${currency.tokenDecimals} initial genesis quantity if 100 billion or higher`, () => {
+ expect(isValidTokenInitialQty('100000000000', '0')).toBe(false);
+ });
+ it(`Rejects ${currency.tokenDecimals} initial genesis quantity if it has more decimal places than tokenDecimals`, () => {
+ expect(isValidTokenInitialQty('1.5', '0')).toBe(false);
+ });
+ it(`Accepts a valid ${currency.tokenTicker} token document URL`, () => {
+ expect(isValidTokenDocumentUrl('cashtabapp.com')).toBe(true);
+ });
+ it(`Accepts a valid ${currency.tokenTicker} token document URL including special URL characters`, () => {
+ expect(isValidTokenDocumentUrl('https://cashtabapp.com/')).toBe(true);
+ });
+ it(`Accepts a blank string as a valid ${currency.tokenTicker} token document URL`, () => {
+ expect(isValidTokenDocumentUrl('')).toBe(true);
+ });
+ it(`Rejects ${currency.tokenTicker} token name if longer than 68 characters`, () => {
+ expect(
+ isValidTokenDocumentUrl(
+ 'http://www.ThisTokenDocumentUrlIsActuallyMuchMuchMuchMuchMuchMuchMuchMuchMuchMuchMuchMuchMuchMuchMuchMuchMuchMuchTooLong.com/',
+ ),
+ ).toBe(false);
+ });
});
diff --git a/web/cashtab/src/utils/validation.js b/web/cashtab/src/utils/validation.js
index 893d77098..dcd71985b 100644
--- a/web/cashtab/src/utils/validation.js
+++ b/web/cashtab/src/utils/validation.js
@@ -1,45 +1,85 @@
import BigNumber from 'bignumber.js';
import { currency } from '@components/Common/Ticker.js';
// Validate cash amount
export const shouldRejectAmountInput = (
cashAmount,
selectedCurrency,
fiatPrice,
totalCashBalance,
) => {
// Take cashAmount as input, a string from form input
let error = false;
let testedAmount = new BigNumber(cashAmount);
if (selectedCurrency === 'USD') {
// Ensure no more than 8 decimal places
testedAmount = new BigNumber(fiatToCrypto(cashAmount, fiatPrice));
}
// Validate value for > 0
if (isNaN(testedAmount)) {
error = 'Amount must be a number';
} else if (testedAmount.lte(0)) {
error = 'Amount must be greater than 0';
} else if (testedAmount.lt(currency.dust)) {
error = `Send amount must be at least ${currency.dust} ${currency.ticker}`;
} else if (testedAmount.gt(totalCashBalance)) {
error = `Amount cannot exceed your ${currency.ticker} balance`;
} else if (!isNaN(testedAmount) && testedAmount.toString().includes('.')) {
if (
testedAmount.toString().split('.')[1].length > currency.cashDecimals
) {
error = `${currency.ticker} transactions do not support more than ${currency.cashDecimals} decimal places`;
}
}
// return false if no error, or string error msg if error
return error;
};
export const fiatToCrypto = (fiatAmount, fiatPrice) => {
let cryptoAmount = new BigNumber(fiatAmount)
.div(new BigNumber(fiatPrice))
.toFixed(currency.cashDecimals);
return cryptoAmount;
};
+
+export const isValidTokenName = tokenName => {
+ return (
+ typeof tokenName === 'string' &&
+ tokenName.length > 0 &&
+ tokenName.length < 68
+ );
+};
+
+export const isValidTokenTicker = tokenTicker => {
+ return (
+ typeof tokenTicker === 'string' &&
+ tokenTicker.length > 0 &&
+ tokenTicker.length < 13
+ );
+};
+
+export const isValidTokenDecimals = tokenDecimals => {
+ return ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'].includes(
+ tokenDecimals,
+ );
+};
+
+export const isValidTokenInitialQty = (tokenInitialQty, tokenDecimals) => {
+ const minimumQty = new BigNumber(1 / 10 ** tokenDecimals);
+ const tokenIntialQtyBig = new BigNumber(tokenInitialQty);
+ return (
+ tokenIntialQtyBig.gte(minimumQty) &&
+ tokenIntialQtyBig.lt(100000000000) &&
+ tokenIntialQtyBig.dp() <= tokenDecimals
+ );
+};
+
+export const isValidTokenDocumentUrl = tokenDocumentUrl => {
+ return (
+ typeof tokenDocumentUrl === 'string' &&
+ tokenDocumentUrl.length >= 0 &&
+ tokenDocumentUrl.length < 68
+ );
+};