diff --git a/web/cashtab/src/components/App.js b/web/cashtab/src/components/App.js --- a/web/cashtab/src/components/App.js +++ b/web/cashtab/src/components/App.js @@ -7,8 +7,10 @@ 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'; @@ -83,9 +85,15 @@ 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; @@ -219,6 +227,9 @@ <Route path="/wallet"> <Wallet /> </Route> + <Route path="/tokens"> + <Tokens /> + </Route> <Route path="/send"> <Send /> </Route> @@ -247,6 +258,14 @@ <fbt desc="Wallet menu button">Wallet</fbt> </NavButton> + <NavButton + active={selectedKey === 'tokens'} + onClick={() => history.push('/tokens')} + > + <AppstoreAddOutlined /> + Tokens + </NavButton> + <NavButton active={selectedKey === 'send'} onClick={() => history.push('/send')} diff --git a/web/cashtab/src/components/Common/Atoms.js b/web/cashtab/src/components/Common/Atoms.js new file mode 100644 --- /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 --- a/web/cashtab/src/components/Common/PrimaryButton.js +++ b/web/cashtab/src/components/Common/PrimaryButton.js @@ -78,10 +78,11 @@ -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; diff --git a/web/cashtab/src/components/Common/StyledCollapse.js b/web/cashtab/src/components/Common/StyledCollapse.js --- a/web/cashtab/src/components/Common/StyledCollapse.js +++ b/web/cashtab/src/components/Common/StyledCollapse.js @@ -14,7 +14,40 @@ 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 --- a/web/cashtab/src/components/Common/Ticker.js +++ b/web/cashtab/src/components/Common/Ticker.js @@ -14,6 +14,7 @@ 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', 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 --- 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`] = ` <div - className="ant-collapse ant-collapse-icon-position-left sc-bdVaJa bWyEBD" + className="ant-collapse ant-collapse-icon-position-left sc-bdVaJa AfOYf" role={null} /> `; 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 --- 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,4 +1,4 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://goo.gl/fbAQLP @generated exports[`Configure with a wallet 1`] = ` <div @@ -33,12 +33,12 @@ className="ant-spin-container ant-spin-blur" > <div - className="sc-kgoBCf dkabgy" + className="sc-kGXeez bnGuui" > <h2> <span aria-label="copy" - className="anticon anticon-copy sc-bwzfXH eCSKEi" + className="anticon anticon-copy sc-htpNat bGTymY" role="img" > <svg @@ -103,7 +103,7 @@ </div> </div> <div - className="ant-collapse ant-collapse-icon-position-left sc-bdVaJa bWyEBD" + className="ant-collapse ant-collapse-icon-position-left sc-bdVaJa AfOYf" role={null} > <div @@ -141,12 +141,12 @@ </div> </div> <div - className="sc-kGXeez kTnHvP" + className="sc-kpOJdX eglhol" /> <h2> <span aria-label="wallet" - className="anticon anticon-wallet sc-bxivhb gaDgkj" + className="anticon anticon-wallet sc-ifAKCX bzXUUf" role="img" > <svg @@ -166,7 +166,7 @@ Manage Wallets </h2> <button - className="sc-gqjmRU XGywS" + className="sc-VigVT hEOBKE" onClick={[Function]} > <span @@ -194,7 +194,7 @@ New Wallet </button> <button - className="sc-VigVT fqMxLW" + className="sc-jTzLTM bLkbAy" onClick={[Function]} > <span @@ -219,11 +219,11 @@ Import Wallet </button> <div - className="sc-kGXeez kTnHvP" + className="sc-kpOJdX eglhol" /> [ <a - className="sc-fjdhpX eJaZHQ" + className="sc-jzJRlG erIHmd" href="https://docs.cashtabapp.com/docs/" rel="noreferrer" target="_blank" @@ -270,12 +270,12 @@ className="ant-spin-container ant-spin-blur" > <div - className="sc-kgoBCf dkabgy" + className="sc-kGXeez bnGuui" > <h2> <span aria-label="copy" - className="anticon anticon-copy sc-bwzfXH eCSKEi" + className="anticon anticon-copy sc-htpNat bGTymY" role="img" > <svg @@ -340,12 +340,12 @@ </div> </div> <div - className="sc-kGXeez kTnHvP" + className="sc-kpOJdX eglhol" /> <h2> <span aria-label="wallet" - className="anticon anticon-wallet sc-bxivhb gaDgkj" + className="anticon anticon-wallet sc-ifAKCX bzXUUf" role="img" > <svg @@ -365,7 +365,7 @@ Manage Wallets </h2> <button - className="sc-gqjmRU XGywS" + className="sc-VigVT hEOBKE" onClick={[Function]} > <span @@ -393,7 +393,7 @@ New Wallet </button> <button - className="sc-VigVT fqMxLW" + className="sc-jTzLTM bLkbAy" onClick={[Function]} > <span @@ -418,11 +418,11 @@ Import Wallet </button> <div - className="sc-kGXeez kTnHvP" + className="sc-kpOJdX eglhol" /> [ <a - className="sc-fjdhpX eJaZHQ" + className="sc-jzJRlG erIHmd" href="https://docs.cashtabapp.com/docs/" rel="noreferrer" target="_blank" diff --git a/web/cashtab/src/components/Tokens/CreateTokenForm.js b/web/cashtab/src/components/Tokens/CreateTokenForm.js new file mode 100644 --- /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: ( + <a href={link} target="_blank" rel="noopener noreferrer"> + <Paragraph> + Token created! Click or tap here for more details + </Paragraph> + </a> + ), + 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 ( + <> + <Modal + title={`Please review and confirm your token settings.`} + visible={showConfirmCreateToken} + onOk={createPreviewedToken} + onCancel={() => setShowConfirmCreateToken(false)} + > + <TokenParamLabel>Name:</TokenParamLabel> {newTokenName} + <br /> + <TokenParamLabel>Ticker:</TokenParamLabel> {newTokenTicker} + <br /> + <TokenParamLabel>Decimals:</TokenParamLabel> {newTokenDecimals} + <br /> + <TokenParamLabel>Supply:</TokenParamLabel> {newTokenInitialQty} + <br /> + <TokenParamLabel>Document URL:</TokenParamLabel>{' '} + {newTokenDocumentUrl === '' + ? 'https://cashtabapp.com/' + : newTokenDocumentUrl} + <br /> + </Modal> + <> + <Spin spinning={genesisLoading} indicator={CashLoadingIcon}> + <TokenCollapse + collapsible={disabled ? 'disabled' : true} + disabled={disabled} + style={{ + marginBottom: '24px', + }} + > + <Panel header="Create Token" key="1"> + <AntdFormWrapper> + <Form + size="small" + style={{ + width: 'auto', + }} + > + <Form.Item + validateStatus={ + newTokenNameIsValid === null || + newTokenNameIsValid + ? '' + : 'error' + } + help={ + newTokenNameIsValid === null || + newTokenNameIsValid + ? '' + : 'Token name must be a string between 1 and 68 characters long' + } + > + <Input + addonBefore="Name" + placeholder="Enter a name for your token" + name="newTokenName" + value={newTokenName} + onChange={e => + handleNewTokenNameInput(e) + } + /> + </Form.Item> + <Form.Item + validateStatus={ + newTokenTickerIsValid === null || + newTokenTickerIsValid + ? '' + : 'error' + } + help={ + newTokenTickerIsValid === null || + newTokenTickerIsValid + ? '' + : 'Ticker must be a string between 1 and 12 characters long' + } + > + <Input + addonBefore="Ticker" + placeholder="Enter a ticker for your token" + name="newTokenTicker" + value={newTokenTicker} + onChange={e => + handleNewTokenTickerInput(e) + } + /> + </Form.Item> + <Form.Item + validateStatus={ + newTokenDecimalsIsValid === null || + newTokenDecimalsIsValid + ? '' + : 'error' + } + help={ + newTokenDecimalsIsValid === null || + newTokenDecimalsIsValid + ? '' + : 'Token decimals must be an integer between 0 and 9' + } + > + <Input + addonBefore="Decimals" + placeholder="Enter number of decimal places" + name="newTokenDecimals" + type="number" + value={newTokenDecimals} + onChange={e => + handleNewTokenDecimalsInput(e) + } + /> + </Form.Item> + <Form.Item + validateStatus={ + newTokenInitialQtyIsValid === + null || + newTokenInitialQtyIsValid + ? '' + : 'error' + } + help={ + newTokenInitialQtyIsValid === + null || + newTokenInitialQtyIsValid + ? '' + : 'Token supply must be greater than 0 and less than 100,000,000,000. Token supply decimal places cannot exceed token decimal places.' + } + > + <Input + addonBefore="Supply" + placeholder="Enter the fixed supply of your token" + name="newTokenInitialQty" + type="number" + value={newTokenInitialQty} + onChange={e => + handleNewTokenInitialQtyInput(e) + } + /> + </Form.Item> + <Form.Item + validateStatus={ + newTokenDocumentUrlIsValid === + null || + newTokenDocumentUrlIsValid + ? '' + : 'error' + } + help={ + newTokenDocumentUrlIsValid === + null || + newTokenDocumentUrlIsValid + ? '' + : 'Document URL cannot exceed 68 characters' + } + > + <Input + addonBefore="Document URL" + placeholder="Enter a website for your token" + name="newTokenDocumentUrl" + value={newTokenDocumentUrl} + onChange={e => + handleNewTokenDocumentUrlInput( + e, + ) + } + /> + </Form.Item> + </Form> + </AntdFormWrapper> + <SmartButton + onClick={() => setShowConfirmCreateToken(true)} + disabled={!tokenGenesisDataIsValid} + > + <PlusSquareOutlined /> + Create Token + </SmartButton> + </Panel> + </TokenCollapse> + </Spin> + </> + </> + ); +}; + +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 --- /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 ? ( + <LoadingCtn> + <LoadingOutlined /> + </LoadingCtn> + ) : ( + <> + {!balances.totalBalance ? ( + <> + <ZeroBalanceHeader> + You need some {currency.ticker} in your wallet + to create tokens. + </ZeroBalanceHeader> + <BalanceHeader>0 {currency.ticker}</BalanceHeader> + </> + ) : ( + <> + <BalanceHeader> + {formatBalance(balances.totalBalance)}{' '} + {currency.ticker} + </BalanceHeader> + {fiatPrice !== null && + !isNaN(balances.totalBalance) && ( + <BalanceHeaderFiat> + $ + {( + balances.totalBalance * fiatPrice + ).toFixed(2)}{' '} + USD + </BalanceHeaderFiat> + )} + </> + )} + {apiError && ( + <> + <p + style={{ + color: 'red', + }} + > + <b>An error occurred on our end.</b> + <br></br> Re-establishing connection... + </p> + <CashLoader /> + </> + )} + <CreateTokenForm + BCH={BCH} + getRestUrl={getRestUrl} + createToken={createToken} + disabled={balances.totalBalanceInSatoshis < 546} + /> + {balances.totalBalanceInSatoshis < 546 && ( + <AlertMsg> + You need at least {currency.dust} {currency.ticker}{' '} + ($ + {(currency.dust * fiatPrice).toFixed(4)} USD) to + create a token + </AlertMsg> + )} + + {tokens && tokens.length > 0 ? ( + <> + <TokenList + wallet={wallet} + tokens={tokens} + jestBCH={false} + /> + </> + ) : ( + <>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 --- /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( + <ThemeProvider theme={theme}> + <CreateTokenForm + BCH={testBCH} + getRestUrl={getRestUrl} + createToken={createToken} + disabled={ + walletWithBalancesAndTokensWithCorrectState.wallet.state + .balances.totalBalanceInSatoshis < 546 + } + /> + </ThemeProvider>, + ); + 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 --- /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( + <ThemeProvider theme={theme}> + <Router> + <Tokens jestBCH={testBCH} /> + </Router> + </ThemeProvider>, + ); + let tree = component.toJSON(); + expect(tree).toMatchSnapshot(); +}); + +test('Wallet with BCH balances', () => { + useContextMock.mockReturnValue(walletWithBalancesMock); + const testBCH = new BCHJS(); + const component = renderer.create( + <ThemeProvider theme={theme}> + <Router> + <Tokens jestBCH={testBCH} /> + </Router> + </ThemeProvider>, + ); + 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( + <ThemeProvider theme={theme}> + <Router> + <Tokens jestBCH={testBCH} /> + </Router> + </ThemeProvider>, + ); + 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( + <ThemeProvider theme={theme}> + <Router> + <Tokens jestBCH={testBCH} /> + </Router> + </ThemeProvider>, + ); + 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( + <ThemeProvider theme={theme}> + <Router> + <Tokens jestBCH={testBCH} /> + </Router> + </ThemeProvider>, + ); + 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( + <ThemeProvider theme={theme}> + <Router> + <Tokens jestBCH={testBCH} /> + </Router> + </ThemeProvider>, + ); + 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 --- /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`] = ` +<div + className="ant-spin-nested-loading" +> + <div + className="ant-spin-container" + > + <div + className="ant-collapse ant-collapse-icon-position-left sc-gqjmRU hqANsM" + role={null} + style={ + Object { + "marginBottom": "24px", + } + } + > + <div + className="ant-collapse-item" + > + <div + aria-expanded={false} + className="ant-collapse-header" + onClick={[Function]} + onKeyPress={[Function]} + role="button" + tabIndex={0} + > + <span + aria-label="right" + className="anticon anticon-right ant-collapse-arrow" + role="img" + > + <svg + aria-hidden="true" + data-icon="right" + fill="currentColor" + focusable="false" + height="1em" + viewBox="64 64 896 896" + width="1em" + > + <path + d="M765.7 486.8L314.9 134.7A7.97 7.97 0 00302 141v77.3c0 4.9 2.3 9.6 6.1 12.6l360 281.1-360 281.1c-3.9 3-6.1 7.7-6.1 12.6V883c0 6.7 7.7 10.4 12.9 6.3l450.8-352.1a31.96 31.96 0 000-50.4z" + /> + </svg> + </span> + Create Token + </div> + </div> + </div> + </div> +</div> +`; 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 --- /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`] = ` +<div + className="sc-jzJRlG iglgzj" +> + <span + aria-label="loading" + className="anticon anticon-loading anticon-spin" + role="img" + > + <svg + aria-hidden="true" + data-icon="loading" + fill="currentColor" + focusable="false" + height="1em" + viewBox="0 0 1024 1024" + width="1em" + > + <path + d="M988 548c-19.9 0-36-16.1-36-36 0-59.4-11.6-117-34.6-171.3a440.45 440.45 0 00-94.3-139.9 437.71 437.71 0 00-139.9-94.3C629 83.6 571.4 72 512 72c-19.9 0-36-16.1-36-36s16.1-36 36-36c69.1 0 136.2 13.5 199.3 40.3C772.3 66 827 103 874 150c47 47 83.9 101.8 109.7 162.7 26.7 63.1 40.2 130.2 40.2 199.3.1 19.9-16 36-35.9 36z" + /> + </svg> + </span> +</div> +`; + +exports[`Wallet with BCH balances and tokens 1`] = ` +<div + className="sc-jzJRlG iglgzj" +> + <span + aria-label="loading" + className="anticon anticon-loading anticon-spin" + role="img" + > + <svg + aria-hidden="true" + data-icon="loading" + fill="currentColor" + focusable="false" + height="1em" + viewBox="0 0 1024 1024" + width="1em" + > + <path + d="M988 548c-19.9 0-36-16.1-36-36 0-59.4-11.6-117-34.6-171.3a440.45 440.45 0 00-94.3-139.9 437.71 437.71 0 00-139.9-94.3C629 83.6 571.4 72 512 72c-19.9 0-36-16.1-36-36s16.1-36 36-36c69.1 0 136.2 13.5 199.3 40.3C772.3 66 827 103 874 150c47 47 83.9 101.8 109.7 162.7 26.7 63.1 40.2 130.2 40.2 199.3.1 19.9-16 36-35.9 36z" + /> + </svg> + </span> +</div> +`; + +exports[`Wallet with BCH balances and tokens and state field 1`] = ` +<div + className="sc-jzJRlG iglgzj" +> + <span + aria-label="loading" + className="anticon anticon-loading anticon-spin" + role="img" + > + <svg + aria-hidden="true" + data-icon="loading" + fill="currentColor" + focusable="false" + height="1em" + viewBox="0 0 1024 1024" + width="1em" + > + <path + d="M988 548c-19.9 0-36-16.1-36-36 0-59.4-11.6-117-34.6-171.3a440.45 440.45 0 00-94.3-139.9 437.71 437.71 0 00-139.9-94.3C629 83.6 571.4 72 512 72c-19.9 0-36-16.1-36-36s16.1-36 36-36c69.1 0 136.2 13.5 199.3 40.3C772.3 66 827 103 874 150c47 47 83.9 101.8 109.7 162.7 26.7 63.1 40.2 130.2 40.2 199.3.1 19.9-16 36-35.9 36z" + /> + </svg> + </span> +</div> +`; + +exports[`Wallet with BCH balances and tokens and state field, but no params in state 1`] = ` +<div + className="sc-jzJRlG iglgzj" +> + <span + aria-label="loading" + className="anticon anticon-loading anticon-spin" + role="img" + > + <svg + aria-hidden="true" + data-icon="loading" + fill="currentColor" + focusable="false" + height="1em" + viewBox="0 0 1024 1024" + width="1em" + > + <path + d="M988 548c-19.9 0-36-16.1-36-36 0-59.4-11.6-117-34.6-171.3a440.45 440.45 0 00-94.3-139.9 437.71 437.71 0 00-139.9-94.3C629 83.6 571.4 72 512 72c-19.9 0-36-16.1-36-36s16.1-36 36-36c69.1 0 136.2 13.5 199.3 40.3C772.3 66 827 103 874 150c47 47 83.9 101.8 109.7 162.7 26.7 63.1 40.2 130.2 40.2 199.3.1 19.9-16 36-35.9 36z" + /> + </svg> + </span> +</div> +`; + +exports[`Wallet without BCH balance 1`] = ` +<div + className="sc-jzJRlG iglgzj" +> + <span + aria-label="loading" + className="anticon anticon-loading anticon-spin" + role="img" + > + <svg + aria-hidden="true" + data-icon="loading" + fill="currentColor" + focusable="false" + height="1em" + viewBox="0 0 1024 1024" + width="1em" + > + <path + d="M988 548c-19.9 0-36-16.1-36-36 0-59.4-11.6-117-34.6-171.3a440.45 440.45 0 00-94.3-139.9 437.71 437.71 0 00-139.9-94.3C629 83.6 571.4 72 512 72c-19.9 0-36-16.1-36-36s16.1-36 36-36c69.1 0 136.2 13.5 199.3 40.3C772.3 66 827 103 874 150c47 47 83.9 101.8 109.7 162.7 26.7 63.1 40.2 130.2 40.2 199.3.1 19.9-16 36-35.9 36z" + /> + </svg> + </span> +</div> +`; + +exports[`Without wallet defined 1`] = ` +<div + className="sc-jzJRlG iglgzj" +> + <span + aria-label="loading" + className="anticon anticon-loading anticon-spin" + role="img" + > + <svg + aria-hidden="true" + data-icon="loading" + fill="currentColor" + focusable="false" + height="1em" + viewBox="0 0 1024 1024" + width="1em" + > + <path + d="M988 548c-19.9 0-36-16.1-36-36 0-59.4-11.6-117-34.6-171.3a440.45 440.45 0 00-94.3-139.9 437.71 437.71 0 00-139.9-94.3C629 83.6 571.4 72 512 72c-19.9 0-36-16.1-36-36s16.1-36 36-36c69.1 0 136.2 13.5 199.3 40.3C772.3 66 827 103 874 150c47 47 83.9 101.8 109.7 162.7 26.7 63.1 40.2 130.2 40.2 199.3.1 19.9-16 36-35.9 36z" + /> + </svg> + </span> +</div> +`; diff --git a/web/cashtab/src/components/Wallet/Tx.js b/web/cashtab/src/components/Wallet/Tx.js --- a/web/cashtab/src/components/Wallet/Tx.js +++ b/web/cashtab/src/components/Wallet/Tx.js @@ -1,6 +1,10 @@ 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'; @@ -11,6 +15,9 @@ 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; @@ -141,10 +148,30 @@ return ( <TxWrapper> - <TxIcon>{data.outgoingTx ? <SentTx /> : <ReceivedTx />}</TxIcon> + <TxIcon> + {data.outgoingTx ? ( + <> + {data.tokenTx && + data.tokenInfo.transactionType === 'GENESIS' ? ( + <GenesisTx /> + ) : ( + <SentTx /> + )} + </> + ) : ( + <ReceivedTx /> + )} + </TxIcon> <DateType> {data.outgoingTx ? ( - <SentLabel>Sent</SentLabel> + <> + {data.tokenTx && + data.tokenInfo.transactionType === 'GENESIS' ? ( + <ReceivedLabel>Genesis</ReceivedLabel> + ) : ( + <SentLabel>Sent</SentLabel> + )} + </> ) : ( <ReceivedLabel>Received</ReceivedLabel> )} @@ -187,13 +214,32 @@ </TxTokenIcon> {data.outgoingTx ? ( <> - <TokenTxAmt> - - {data.tokenInfo.qtySent.toString()} - {data.tokenInfo.tokenTicker} - </TokenTxAmt> - <TokenName> - {data.tokenInfo.tokenName} - </TokenName> + {data.tokenInfo.transactionType === + 'GENESIS' ? ( + <> + <TokenTxAmt> + +{' '} + {data.tokenInfo.qtyReceived.toString()} + + {data.tokenInfo.tokenTicker} + </TokenTxAmt> + <TokenName> + {data.tokenInfo.tokenName} + </TokenName> + </> + ) : ( + <> + <TokenTxAmt> + -{' '} + {data.tokenInfo.qtySent.toString()} + + {data.tokenInfo.tokenTicker} + </TokenTxAmt> + <TokenName> + {data.tokenInfo.tokenName} + </TokenName> + </> + )} </> ) : ( <> diff --git a/web/cashtab/src/components/Wallet/Wallet.js b/web/cashtab/src/components/Wallet/Wallet.js --- a/web/cashtab/src/components/Wallet/Wallet.js +++ b/web/cashtab/src/components/Wallet/Wallet.js @@ -10,49 +10,12 @@ 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; @@ -357,7 +320,11 @@ </TabPane> <TabPane active={activeTab === 'tokens'}> {tokens && tokens.length > 0 ? ( - <TokenList tokens={tokens} /> + <TokenList + wallet={wallet} + tokens={tokens} + jestBCH={false} + /> ) : ( <p> Tokens sent to your {currency.tokenTicker}{' '} 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 --- a/web/cashtab/src/components/Wallet/__tests__/__snapshots__/Wallet.test.js.snap +++ b/web/cashtab/src/components/Wallet/__tests__/__snapshots__/Wallet.test.js.snap @@ -3,14 +3,14 @@ exports[`Wallet with BCH balances 1`] = ` Array [ <div - className="sc-jAaTju dsaCuU" + className="sc-jDwBTQ giRFkr" > 0.06047469 BCHA </div>, <div - className="sc-jDwBTQ hhosDq" + className="sc-gPEVay bgrZDN" > $ NaN @@ -82,16 +82,16 @@ </div> </div>, <div - className="sc-Rmtcm ldTlYI" + className="sc-jhAzac vWMwd" > <div - className="sc-bRBYWo cvfjTr" + className="sc-fBuWsC jGeMc" onClick={[Function]} > BCHA </div> <div - className="sc-bRBYWo cvfjTr nonactiveBtn" + className="sc-fBuWsC jGeMc nonactiveBtn" onClick={[Function]} > SLPA @@ -103,14 +103,14 @@ exports[`Wallet with BCH balances and tokens 1`] = ` Array [ <div - className="sc-jAaTju dsaCuU" + className="sc-jDwBTQ giRFkr" > 0.06047469 BCHA </div>, <div - className="sc-jDwBTQ hhosDq" + className="sc-gPEVay bgrZDN" > $ NaN @@ -182,16 +182,16 @@ </div> </div>, <div - className="sc-Rmtcm ldTlYI" + className="sc-jhAzac vWMwd" > <div - className="sc-bRBYWo cvfjTr" + className="sc-fBuWsC jGeMc" onClick={[Function]} > BCHA </div> <div - className="sc-bRBYWo cvfjTr nonactiveBtn" + className="sc-fBuWsC jGeMc nonactiveBtn" onClick={[Function]} > SLPA @@ -203,14 +203,14 @@ exports[`Wallet with BCH balances and tokens and state field 1`] = ` Array [ <div - className="sc-jAaTju dsaCuU" + className="sc-jDwBTQ giRFkr" > 0.06047469 BCHA </div>, <div - className="sc-jDwBTQ hhosDq" + className="sc-gPEVay bgrZDN" > $ NaN @@ -282,16 +282,16 @@ </div> </div>, <div - className="sc-Rmtcm ldTlYI" + className="sc-jhAzac vWMwd" > <div - className="sc-bRBYWo cvfjTr" + className="sc-fBuWsC jGeMc" onClick={[Function]} > BCHA </div> <div - className="sc-bRBYWo cvfjTr nonactiveBtn" + className="sc-fBuWsC jGeMc nonactiveBtn" onClick={[Function]} > SLPA @@ -303,14 +303,14 @@ exports[`Wallet with BCH balances and tokens and state field, but no params in state 1`] = ` Array [ <div - className="sc-jAaTju dsaCuU" + className="sc-jDwBTQ giRFkr" > 0.06047469 BCHA </div>, <div - className="sc-jDwBTQ hhosDq" + className="sc-gPEVay bgrZDN" > $ NaN @@ -382,16 +382,16 @@ </div> </div>, <div - className="sc-Rmtcm ldTlYI" + className="sc-jhAzac vWMwd" > <div - className="sc-bRBYWo cvfjTr" + className="sc-fBuWsC jGeMc" onClick={[Function]} > BCHA </div> <div - className="sc-bRBYWo cvfjTr nonactiveBtn" + className="sc-fBuWsC jGeMc nonactiveBtn" onClick={[Function]} > SLPA @@ -403,7 +403,7 @@ exports[`Wallet without BCH balance 1`] = ` Array [ <div - className="sc-gPEVay iEbfNY" + className="sc-iRbamj kYGqgG" > <span aria-label="party emoji" @@ -429,7 +429,7 @@ to send to others </div>, <div - className="sc-jAaTju dsaCuU" + className="sc-jDwBTQ giRFkr" > 0 BCHA @@ -499,16 +499,16 @@ </div> </div>, <div - className="sc-Rmtcm ldTlYI" + className="sc-jhAzac vWMwd" > <div - className="sc-bRBYWo cvfjTr" + className="sc-fBuWsC jGeMc" onClick={[Function]} > BCHA </div> <div - className="sc-bRBYWo cvfjTr nonactiveBtn" + className="sc-fBuWsC jGeMc nonactiveBtn" onClick={[Function]} > SLPA diff --git a/web/cashtab/src/hooks/__mocks__/createToken.js b/web/cashtab/src/hooks/__mocks__/createToken.js new file mode 100644 --- /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 --- a/web/cashtab/src/hooks/__mocks__/mockParseTokenInfoForTxHistory.js +++ b/web/cashtab/src/hooks/__mocks__/mockParseTokenInfoForTxHistory.js @@ -1,3 +1,5 @@ +// @generated + export const tokenSendWdt = { txid: 'b923fba5b011df438c96f7f8f715fcf2b9ac2f96ea73139885e00aee4361f98f', parsedTx: { @@ -69,6 +71,7 @@ tokenName: 'Test Token With Exceptionally Long Name For CSS And Style Revisions', tokenTicker: 'WDT', + transactionType: 'SEND', }, }; @@ -135,5 +138,63 @@ '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 --- a/web/cashtab/src/hooks/__tests__/useBCH.test.js +++ b/web/cashtab/src/hooks/__tests__/useBCH.test.js @@ -5,6 +5,7 @@ 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'; @@ -15,6 +16,7 @@ import { tokenSendWdt, tokenReceiveTBS, + tokenGenesisCashtabMintAlpha, } from '../__mocks__/mockParseTokenInfoForTxHistory'; import { mockSentCashTx, @@ -304,6 +306,43 @@ ); }); + 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( @@ -358,4 +397,14 @@ ), ).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 --- a/web/cashtab/src/hooks/useBCH.js +++ b/web/cashtab/src/hooks/useBCH.js @@ -5,6 +5,7 @@ toSmallestDenomination, batchArray, flattenBatchedHydratedUtxos, + isValidStoredWallet, } from '@utils/cashMethods'; export default function useBCH() { @@ -202,6 +203,7 @@ txDataPromiseResponse = await Promise.all(txDataPromises); const parsed = parseTxData(txDataPromiseResponse); + return parsed; } catch (err) { console.log(`Error in Promise.all(txDataPromises):`); @@ -212,7 +214,7 @@ 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; @@ -223,7 +225,13 @@ 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) { @@ -242,6 +250,7 @@ cashtabTokenInfo.tokenId = tokenInfo.tokenIdHex; cashtabTokenInfo.tokenName = tokenInfo.tokenName; cashtabTokenInfo.tokenTicker = tokenInfo.tokenTicker; + cashtabTokenInfo.transactionType = transactionType; return cashtabTokenInfo; }; @@ -489,6 +498,130 @@ 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, @@ -849,5 +982,6 @@ getRestUrl, sendBch, sendToken, + createToken, }; } diff --git a/web/cashtab/src/utils/__tests__/validation.test.js b/web/cashtab/src/utils/__tests__/validation.test.js --- a/web/cashtab/src/utils/__tests__/validation.test.js +++ b/web/cashtab/src/utils/__tests__/validation.test.js @@ -1,4 +1,12 @@ -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', () => { @@ -79,4 +87,90 @@ 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 --- a/web/cashtab/src/utils/validation.js +++ b/web/cashtab/src/utils/validation.js @@ -43,3 +43,43 @@ .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 + ); +};