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

`; 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

`; 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 !== '' ? ( {`identicon } /> ) : ( {`identicon )} {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
,
Copied
qzagy4 7mvh6qxkvcn3acjnz73rkhkc6y7cpt zgcqy6
,
BCHA
SLPA
, ] `; exports[`Wallet with BCH balances and tokens 1`] = ` Array [
0.06047469 BCHA
,
$ NaN USD
,
Copied
qzagy4 7mvh6qxkvcn3acjnz73rkhkc6y7cpt zgcqy6
,
BCHA
SLPA
, ] `; exports[`Wallet with BCH balances and tokens and state field 1`] = ` Array [
0.06047469 BCHA
,
$ NaN USD
,
Copied
qzagy4 7mvh6qxkvcn3acjnz73rkhkc6y7cpt zgcqy6
,
BCHA
SLPA
, ] `; exports[`Wallet with BCH balances and tokens and state field, but no params in state 1`] = ` Array [
0.06047469 BCHA
,
$ NaN USD
,
Copied
qzagy4 7mvh6qxkvcn3acjnz73rkhkc6y7cpt zgcqy6
,
BCHA
SLPA
, ] `; 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
,
Copied
qzagy4 7mvh6qxkvcn3acjnz73rkhkc6y7cpt zgcqy6
,
BCHA
SLPA
, ] `; 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 + ); +};