diff --git a/cashtab/extension/public/manifest.json b/cashtab/extension/public/manifest.json --- a/cashtab/extension/public/manifest.json +++ b/cashtab/extension/public/manifest.json @@ -1,9 +1,8 @@ { "manifest_version": 3, - "name": "Cashtab", "description": "A browser-integrated eCash wallet from Bitcoin ABC", - "version": "3.23.0", + "version": "3.24.0", "content_scripts": [ { "matches": ["file://*/*", "http://*/*", "https://*/*"], diff --git a/cashtab/package-lock.json b/cashtab/package-lock.json --- a/cashtab/package-lock.json +++ b/cashtab/package-lock.json @@ -1,12 +1,12 @@ { "name": "cashtab", - "version": "2.23.5", + "version": "2.24.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cashtab", - "version": "2.23.5", + "version": "2.24.0", "dependencies": { "@ant-design/icons": "^5.3.0", "@bitgo/utxo-lib": "^9.33.0", diff --git a/cashtab/package.json b/cashtab/package.json --- a/cashtab/package.json +++ b/cashtab/package.json @@ -1,6 +1,6 @@ { "name": "cashtab", - "version": "2.23.5", + "version": "2.24.0", "private": true, "scripts": { "start": "node scripts/start.js", diff --git a/cashtab/src/components/App/App.js b/cashtab/src/components/App/App.js --- a/cashtab/src/components/App/App.js +++ b/cashtab/src/components/App/App.js @@ -11,6 +11,7 @@ ReceiveIcon, SettingsIcon, AirdropIcon, + BankIcon, WalletIcon, ContactsIcon, ThemedSignAndVerifyMsg, @@ -28,6 +29,7 @@ import Airdrop from 'components/Airdrop/Airdrop'; import BackupWallet from 'components/BackupWallet/BackupWallet'; import Contacts from 'components/Contacts'; +import Wallets from 'components/Wallets'; import Alias from 'components/Alias/Alias'; import Etokens from 'components/Etokens/Etokens'; import Configure from 'components/Configure/Configure'; @@ -572,6 +574,13 @@ )} + {selectedKey === + 'wallets' && ( + + Wallets + + + )} {selectedKey === 'configure' && ( @@ -682,6 +691,10 @@ } /> + } + /> } @@ -797,6 +810,15 @@

Wallet Backup

+ navigate('/wallets')} + > + {' '} +

Wallets

+ +
{ const mockedChronik = await initializeCashtabStateForTests( @@ -498,125 +502,6 @@ // The value field is populated with dust expect(screen.getByPlaceholderText('Amount')).toHaveValue(5.5); }); - it('We do not see the camera auto-open setting in the config screen on a desktop device', async () => { - const mockedChronik = await initializeCashtabStateForTests( - freshWalletWithOneIncomingCashtabMsg, - localforage, - ); - - render( - , - ); - - // We are on the settings screen - await screen.findByTestId('configure-ctn'); - - // We do not see the auto open option - expect( - screen.queryByText('Auto-open camera on send'), - ).not.toBeInTheDocument(); - }); - it('We do see the camera auto-open setting in the config screen on a mobile device', async () => { - Object.defineProperty(navigator, 'userAgentData', { - value: { - mobile: true, - }, - writable: true, - }); - - // Get mocked chronik client with expected API results for this wallet - const mockedChronik = await initializeCashtabStateForTests( - freshWalletWithOneIncomingCashtabMsg, - localforage, - ); - - render( - , - ); - - // We are on the settings screen - await screen.findByTestId('configure-ctn'); - - // Now we do see the auto open option - expect( - await screen.findByText('Auto-open camera on send'), - ).toBeInTheDocument(); - - // Unset mock - Object.defineProperty(navigator, 'userAgentData', { - value: { - mobile: false, - }, - writable: true, - }); - }); - it('Setting "Send Confirmations" settings will show send confirmations', async () => { - const mockedChronik = await initializeCashtabStateForTests( - walletWithXecAndTokens, - localforage, - ); - - const hex = - '0200000001fe667fba52a1aa603a892126e492717eed3dad43bfea7365a7fdd08e051e8a21020000006a47304402206e0875eb1b866bc063217eb55ba88ddb2a5c4f299278e0c7ce4f34194619a6d502201e2c373cfe93ed35c6502e22b748ab07893e22643107b58f018af8ffbd6b654e4121031d4603bdc23aca9432f903e3cf5975a3f655cc3fa5057c61d00dfc1ca5dfd02dffffffff027c150000000000001976a9146ffbe7c7d7bd01295eb1e371de9550339bdcf9fd88accd6c0e00000000001976a9143a5fb236934ec078b4507c303d3afd82067f8fc188ac00000000'; - const txid = - '7eb806a83c48b0ab38b5af10aaa7452d4648f2c0d41975343ada9f4aa8255bd8'; - mockedChronik.setMock('broadcastTx', { - input: hex, - output: { txid }, - }); - - render(); - - // Default route is home - await screen.findByTestId('tx-history-ctn'); - - // Click the hamburger menu - await user.click(screen.queryByTestId('hamburger')); - - // Navigate to Settings screen - await user.click(screen.queryByTestId('nav-btn-configure')); - - // Now we see the Settings screen - expect(screen.getByTestId('configure-ctn')).toBeInTheDocument(); - - // Send confirmations are disabled by default - - // Enable send confirmations - await user.click(screen.getByTestId('send-confirmations-switch')); - - // Navigate to the Send screen - await user.click(screen.queryByTestId('nav-btn-send')); - - // Now we see the Send screen - expect(screen.getByTestId('send-to-many-switch')).toBeInTheDocument(); - - // Fill out to and amount - await user.type( - screen.getByPlaceholderText('Address'), - 'ecash:qphlhe78677sz227k83hrh542qeehh8el5lcjwk72y', - ); - await user.type(screen.getByPlaceholderText('Amount'), '55'); - // click send - await user.click(screen.getByRole('button', { name: /Send/ })); - // we see a modal - expect( - await screen.findByText( - `Send 55 XEC to ecash:qphlhe78677sz227k83hrh542qeehh8el5lcjwk72y`, - ), - ).toBeInTheDocument(); - - // We can click ok to send the tx - await user.click(screen.getByText('OK')); - - // Notification is rendered with expected txid?; - const txSuccessNotification = await screen.findByText('eCash sent'); - await waitFor(() => - expect(txSuccessNotification).toHaveAttribute( - 'href', - `${explorer.blockExplorerUrl}/tx/${txid}`, - ), - ); - }); it('If Cashtab starts up with some settings keys missing, the missing keys are migrated to default values', async () => { // Note: this is what happens to existing users when we add a new key to cashtabState.settings const mockedChronik = await initializeCashtabStateForTests( @@ -649,140 +534,6 @@ }), ); }); - it('Setting "ABSOLUTE MINIMUM fees" settings will reduce fees to absolute min', async () => { - // Modify walletWithXecAndTokens to have the required token for this feature - const walletWithVipToken = { - ...walletWithXecAndTokens, - state: { - ...walletWithXecAndTokens.state, - slpUtxos: [ - ...walletWithXecAndTokens.state.slpUtxos, - requiredUtxoThisToken, - ], - }, - }; - - const mockedChronik = await initializeCashtabStateForTests( - walletWithVipToken, - localforage, - ); - - // Make sure the app can get this token's genesis info by calling a mock - mockedChronik.setMock('token', { - input: appConfig.vipSettingsTokenId, - output: vipTokenChronikTokenMocks.token, - }); - mockedChronik.setMock('tx', { - input: appConfig.vipSettingsTokenId, - output: vipTokenChronikTokenMocks.tx, - }); - - // Can verify in Electrum that this tx is sent at 1.0 sat/byte - const hex = - '0200000001fe667fba52a1aa603a892126e492717eed3dad43bfea7365a7fdd08e051e8a21020000006a473044022043679b2fcde0099b0cd29bfbca382e92e3b871c079a0db7d73c39440d067f5bb02202e2ab2d5d83b70911da2758afd9e56eaaaa989050f35e4cc4d28d20afc29778a4121031d4603bdc23aca9432f903e3cf5975a3f655cc3fa5057c61d00dfc1ca5dfd02dffffffff027c150000000000001976a9146ffbe7c7d7bd01295eb1e371de9550339bdcf9fd88acb26d0e00000000001976a9143a5fb236934ec078b4507c303d3afd82067f8fc188ac00000000'; - const txid = - '6d2e157e2e2b1fa47cc63ede548375213942e29c090f5d9cbc2722258f720c08'; - mockedChronik.setMock('broadcastTx', { - input: hex, - output: { txid }, - }); - - // Can verify in Electrum that this tx is sent at 1.0 sat/byte - const tokenSendHex = - '02000000023abaa0b3d97fdc6fb07a535c552fcb379e7bffa6e7e52707b8cf1507bf243e42010000006b483045022100a3ee483d79bbc25ea139dbdac578a533ceb6a8764ba49aa4a46f9cfd73efd86602202fe5a207777e0ef846d19e04b75f9ebb3ff5e0c3b70108526aadb2e9ea27865c4121031d4603bdc23aca9432f903e3cf5975a3f655cc3fa5057c61d00dfc1ca5dfd02dfffffffffe667fba52a1aa603a892126e492717eed3dad43bfea7365a7fdd08e051e8a21020000006b483045022100c45c36d3083c2a7980535b0495a34c976c90bb51de502b9f9f3f840578a46283022034d491a71135e8497bfa79b664e0e7d5458ec3387643dc1636a8d65721c7b2054121031d4603bdc23aca9432f903e3cf5975a3f655cc3fa5057c61d00dfc1ca5dfd02dffffffff040000000000000000406a04534c500001010453454e4420fb4233e8a568993976ed38a81c2671587c5ad09552dedefa78760deed6ff87aa08000000024e160364080000000005f5e09c22020000000000001976a9144e532257c01b310b3b5c1fd947c79a72addf852388ac22020000000000001976a9143a5fb236934ec078b4507c303d3afd82067f8fc188ac0d800e00000000001976a9143a5fb236934ec078b4507c303d3afd82067f8fc188ac00000000'; - const tokenSendTxid = - 'ce727c96439dfe365cb47f780c37ebb2e756051db62375e992419d5db3c81b1e'; - - mockedChronik.setMock('broadcastTx', { - input: tokenSendHex, - output: { txid: tokenSendTxid }, - }); - - render(); - - // Default route is home - await screen.findByTestId('tx-history-ctn'); - - // Click the hamburger menu - await user.click(screen.queryByTestId('hamburger')); - - // Navigate to Settings screen - await user.click(screen.queryByTestId('nav-btn-configure')); - - // Now we see the Settings screen - expect(screen.getByTestId('configure-ctn')).toBeInTheDocument(); - - // Send confirmations are disabled by default - - // Enable min fee sends - await user.click(screen.getByTestId('settings-minFeeSends-switch')); - - // Navigate to the Send screen - await user.click(screen.queryByTestId('nav-btn-send')); - - // Now we see the Send screen - expect(screen.getByTestId('send-to-many-switch')).toBeInTheDocument(); - - // Fill out to and amount - await user.type( - screen.getByPlaceholderText('Address'), - 'ecash:qphlhe78677sz227k83hrh542qeehh8el5lcjwk72y', - ); - await user.type(screen.getByPlaceholderText('Amount'), '55'); - - // click send to broadcast the tx - await user.click(screen.getByRole('button', { name: /Send/ })); - - // Notification is rendered with expected txid - const txSuccessNotification = await screen.findByText('eCash sent'); - await waitFor(() => - expect(txSuccessNotification).toHaveAttribute( - 'href', - `${explorer.blockExplorerUrl}/tx/${txid}`, - ), - ); - - // If the user's balance of this token falls below the required amount, - // the feature will be disabled even though the settings value persists - - // Send some tokens - - // Navigate to eTokens screen - await user.click(screen.queryByTestId('nav-btn-etokens')); - - // Click on the VIP token - await user.click(screen.getByText('GRP')); - - // Wait for element to get token info and load - expect((await screen.findAllByText(/GRP/))[0]).toBeInTheDocument(); - - // We send enough GRP to be under the min - await user.type( - screen.getByPlaceholderText('Address'), - 'ecash:qp89xgjhcqdnzzemts0aj378nfe2mhu9yvxj9nhgg6', - ); - await user.type(screen.getByPlaceholderText('Amount'), '99000001'); - - // Click the Send token button - await user.click(screen.getByRole('button', { name: /Send/ })); - - const sendTokenSuccessNotification = await screen.findByText( - 'eToken sent', - ); - await waitFor(() => - expect(sendTokenSuccessNotification).toHaveAttribute( - 'href', - `${explorer.blockExplorerUrl}/tx/${tokenSendTxid}`, - ), - ); - - // Actually we can't update the utxo set now, so we add a separate test to confirm - // the feature is disabled even if it was set to true but then token balance decreased - // TODO we can test wallet utxo set updates if we connect some Cashtab integration tests - // to regtest - - // See SendXec test, "If the user has minFeeSends set to true but no longer has the right token amount, the feature is disabled" - }); it('Wallet with easter egg token sees easter egg', async () => { const EASTER_EGG_TOKENID = '50d8292c6255cda7afc6c8566fed3cf42a2794e9619740fe8f4c95431271410e'; diff --git a/cashtab/src/components/App/fixtures/mocks.js b/cashtab/src/components/App/fixtures/mocks.js --- a/cashtab/src/components/App/fixtures/mocks.js +++ b/cashtab/src/components/App/fixtures/mocks.js @@ -2382,3 +2382,18 @@ '76a91495e79f51d4260bc0dc3ba7fb77c7be92d0fbdd1d88ac', ], }; + +export const populatedContactList = [ + { + address: 'ecash:qp89xgjhcqdnzzemts0aj378nfe2mhu9yvxj9nhgg6', + name: 'alpha', + }, + { + address: 'ecash:qz2708636snqhsxu8wnlka78h6fdp77ar59jrf5035', + name: 'beta', + }, + { + address: 'ecash:qphlhe78677sz227k83hrh542qeehh8el5lcjwk72y', + name: 'gamma', + }, +]; diff --git a/cashtab/src/components/Common/CustomIcons.js b/cashtab/src/components/Common/CustomIcons.js --- a/cashtab/src/components/Common/CustomIcons.js +++ b/cashtab/src/components/Common/CustomIcons.js @@ -8,6 +8,7 @@ CopyOutlined, DollarOutlined, LoadingOutlined, + BankOutlined, WalletOutlined, SettingOutlined, LockOutlined, @@ -70,6 +71,9 @@ export const ThemedWalletOutlined = styled(WalletOutlined)` color: ${props => props.theme.icons.outlined} !important; `; +export const ThemedBankOutlined = styled(BankOutlined)` + color: ${props => props.theme.icons.outlined} !important; +`; export const ThemedSettingOutlined = styled(SettingOutlined)` color: ${props => props.theme.icons.outlined} !important; `; @@ -143,6 +147,9 @@ export const WalletIcon = styled(ThemedWalletOutlined)` min-width: 24px; `; +export const BankIcon = styled(ThemedBankOutlined)` + min-width: 24px; +`; export const ContactsIcon = styled(ThemedContactsOutlined)` min-width: 24px; `; diff --git a/cashtab/src/components/Configure/Configure.js b/cashtab/src/components/Configure/Configure.js --- a/cashtab/src/components/Configure/Configure.js +++ b/cashtab/src/components/Configure/Configure.js @@ -2,24 +2,14 @@ // Distributed under the MIT software license, see the accompanying // file COPYING or http://www.opensource.org/licenses/mit-license.php. -import React, { useState } from 'react'; +import React from 'react'; import styled from 'styled-components'; import { Link } from 'react-router-dom'; -import { Collapse, Tooltip } from 'antd'; import { LockFilled } from '@ant-design/icons'; import { WalletContext } from 'wallet/context'; -import { StyledCollapse } from 'components/Common/StyledCollapse'; -import { AntdFormWrapper } from 'components/Common/EnhancedInputs'; -import PrimaryButton, { - SecondaryButton, -} from 'components/Common/PrimaryButton'; import { - ThemedWalletOutlined, ThemedDollarOutlined, ThemedSettingOutlined, - ThemedContactsOutlined, - ThemedTrashcanOutlined, - ThemedEditOutlined, ThemedXIcon, ThemedFacebookIcon, ThemedGithubIcon, @@ -27,187 +17,18 @@ SocialLink, } from 'components/Common/CustomIcons'; import TokenIcon from 'components/Etokens/TokenIcon'; -import { Event } from 'components/Common/GoogleAnalytics'; -import ApiError from 'components/Common/ApiError'; -import { isValidNewWalletNameLength, validateMnemonic } from 'validation'; import { getWalletState } from 'utils/cashMethods'; import appConfig from 'config/app'; -import { isMobile, getUserLocale } from 'helpers'; -import { - hasEnoughToken, - createCashtabWallet, - generateMnemonic, - toXec, - getWalletsForNewActiveWallet, -} from 'wallet'; -import CustomModal from 'components/Common/Modal'; -import { toast } from 'react-toastify'; -import { - Input, - ModalInput, - InputFlex, - CurrencySelect, -} from 'components/Common/Inputs'; +import { isMobile } from 'helpers'; +import { hasEnoughToken } from 'wallet'; +import { CurrencySelect } from 'components/Common/Inputs'; import Switch from 'components/Common/Switch'; import { Info } from 'components/Common/Atoms'; -const { Panel } = Collapse; - const VersionContainer = styled.div` color: ${props => props.theme.contrast}; `; -const SWRow = styled.div` - border-radius: 3px; - padding: 10px 0; - display: flex; - align-items: center; - justify-content: center; - margin-bottom: 6px; - @media (max-width: 500px) { - flex-direction: column; - margin-bottom: 12px; - } -`; - -const SWName = styled.div` - width: 50%; - display: flex; - align-items: center; - justify-content: space-between; - word-wrap: break-word; - hyphens: auto; - - @media (max-width: 500px) { - width: 100%; - justify-content: center; - margin-bottom: 15px; - } - - h3 { - font-size: 16px; - color: ${props => props.theme.darkBlue}; - margin: 0; - text-align: center; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - h3.overflow { - width: 100px; - overflow: hidden; - text-overflow: ellipsis; - } -`; - -const SWBalance = styled.div` - width: 50%; - display: flex; - align-items: center; - justify-content: space-between; - word-wrap: break-word; - hyphens: auto; - @media (max-width: 500px) { - width: 100%; - justify-content: center; - margin-bottom: 15px; - } - div { - font-size: 13px; - color: ${props => props.theme.darkBlue}; - margin: 0; - text-align: center; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - div.overflow { - width: 150px; - overflow: hidden; - text-overflow: ellipsis; - } -`; - -const SWButtonCtn = styled.div` - width: 50%; - display: flex; - align-items: center; - justify-content: flex-end; - @media (max-width: 500px) { - width: 100%; - justify-content: center; - } - - button { - cursor: pointer; - background: transparent; - border: 1px solid #fff; - box-shadow: none; - color: #fff; - border-radius: 3px; - opacity: 0.6; - transition: all 200ms ease-in-out; - - :hover { - opacity: 1; - background: ${props => props.theme.eCashBlue}; - border-color: ${props => props.theme.eCashBlue}; - } - - @media (max-width: 768px) { - font-size: 14px; - } - } - - svg { - stroke: ${props => props.theme.eCashBlue}; - fill: ${props => props.theme.eCashBlue}; - width: 20px; - height: 25px; - margin-right: 10px; - cursor: pointer; - :hover { - stroke: ${props => props.theme.settings.delete}; - fill: ${props => props.theme.settings.delete}; - } - } -`; - -const AWRow = styled.div` - padding: 10px 0; - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 6px; - h3 { - font-size: 16px; - flex: 1 1 0; - display: inline-block; - color: ${props => props.theme.darkBlue}; - margin: 0; - text-align: left; - font-weight: bold; - @media (max-width: 500px) { - font-size: 14px; - } - } - h4 { - font-size: 16px; - flex: 1 1 0; - display: inline-block; - color: ${props => props.theme.eCashBlue} !important; - margin: 0; - text-align: right; - } - ${SWButtonCtn} { - flex: 1 1 0; - } - @media (max-width: 500px) { - flex-direction: column; - margin-bottom: 12px; - } -`; - const StyledConfigure = styled.div` margin: 12px 0; h2 { @@ -270,8 +91,8 @@ const Configure = () => { const ContextValue = React.useContext(WalletContext); - const { apiError, updateCashtabState, cashtabState } = ContextValue; - const { contactList, settings, wallets } = cashtabState; + const { updateCashtabState, cashtabState } = ContextValue; + const { settings, wallets } = cashtabState; const wallet = wallets.length > 0 ? wallets[0] : false; @@ -280,307 +101,6 @@ const { tokens } = walletState; - const userLocale = getUserLocale(navigator); - - const [formData, setFormData] = useState({ - mnemonic: '', - }); - const [showRenameWalletModal, setShowRenameWalletModal] = useState(false); - const [showDeleteWalletModal, setShowDeleteWalletModal] = useState(false); - const [walletToBeRenamed, setWalletToBeRenamed] = useState(null); - const [walletToBeDeleted, setWalletToBeDeleted] = useState(null); - const [newWalletName, setNewWalletName] = useState(null); - const [ - confirmationOfWalletToBeDeleted, - setConfirmationOfWalletToBeDeleted, - ] = useState(''); - const [newWalletNameIsValid, setNewWalletNameIsValid] = useState(null); - const [walletDeleteConfirmationError, setWalletDeleteConfirmationError] = - useState(false); - const [seedInput, openSeedInput] = useState(false); - const [savedWalletContactModal, setSavedWalletContactModal] = - useState(false); - - const showPopulatedDeleteWalletModal = walletInfo => { - setWalletToBeDeleted(walletInfo); - setShowDeleteWalletModal(true); - }; - - const showPopulatedRenameWalletModal = walletInfo => { - setWalletToBeRenamed(walletInfo); - setShowRenameWalletModal(true); - }; - const cancelRenameWallet = () => { - // Delete form value - setNewWalletName(null); - setShowRenameWalletModal(false); - }; - const cancelDeleteWallet = () => { - setWalletToBeDeleted(null); - setConfirmationOfWalletToBeDeleted(''); - setShowDeleteWalletModal(false); - }; - - const [isValidMnemonic, setIsValidMnemonic] = useState(null); - - const [manualContactName, setManualContactName] = useState(''); - const [manualContactAddress, setManualContactAddress] = useState(''); - useState(false); - - // Generate a new wallet from a random seed and add it to wallets - const addNewWallet = async () => { - // Generate a new wallet with a new mnemonic - const mnemonic = generateMnemonic(); - const newAddedWallet = await createCashtabWallet(mnemonic); - - // Note: technically possible though highly unlikley that a wallet already exists with this name - // Also technically possible though ... er, almost impossibly improbable for wallet with same mnemonic to exist - // In both cases, the odds are tremendously low. - // Let's cover the edge case anyway though. It's easy enough for the user to just create - // a wallet again if we some crazy how get here - const walletAlreadyInWalletsSomehow = wallets.find( - wallet => - wallet.name === newAddedWallet.name || - wallet.mnemonic === newAddedWallet.mnemonic, - ); - if (typeof walletAlreadyInWalletsSomehow !== 'undefined') { - toast.error( - `By a vanishingly small chance, "${newAddedWallet.name}" already existed in saved wallets. Please try again.`, - ); - // Do not add this wallet - return; - } - - // Event("Category", "Action", "Label") - // Track number of times a different wallet is activated - Event('Configure.js', 'Create Wallet', 'New'); - // Add it to the end of the wallets object - updateCashtabState('wallets', [...wallets, newAddedWallet]); - - toast.success( - `New wallet "${newAddedWallet.name}" added to your saved wallets`, - ); - }; - - const activateWallet = (walletToActivate, wallets) => { - // Get desired wallets array after activating walletToActivate - const walletsAfterActivation = getWalletsForNewActiveWallet( - walletToActivate, - wallets, - ); - - // Event("Category", "Action", "Label") - // Track number of times a different wallet is activated - Event('Configure.js', 'Activate', ''); - - // Update wallets to activate this wallet - updateCashtabState('wallets', walletsAfterActivation); - }; - - /** - * Add a new imported wallet to cashtabState wallets object - * @param {mnemonic} string - */ - async function importNewWallet(mnemonic) { - // Make sure no existing wallets have this mnemonic - const walletInWallets = wallets.find( - wallet => wallet.mnemonic === mnemonic, - ); - - if (typeof walletInWallets !== 'undefined') { - // Import error modal - console.error( - `Cannot import: wallet already exists (name: "${walletInWallets.name}")`, - ); - toast.error( - `Cannot import: wallet already exists (name: "${walletInWallets.name}")`, - ); - // Do not clear form data in this case - return; - } - - // Create a new wallet from mnemonic - const newImportedWallet = await createCashtabWallet(formData.mnemonic); - - // Handle edge case of another wallet having the same name - const existingWalletHasSameName = wallets.find( - wallet => wallet.name === newImportedWallet, - ); - if (typeof existingWalletHasSameName !== 'undefined') { - // Import error modal for wallet existing with the same name - console.error( - `Cannot import: wallet with same name already exists (name: "${existingWalletHasSameName.name}")`, - ); - toast.error( - `Cannot import: wallet with same name already exists (name: "${existingWalletHasSameName.name}")`, - ); - // Do not clear form data in this case - return; - } - - // Event("Category", "Action", "Label") - // Track number of times a different wallet is activated - Event('Configure.js', 'Create Wallet', 'Imported'); - - // Add it to the end of the wallets object - updateCashtabState('wallets', [...wallets, newImportedWallet]); - - // Import success modal - toast.success( - `New imported wallet "${newImportedWallet.name}" added to your saved wallets`, - ); - - // Clear formdata - setFormData({ ...formData, mnemonic: '' }); - } - - const handleImportMnemonicInput = e => { - const { value, name } = e.target; - - const isValidMnemonic = validateMnemonic(value); - - // Validate mnemonic on change - // Import button should be disabled unless mnemonic is valid - setIsValidMnemonic(isValidMnemonic); - - setFormData(p => ({ ...p, [name]: value })); - }; - - /** - * Change wallet.name of an existing wallet - * @param {string} oldName previous wallet name - * @param {string} newName potential new wallet name - */ - const renameWallet = async (oldName, newName) => { - if (!isValidNewWalletNameLength(newName)) { - setNewWalletNameIsValid(false); - // Clear the input - setNewWalletName(null); - return; - } - - // Hide modal - setShowRenameWalletModal(false); - - // Change wallet name - const walletOfWalletsWithThisNameAlready = wallets.find( - wallet => wallet.name === newName, - ); - - if (typeof walletOfWalletsWithThisNameAlready !== 'undefined') { - // If there is already a wallet with this name, show an error modal - toast.error(`Rename failed. All wallets must have a unique name.`); - - // Clear the input - setNewWalletName(null); - - // Do not attempt to rename the wallet if you already have a wallet with this name - return; - } - - // Find the wallet and rename it - const indexOfWalletToBeRenamed = wallets.findIndex( - wallet => wallet.name === oldName, - ); - - if (typeof indexOfWalletToBeRenamed === 'undefined') { - // If we can't find this, we are also in trouble - // Should never happen - toast.error( - `Rename failed. Cashtab could not find existing wallet "${oldName}".`, - ); - - // Clear new wallet name input - setNewWalletName(null); - return; - } - - const renamedWallet = wallets[indexOfWalletToBeRenamed]; - renamedWallet.name = newName; - - // Update cashtabState and localforage - updateCashtabState('wallets', wallets); - - toast.success(`Wallet "${oldName}" renamed to "${newName}"`); - - // Clear wallet name for form - setNewWalletName(null); - }; - - /** - * Delete a specified wallet from users wallets array - * @param {object} walletToBeDeleted - */ - const deleteWallet = async walletToBeDeleted => { - if (walletDeleteConfirmationError) { - return; - } - - if ( - confirmationOfWalletToBeDeleted !== - `delete ${walletToBeDeleted.name}` - ) { - setWalletDeleteConfirmationError( - 'Your confirmation phrase must match exactly', - ); - return; - } - - // Hide modal - setShowDeleteWalletModal(false); - - // Find the wallet by mnemonic as this is guaranteed to be unique - const indexOfWalletToDelete = wallets.find( - wallet => wallet.mnemonic === walletToBeDeleted.mnemonic, - ); - - if (typeof indexOfWalletToDelete === 'undefined') { - // If we can't find it, there is some kind of problem - // Should never happen - toast.error(`Error deleting ${walletToBeDeleted.name}.`); - return; - } - - // Update in state and localforage with the same list, less the deleted wallet - updateCashtabState( - 'wallets', - wallets.filter( - wallet => wallet.mnemonic !== walletToBeDeleted.mnemonic, - ), - ); - - toast.success( - `Wallet "${walletToBeDeleted.name}" successfully deleted`, - ); - - setConfirmationOfWalletToBeDeleted(''); - }; - - const handleWalletNameInput = e => { - const { value } = e.target; - // validation - if (value && isValidNewWalletNameLength(value)) { - setNewWalletNameIsValid(true); - } else { - setNewWalletNameIsValid(false); - } - - setNewWalletName(value); - }; - - const handleWalletToDeleteInput = e => { - const { value } = e.target; - - if (value && value === `delete ${walletToBeDeleted.name}`) { - setWalletDeleteConfirmationError(false); - } else { - setWalletDeleteConfirmationError( - 'Your confirmation phrase must match exactly', - ); - } - setConfirmationOfWalletToBeDeleted(value); - }; - const handleSendModalToggle = e => { updateCashtabState('settings', { ...settings, @@ -608,101 +128,8 @@ }); }; - const handleAddSavedWalletAsContactOk = async () => { - // Check to see if the contact exists - const contactExists = contactList.find( - contact => contact.address === manualContactAddress, - ); - if (typeof contactExists !== 'undefined') { - // it exists - toast.error( - `${manualContactAddress} already exists in the Contact List`, - ); - } else { - contactList.push({ - name: manualContactName, - address: manualContactAddress, - }); - // update localforage and state - await updateCashtabState('contactList', contactList); - toast.success(`${manualContactAddress} added to Contacts`); - } - - // Reset relevant state fields - setSavedWalletContactModal(false); - setManualContactName(''); - setManualContactAddress(''); - }; - - const handleAddSavedWalletAsContactCancel = () => { - setSavedWalletContactModal(false); - setManualContactName(''); - setManualContactAddress(''); - }; - - const addSavedWalletToContact = wallet => { - if (!wallet) { - return; - } - // initialise saved wallet name and address to state for confirmation modal - setManualContactName(wallet.name); - setManualContactAddress(wallet.paths.get(1899).address); - setSavedWalletContactModal(true); - }; - return ( - {savedWalletContactModal && ( - handleAddSavedWalletAsContactOk()} - handleCancel={() => handleAddSavedWalletAsContactCancel()} - showCancelButton - /> - )} - {walletToBeRenamed !== null && showRenameWalletModal && ( - - renameWallet(walletToBeRenamed.name, newWalletName) - } - handleCancel={() => cancelRenameWallet()} - showCancelButton - > - - - )} - {walletToBeDeleted !== null && showDeleteWalletModal && ( - deleteWallet(walletToBeDeleted)} - handleCancel={() => cancelDeleteWallet()} - showCancelButton - > - - - )} ℹ️ Backup wallet has moved @@ -716,159 +143,11 @@ ℹ️ Contacts have moved to the{' '} Contacts screen + + ℹ️ Wallets have moved to the{' '} + Wallets screen + -

- Manage Wallets -

- {apiError ? ( - - ) : ( - <> - addNewWallet()}> - New Wallet - - openSeedInput(!seedInput)}> - Import Wallet - - {seedInput && ( - -

- Copy and paste your mnemonic seed phrase below - to import an existing wallet -

- - - - importNewWallet(formData.mnemonic) - } - > - Import - -
- )} - - )} - {wallet !== false && wallets.length > 0 && ( - <> - - -
- {wallets.map((wallet, index) => - index === 0 ? ( - - -

- {wallet.name} -

-
-

Currently active

- - - showPopulatedRenameWalletModal( - wallet, - ) - } - /> - - addSavedWalletToContact( - wallet, - ) - } - /> - -
- ) : ( - - - -

- {wallet.name} -

-
-
- -
- [ - {wallet?.state - ?.balanceSats !== 0 - ? toXec( - wallet.state - .balanceSats, - ).toLocaleString( - userLocale, - { - maximumFractionDigits: - appConfig.cashDecimals, - }, - ) - : 'N/A'}{' '} - XEC] -
-
- - - showPopulatedRenameWalletModal( - wallet, - ) - } - /> - - addSavedWalletToContact( - wallet, - ) - } - /> - - showPopulatedDeleteWalletModal( - wallet, - ) - } - /> - - -
- ), - )} -
-
-
- - )}

diff --git a/cashtab/src/components/Configure/__tests__/Configure.test.js b/cashtab/src/components/Configure/__tests__/Configure.test.js --- a/cashtab/src/components/Configure/__tests__/Configure.test.js +++ b/cashtab/src/components/Configure/__tests__/Configure.test.js @@ -3,8 +3,12 @@ // file COPYING or http://www.opensource.org/licenses/mit-license.php. import React from 'react'; -import { walletWithXecAndTokens } from 'components/App/fixtures/mocks'; -import { populatedContactList } from 'components/Configure/fixtures/mocks'; +import { + walletWithXecAndTokens, + vipTokenChronikTokenMocks, + freshWalletWithOneIncomingCashtabMsg, + requiredUtxoThisToken, +} from 'components/App/fixtures/mocks'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent, { PointerEventsCheckLevel, @@ -16,13 +20,10 @@ import appConfig from 'config/app'; import { initializeCashtabStateForTests, - prepareMockedChronikCallsForWallet, clearLocalForage, } from 'components/App/fixtures/helpers'; -import { validSavedWallets } from 'components/App/fixtures/mocks'; import CashtabTestWrapper from 'components/App/fixtures/CashtabTestWrapper'; -import * as bip39 from 'bip39'; -import { cashtabWalletsFromJSON } from 'helpers'; +import { explorer } from 'config/explorer'; // https://stackoverflow.com/questions/39830580/jest-test-fails-typeerror-window-matchmedia-is-not-a-function Object.defineProperty(window, 'matchMedia', { @@ -51,16 +52,6 @@ dispatchEvent: jest.fn(), }); -// Mock bip39.generateMnemonic() so we can have a consistent test for wallet name -jest.mock('bip39', () => ({ - __esModule: true, - ...jest.requireActual('bip39'), - generateMnemonic: jest.fn( - () => - 'grant grass sock faculty behave guitar pepper tiger sustain task occur soon', - ), -})); - describe('', () => { let user; beforeEach(() => { @@ -107,392 +98,258 @@ jest.clearAllMocks(); await clearLocalForage(localforage); }); - it('We can add a savedWallet as a contact', async () => { - // localforage defaults + it('We do not see the camera auto-open setting in the config screen on a desktop device', async () => { const mockedChronik = await initializeCashtabStateForTests( - walletWithXecAndTokens, + freshWalletWithOneIncomingCashtabMsg, localforage, ); - // Custom contact list - await localforage.setItem('contactList', populatedContactList); - - const savedWallet = validSavedWallets[0]; - - // Add a new saved wallet that can be rendered - const addedSavedWalletContact = { - address: savedWallet.paths.get(1899).address, - name: savedWallet.name, - }; - await localforage.setItem('wallets', [ - walletWithXecAndTokens, - savedWallet, - ]); - render( , ); - // Wait for the app to load - await waitFor(() => - expect(screen.queryByTestId('loading-ctn')).not.toBeInTheDocument(), - ); - - // Configure component is rendered - expect(screen.getByTestId('configure-ctn')).toBeInTheDocument(); - - // We can add a savedWallet as a contact - - // Note the savedWallets collapse loads expanded - - // We see expected saved wallets - expect((await screen.findAllByText('alpha'))[1]).toBeInTheDocument(); - - // Click button to add this saved wallet to contacts - await user.click( - screen.getAllByTestId('add-saved-wallet-to-contact-btn')[0], - ); - - // Raises a confirm modal. Click OK. - await user.click(screen.getByText('OK')); + // We are on the settings screen + await screen.findByTestId('configure-ctn'); - // Confirm new wallet added to contacts - await waitFor(async () => - expect(await localforage.getItem('contactList')).toEqual( - populatedContactList.concat(addedSavedWalletContact), - ), - ); - - // Confirm add contact notification is triggered - await waitFor(() => { - expect( - screen.getByText( - `${addedSavedWalletContact.address} added to Contacts`, - ), - ).toBeInTheDocument(); - }); - }); - it('Confirm mocked bip39.generateMnemonic() returns the expected seed', () => { - expect(bip39.generateMnemonic()).toBe( - 'grant grass sock faculty behave guitar pepper tiger sustain task occur soon', - ); + // We do not see the auto open option + expect( + screen.queryByText('Auto-open camera on send'), + ).not.toBeInTheDocument(); }); - it('We can rename the active wallet or a saved wallet, we can add a wallet, we can import a wallet, we can delete a wallet', async () => { - // localforage defaults + it('We do see the camera auto-open setting in the config screen on a mobile device', async () => { + Object.defineProperty(navigator, 'userAgentData', { + value: { + mobile: true, + }, + writable: true, + }); + + // Get mocked chronik client with expected API results for this wallet const mockedChronik = await initializeCashtabStateForTests( - walletWithXecAndTokens, + freshWalletWithOneIncomingCashtabMsg, localforage, ); - // Add 5 valid saved wallets with no state - await localforage.setItem( - 'wallets', - [walletWithXecAndTokens].concat(validSavedWallets), - ); - - const walletToBeActivatedLaterInTest = validSavedWallets.find( - wallet => wallet.name === 'bravo', - ); - - // Mock utxos for wallet we will activate - prepareMockedChronikCallsForWallet( - mockedChronik, - walletToBeActivatedLaterInTest, - ); - render( , ); - // Wait for the app to load - await waitFor(() => - expect(screen.queryByTestId('loading-ctn')).not.toBeInTheDocument(), - ); - - // Note, the savedWallets collapse loads open by default - - // We see expected saved wallets - // Note, we see these in the wallet header dropdown and in the savedWallets list - expect((await screen.findAllByText('alpha'))[1]).toBeInTheDocument(); - expect((await screen.findAllByText('bravo'))[1]).toBeInTheDocument(); - expect((await screen.findAllByText('charlie'))[1]).toBeInTheDocument(); - expect((await screen.findAllByText('delta'))[1]).toBeInTheDocument(); - expect((await screen.findAllByText('echo'))[1]).toBeInTheDocument(); + // We are on the settings screen + await screen.findByTestId('configure-ctn'); - // Let's rename alpha. Its button will be the first in the list. - await user.click(screen.getAllByTestId('rename-saved-wallet')[0]); - - // We see a modal. + // Now we do see the auto open option expect( - await screen.findByText('Editing name for wallet "alpha"'), + await screen.findByText('Auto-open camera on send'), ).toBeInTheDocument(); - // Try to rename it to an already existing name - await user.type( - await screen.findByPlaceholderText('Enter new wallet name'), - 'bravo', + // Unset mock + Object.defineProperty(navigator, 'userAgentData', { + value: { + mobile: false, + }, + writable: true, + }); + }); + it('Setting "Send Confirmations" settings will show send confirmations', async () => { + const mockedChronik = await initializeCashtabStateForTests( + walletWithXecAndTokens, + localforage, ); - // Click ok - await user.click(screen.getByRole('button', { name: 'OK' })); + const hex = + '0200000001fe667fba52a1aa603a892126e492717eed3dad43bfea7365a7fdd08e051e8a21020000006a47304402206e0875eb1b866bc063217eb55ba88ddb2a5c4f299278e0c7ce4f34194619a6d502201e2c373cfe93ed35c6502e22b748ab07893e22643107b58f018af8ffbd6b654e4121031d4603bdc23aca9432f903e3cf5975a3f655cc3fa5057c61d00dfc1ca5dfd02dffffffff027c150000000000001976a9146ffbe7c7d7bd01295eb1e371de9550339bdcf9fd88accd6c0e00000000001976a9143a5fb236934ec078b4507c303d3afd82067f8fc188ac00000000'; + const txid = + '7eb806a83c48b0ab38b5af10aaa7452d4648f2c0d41975343ada9f4aa8255bd8'; + mockedChronik.setMock('broadcastTx', { + input: hex, + output: { txid }, + }); - // We see an error modal - expect( - await screen.findByText( - 'Rename failed. All wallets must have a unique name.', - ), - ).toBeInTheDocument(); + render(); - // alpha is still named alpha - expect((await screen.findAllByText('alpha'))[1]).toBeInTheDocument(); + // Default route is home + await screen.findByTestId('tx-history-ctn'); - // We give it an available name - await user.click(screen.getAllByTestId('rename-saved-wallet')[0]); + // Click the hamburger menu + await user.click(screen.queryByTestId('hamburger')); - // Text input field is empty - expect( - await screen.findByPlaceholderText('Enter new wallet name'), - ).toHaveValue(''); + // Navigate to Settings screen + await user.click(screen.queryByTestId('nav-btn-configure')); - await user.type( - await screen.findByPlaceholderText('Enter new wallet name'), - 'ALPHA PRIME', - ); + // Now we see the Settings screen + expect(screen.getByTestId('configure-ctn')).toBeInTheDocument(); - // Click ok - await user.click(screen.getByRole('button', { name: 'OK' })); + // Send confirmations are disabled by default - // We get a confirmation modal - expect( - await screen.findByText('Wallet "alpha" renamed to "ALPHA PRIME"'), - ).toBeInTheDocument(); + // Enable send confirmations + await user.click(screen.getByTestId('send-confirmations-switch')); - // The wallet has been renamed - expect( - (await screen.findAllByText('ALPHA PRIME'))[1], - ).toBeInTheDocument(); + // Navigate to the Send screen + await user.click(screen.queryByTestId('nav-btn-send')); - // Now let's rename the active wallet - await user.click(screen.getByTestId('rename-active-wallet')); + // Now we see the Send screen + expect(screen.getByTestId('send-to-many-switch')).toBeInTheDocument(); + // Fill out to and amount await user.type( - await screen.findByPlaceholderText('Enter new wallet name'), - 'ACTIVE WALLET', + screen.getByPlaceholderText('Address'), + 'ecash:qphlhe78677sz227k83hrh542qeehh8el5lcjwk72y', ); - - // Click ok - await user.click(screen.getByRole('button', { name: 'OK' })); - - // We get a confirmation modal + await user.type(screen.getByPlaceholderText('Amount'), '55'); + // click send + await user.click(screen.getByRole('button', { name: /Send/ })); + // we see a modal expect( await screen.findByText( - 'Wallet "Transaction Fixtures" renamed to "ACTIVE WALLET"', + `Send 55 XEC to ecash:qphlhe78677sz227k83hrh542qeehh8el5lcjwk72y`, ), ).toBeInTheDocument(); - // The wallet has been renamed. The new name is updated in all locations. - const activeWalletLabels = await screen.findAllByText('ACTIVE WALLET'); - const EXPECTED_ACTIVE_WALLET_LABELS_IN_DOCUMENT = 2; - expect(activeWalletLabels.length).toBe( - EXPECTED_ACTIVE_WALLET_LABELS_IN_DOCUMENT, - ); - - // We can delete a wallet - // Delete the first wallet in the savedWallets list - await user.click(screen.getAllByTestId('delete-saved-wallet')[0]); + // We can click ok to send the tx + await user.click(screen.getByText('OK')); - // We see a confirmation modal - expect( - await screen.findByText( - `Delete wallet "ALPHA PRIME"?. This cannot be undone. Make sure you have backed up your wallet.`, + // Notification is rendered with expected txid?; + const txSuccessNotification = await screen.findByText('eCash sent'); + await waitFor(() => + expect(txSuccessNotification).toHaveAttribute( + 'href', + `${explorer.blockExplorerUrl}/tx/${txid}`, ), - ).toBeInTheDocument(); + ); + }); + it('Setting "ABSOLUTE MINIMUM fees" settings will reduce fees to absolute min', async () => { + // Modify walletWithXecAndTokens to have the required token for this feature + const walletWithVipToken = { + ...walletWithXecAndTokens, + state: { + ...walletWithXecAndTokens.state, + slpUtxos: [ + ...walletWithXecAndTokens.state.slpUtxos, + requiredUtxoThisToken, + ], + }, + }; - // Type deletion confirmation - await user.type( - screen.getByPlaceholderText(`Type "delete ALPHA PRIME" to confirm`), - `delete ALPHA PRIME`, + const mockedChronik = await initializeCashtabStateForTests( + walletWithVipToken, + localforage, ); - // Click ok to delete the wallet - await user.click(screen.getByRole('button', { name: 'OK' })); + // Make sure the app can get this token's genesis info by calling a mock + mockedChronik.setMock('token', { + input: appConfig.vipSettingsTokenId, + output: vipTokenChronikTokenMocks.token, + }); + mockedChronik.setMock('tx', { + input: appConfig.vipSettingsTokenId, + output: vipTokenChronikTokenMocks.tx, + }); - // We get a modal confirming successful wallet deletion - expect( - await screen.findByText( - 'Wallet "ALPHA PRIME" successfully deleted', - ), - ).toBeInTheDocument(); + // Can verify in Electrum that this tx is sent at 1.0 sat/byte + const hex = + '0200000001fe667fba52a1aa603a892126e492717eed3dad43bfea7365a7fdd08e051e8a21020000006a473044022043679b2fcde0099b0cd29bfbca382e92e3b871c079a0db7d73c39440d067f5bb02202e2ab2d5d83b70911da2758afd9e56eaaaa989050f35e4cc4d28d20afc29778a4121031d4603bdc23aca9432f903e3cf5975a3f655cc3fa5057c61d00dfc1ca5dfd02dffffffff027c150000000000001976a9146ffbe7c7d7bd01295eb1e371de9550339bdcf9fd88acb26d0e00000000001976a9143a5fb236934ec078b4507c303d3afd82067f8fc188ac00000000'; + const txid = + '6d2e157e2e2b1fa47cc63ede548375213942e29c090f5d9cbc2722258f720c08'; + mockedChronik.setMock('broadcastTx', { + input: hex, + output: { txid }, + }); - // wallet ALPHA PRIME is no longer in savedWallets list - await waitFor(() => - expect(screen.queryByText('ALPHA PRIME')).not.toBeInTheDocument(), - ); + // Can verify in Electrum that this tx is sent at 1.0 sat/byte + const tokenSendHex = + '02000000023abaa0b3d97fdc6fb07a535c552fcb379e7bffa6e7e52707b8cf1507bf243e42010000006b483045022100a3ee483d79bbc25ea139dbdac578a533ceb6a8764ba49aa4a46f9cfd73efd86602202fe5a207777e0ef846d19e04b75f9ebb3ff5e0c3b70108526aadb2e9ea27865c4121031d4603bdc23aca9432f903e3cf5975a3f655cc3fa5057c61d00dfc1ca5dfd02dfffffffffe667fba52a1aa603a892126e492717eed3dad43bfea7365a7fdd08e051e8a21020000006b483045022100c45c36d3083c2a7980535b0495a34c976c90bb51de502b9f9f3f840578a46283022034d491a71135e8497bfa79b664e0e7d5458ec3387643dc1636a8d65721c7b2054121031d4603bdc23aca9432f903e3cf5975a3f655cc3fa5057c61d00dfc1ca5dfd02dffffffff040000000000000000406a04534c500001010453454e4420fb4233e8a568993976ed38a81c2671587c5ad09552dedefa78760deed6ff87aa08000000024e160364080000000005f5e09c22020000000000001976a9144e532257c01b310b3b5c1fd947c79a72addf852388ac22020000000000001976a9143a5fb236934ec078b4507c303d3afd82067f8fc188ac0d800e00000000001976a9143a5fb236934ec078b4507c303d3afd82067f8fc188ac00000000'; + const tokenSendTxid = + 'ce727c96439dfe365cb47f780c37ebb2e756051db62375e992419d5db3c81b1e'; - // nor is it in localforage - const walletsNow = cashtabWalletsFromJSON( - await localforage.getItem('wallets'), - ); + mockedChronik.setMock('broadcastTx', { + input: tokenSendHex, + output: { txid: tokenSendTxid }, + }); - const expectedWalletsNow = [ - ...[walletWithXecAndTokens].concat(validSavedWallets), - ]; - // The active wallet has been renamed - expectedWalletsNow[0].name = 'ACTIVE WALLET'; - // We no longer have wallet alpha -- delete it - const alphaIndex = expectedWalletsNow.findIndex( - wallet => wallet.name === 'alpha', - ); - expectedWalletsNow.splice(alphaIndex, 1); - expect(walletsNow).toEqual(expectedWalletsNow); - - // We can add a wallet without specifying any mnemonic - await user.click( - screen.getByRole('button', { - name: /New Wallet/, - }), - ); + render(); - // Wallet added success modal - expect( - await screen.findByText( - `New wallet "qrj4p" added to your saved wallets`, - ), - ).toBeInTheDocument(); + // Default route is home + await screen.findByTestId('tx-history-ctn'); - // We see the new wallet - expect((await screen.findAllByText('qrj4p'))[1]).toBeInTheDocument(); + // Click the hamburger menu + await user.click(screen.queryByTestId('hamburger')); - // It is added to the end of the wallets array - // It will be organized alphabetically when the user refreshes and loadCashtabState runs - // We want it added at the end so it's easy for a user to see what wallet was just added - const walletsAfterAdd = await localforage.getItem('wallets'); - expect(walletsAfterAdd[walletsAfterAdd.length - 1].name).toBe('qrj4p'); + // Navigate to Settings screen + await user.click(screen.queryByTestId('nav-btn-configure')); - // We can import a wallet by specifying a mnemonic - await user.click( - screen.getByRole('button', { - name: /Import Wallet/, // name is "import Import Wallet" as icon is included, more antd weirdness - }), - ); + // Now we see the Settings screen + expect(screen.getByTestId('configure-ctn')).toBeInTheDocument(); - // We see import input field and prompt - expect( - screen.getByText( - 'Copy and paste your mnemonic seed phrase below to import an existing wallet', - ), - ).toBeInTheDocument(); + // Send confirmations are disabled by default - // Import button is disabled - const importBtn = screen.getByRole('button', { - name: 'Import', - }); - expect(importBtn).toHaveAttribute('disabled'); + // Enable min fee sends + await user.click(screen.getByTestId('settings-minFeeSends-switch')); - // Type in most of a mnemonic - await user.type( - screen.getByPlaceholderText('mnemonic (seed phrase)'), - 'pioneer waste next tired armed course expand stairs load brick asthma ', - ); - // The validation msg is in the document - expect( - screen.getByText('Valid mnemonic seed phrase required'), - ).toBeInTheDocument(); + // Navigate to the Send screen + await user.click(screen.queryByTestId('nav-btn-send')); - // Type in the rest + // Now we see the Send screen + expect(screen.getByTestId('send-to-many-switch')).toBeInTheDocument(); + + // Fill out to and amount await user.type( - screen.getByPlaceholderText('mnemonic (seed phrase)'), - 'budget', + screen.getByPlaceholderText('Address'), + 'ecash:qphlhe78677sz227k83hrh542qeehh8el5lcjwk72y', ); + await user.type(screen.getByPlaceholderText('Amount'), '55'); - // The validation msg is not in the document - expect( - screen.queryByText('Valid mnemonic seed phrase required'), - ).not.toBeInTheDocument(); + // click send to broadcast the tx + await user.click(screen.getByRole('button', { name: /Send/ })); - // The button is not disabled - expect(importBtn).not.toHaveAttribute('disabled'); + // Notification is rendered with expected txid + const txSuccessNotification = await screen.findByText('eCash sent'); + await waitFor(() => + expect(txSuccessNotification).toHaveAttribute( + 'href', + `${explorer.blockExplorerUrl}/tx/${txid}`, + ), + ); - // Click import - await user.click(importBtn); + // If the user's balance of this token falls below the required amount, + // the feature will be disabled even though the settings value persists - // Wallet imported success modal - expect( - await screen.findByText( - `New imported wallet "qzxep" added to your saved wallets`, - ), - ).toBeInTheDocument(); + // Send some tokens - // We see the new wallet - expect((await screen.findAllByText('qzxep'))[1]).toBeInTheDocument(); + // Navigate to eTokens screen + await user.click(screen.queryByTestId('nav-btn-etokens')); - // It is added to the end of the wallets array - // It will be organized alphabetically when the user refreshes and loadCashtabState runs - // We want it added at the end so it's easy for a user to see what wallet was just added - const walletsAfterImport = await localforage.getItem('wallets'); - expect(walletsAfterImport[walletsAfterImport.length - 1].name).toBe( - 'qzxep', - ); + // Click on the VIP token + await user.click(screen.getByText('GRP')); - // Wait for mnemonic input to be cleared - await waitFor(() => - expect( - screen.getByPlaceholderText('mnemonic (seed phrase)'), - ).toHaveValue(''), - ); + // Wait for element to get token info and load + expect((await screen.findAllByText(/GRP/))[0]).toBeInTheDocument(); - // If we try to import the same wallet again, we get an error and wallets is unchanged + // We send enough GRP to be under the min await user.type( - screen.getByPlaceholderText('mnemonic (seed phrase)'), - 'pioneer waste next tired armed course expand stairs load brick asthma budget', + screen.getByPlaceholderText('Address'), + 'ecash:qp89xgjhcqdnzzemts0aj378nfe2mhu9yvxj9nhgg6', ); + await user.type(screen.getByPlaceholderText('Amount'), '99000001'); - // The button is not disabled - expect(importBtn).not.toHaveAttribute('disabled'); - - // Click import - await user.click(importBtn); - - // Wallet imported failure modal - expect( - await screen.findByText( - `Cannot import: wallet already exists (name: "qzxep")`, - ), - ).toBeInTheDocument(); - - // We can change the active wallet + // Click the Send token button + await user.click(screen.getByRole('button', { name: /Send/ })); - // Activate the first wallet in the list - // Since ALPHA PRIME has been deleted, "bravo" is the first wallet in the list - await user.click( - screen.getAllByRole('button', { name: 'Activate' })[0], + const sendTokenSuccessNotification = await screen.findByText( + 'eToken sent', ); - - // Now "bravo" is the active wallet - const newActiveWalletLabels = await screen.findAllByText('bravo'); - expect(newActiveWalletLabels.length).toBe( - EXPECTED_ACTIVE_WALLET_LABELS_IN_DOCUMENT, + await waitFor(() => + expect(sendTokenSuccessNotification).toHaveAttribute( + 'href', + `${explorer.blockExplorerUrl}/tx/${tokenSendTxid}`, + ), ); - // If we try to add a wallet that has the same name as an already existing wallet - // We get an error modal - - // We can add a wallet without specifying any mnemonic - // Since we already did this earlier in the test, and we have mocked generateMnemonic() in this test, - // we will get the same wallet that already exists - // Confirm this edge case is not allowed - await user.click( - screen.getByRole('button', { - name: /New Wallet/, - }), - ); + // Actually we can't update the utxo set now, so we add a separate test to confirm + // the feature is disabled even if it was set to true but then token balance decreased + // TODO we can test wallet utxo set updates if we connect some Cashtab integration tests + // to regtest - // We get the once-in-a-blue-moon modal error - expect( - await screen.findByText( - `By a vanishingly small chance, "qrj4p" already existed in saved wallets. Please try again.`, - ), - ).toBeInTheDocument(); + // See SendXec test, "If the user has minFeeSends set to true but no longer has the right token amount, the feature is disabled" }); it('We can choose a new fiat currency', async () => { const mockedChronik = await initializeCashtabStateForTests( diff --git a/cashtab/src/components/Configure/fixtures/mocks.js b/cashtab/src/components/Configure/fixtures/mocks.js deleted file mode 100644 --- a/cashtab/src/components/Configure/fixtures/mocks.js +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright (c) 2024 The Bitcoin developers -// Distributed under the MIT software license, see the accompanying -// file COPYING or http://www.opensource.org/licenses/mit-license.php. - -export const populatedContactList = [ - { - address: 'ecash:qp89xgjhcqdnzzemts0aj378nfe2mhu9yvxj9nhgg6', - name: 'alpha', - }, - { - address: 'ecash:qz2708636snqhsxu8wnlka78h6fdp77ar59jrf5035', - name: 'beta', - }, - { - address: 'ecash:qphlhe78677sz227k83hrh542qeehh8el5lcjwk72y', - name: 'gamma', - }, -]; diff --git a/cashtab/src/components/Contacts/__tests__/index.test.js b/cashtab/src/components/Contacts/__tests__/index.test.js --- a/cashtab/src/components/Contacts/__tests__/index.test.js +++ b/cashtab/src/components/Contacts/__tests__/index.test.js @@ -3,8 +3,10 @@ // file COPYING or http://www.opensource.org/licenses/mit-license.php. import React from 'react'; -import { walletWithXecAndTokens } from 'components/App/fixtures/mocks'; -import { populatedContactList } from 'components/Configure/fixtures/mocks'; +import { + walletWithXecAndTokens, + populatedContactList, +} from 'components/App/fixtures/mocks'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent, { PointerEventsCheckLevel, diff --git a/cashtab/src/components/Configure/__tests__/Configure.test.js b/cashtab/src/components/Wallets/__tests__/index.test.js copy from cashtab/src/components/Configure/__tests__/Configure.test.js copy to cashtab/src/components/Wallets/__tests__/index.test.js --- a/cashtab/src/components/Configure/__tests__/Configure.test.js +++ b/cashtab/src/components/Wallets/__tests__/index.test.js @@ -3,8 +3,10 @@ // file COPYING or http://www.opensource.org/licenses/mit-license.php. import React from 'react'; -import { walletWithXecAndTokens } from 'components/App/fixtures/mocks'; -import { populatedContactList } from 'components/Configure/fixtures/mocks'; +import { + walletWithXecAndTokens, + populatedContactList, +} from 'components/App/fixtures/mocks'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent, { PointerEventsCheckLevel, @@ -61,7 +63,7 @@ ), })); -describe('', () => { +describe('', () => { let user; beforeEach(() => { // Set up userEvent to skip pointerEvents check, which returns false positives with antd @@ -87,21 +89,6 @@ .mockResolvedValue({ json: () => Promise.resolve(priceResponse), }); - // Mock another price URL for a user that changes fiat currency - const altFiat = 'gbp'; - const altFiatPriceApiUrl = `https://api.coingecko.com/api/v3/simple/price?ids=${cryptoId}&vs_currencies=${altFiat}&include_last_updated_at=true`; - const xecPriceAltFiat = 0.00002; - const altFiatPriceResponse = { - ecash: { - [altFiat]: xecPriceAltFiat, - last_updated_at: 1706644626, - }, - }; - when(fetch) - .calledWith(altFiatPriceApiUrl) - .mockResolvedValue({ - json: () => Promise.resolve(altFiatPriceResponse), - }); }); afterEach(async () => { jest.clearAllMocks(); @@ -129,22 +116,15 @@ savedWallet, ]); - render( - , - ); + render(); // Wait for the app to load await waitFor(() => expect(screen.queryByTestId('loading-ctn')).not.toBeInTheDocument(), ); - // Configure component is rendered - expect(screen.getByTestId('configure-ctn')).toBeInTheDocument(); - // We can add a savedWallet as a contact - // Note the savedWallets collapse loads expanded - // We see expected saved wallets expect((await screen.findAllByText('alpha'))[1]).toBeInTheDocument(); @@ -153,9 +133,6 @@ screen.getAllByTestId('add-saved-wallet-to-contact-btn')[0], ); - // Raises a confirm modal. Click OK. - await user.click(screen.getByText('OK')); - // Confirm new wallet added to contacts await waitFor(async () => expect(await localforage.getItem('contactList')).toEqual( @@ -163,14 +140,11 @@ ), ); - // Confirm add contact notification is triggered - await waitFor(() => { - expect( - screen.getByText( - `${addedSavedWalletContact.address} added to Contacts`, - ), - ).toBeInTheDocument(); - }); + expect( + await screen.findByText( + `${addedSavedWalletContact.name} (${addedSavedWalletContact.address}) added to Contact List`, + ), + ).toBeInTheDocument(); }); it('Confirm mocked bip39.generateMnemonic() returns the expected seed', () => { expect(bip39.generateMnemonic()).toBe( @@ -200,9 +174,7 @@ walletToBeActivatedLaterInTest, ); - render( - , - ); + render(); // Wait for the app to load await waitFor(() => @@ -223,36 +195,24 @@ await user.click(screen.getAllByTestId('rename-saved-wallet')[0]); // We see a modal. - expect( - await screen.findByText('Editing name for wallet "alpha"'), - ).toBeInTheDocument(); + expect(await screen.findByText(`Rename "alpha"?`)).toBeInTheDocument(); // Try to rename it to an already existing name await user.type( - await screen.findByPlaceholderText('Enter new wallet name'), + screen.getByPlaceholderText('Enter new wallet name'), 'bravo', ); - // Click ok - await user.click(screen.getByRole('button', { name: 'OK' })); - - // We see an error modal + // We see expected validation error expect( - await screen.findByText( - 'Rename failed. All wallets must have a unique name.', - ), + screen.getByText(`Wallet name "bravo" already exists`), ).toBeInTheDocument(); - // alpha is still named alpha - expect((await screen.findAllByText('alpha'))[1]).toBeInTheDocument(); - - // We give it an available name - await user.click(screen.getAllByTestId('rename-saved-wallet')[0]); + // Rename OK button is disabled + expect(screen.getByText('OK')).toHaveProperty('disabled', true); - // Text input field is empty - expect( - await screen.findByPlaceholderText('Enter new wallet name'), - ).toHaveValue(''); + // Clear the input + await user.clear(screen.getByPlaceholderText('Enter new wallet name')); await user.type( await screen.findByPlaceholderText('Enter new wallet name'), @@ -264,7 +224,7 @@ // We get a confirmation modal expect( - await screen.findByText('Wallet "alpha" renamed to "ALPHA PRIME"'), + await screen.findByText('"alpha" renamed to "ALPHA PRIME"'), ).toBeInTheDocument(); // The wallet has been renamed @@ -286,7 +246,7 @@ // We get a confirmation modal expect( await screen.findByText( - 'Wallet "Transaction Fixtures" renamed to "ACTIVE WALLET"', + '"Transaction Fixtures" renamed to "ACTIVE WALLET"', ), ).toBeInTheDocument(); @@ -303,9 +263,7 @@ // We see a confirmation modal expect( - await screen.findByText( - `Delete wallet "ALPHA PRIME"?. This cannot be undone. Make sure you have backed up your wallet.`, - ), + await screen.findByText(`Delete "ALPHA PRIME"?`), ).toBeInTheDocument(); // Type deletion confirmation @@ -319,9 +277,7 @@ // We get a modal confirming successful wallet deletion expect( - await screen.findByText( - 'Wallet "ALPHA PRIME" successfully deleted', - ), + await screen.findByText('"ALPHA PRIME" deleted'), ).toBeInTheDocument(); // wallet ALPHA PRIME is no longer in savedWallets list @@ -353,11 +309,9 @@ }), ); - // Wallet added success modal + // Wallet added success notification expect( - await screen.findByText( - `New wallet "qrj4p" added to your saved wallets`, - ), + await screen.findByText(`New wallet "qrj4p" added to wallets`), ).toBeInTheDocument(); // We see the new wallet @@ -376,18 +330,13 @@ }), ); - // We see import input field and prompt - expect( - screen.getByText( - 'Copy and paste your mnemonic seed phrase below to import an existing wallet', - ), - ).toBeInTheDocument(); + // We see import modal // Import button is disabled const importBtn = screen.getByRole('button', { - name: 'Import', + name: 'OK', }); - expect(importBtn).toHaveAttribute('disabled'); + expect(importBtn).toHaveProperty('disabled', true); // Type in most of a mnemonic await user.type( @@ -395,9 +344,7 @@ 'pioneer waste next tired armed course expand stairs load brick asthma ', ); // The validation msg is in the document - expect( - screen.getByText('Valid mnemonic seed phrase required'), - ).toBeInTheDocument(); + expect(screen.getByText('Invalid mnemonic')).toBeInTheDocument(); // Type in the rest await user.type( @@ -406,12 +353,10 @@ ); // The validation msg is not in the document - expect( - screen.queryByText('Valid mnemonic seed phrase required'), - ).not.toBeInTheDocument(); + expect(screen.queryByText('Invalid mnemonic')).not.toBeInTheDocument(); // The button is not disabled - expect(importBtn).not.toHaveAttribute('disabled'); + expect(importBtn).toHaveProperty('disabled', false); // Click import await user.click(importBtn); @@ -441,6 +386,13 @@ ).toHaveValue(''), ); + // Bring up the import modal again + await user.click( + screen.getByRole('button', { + name: /Import Wallet/, // name is "import Import Wallet" as icon is included, more antd weirdness + }), + ); + // If we try to import the same wallet again, we get an error and wallets is unchanged await user.type( screen.getByPlaceholderText('mnemonic (seed phrase)'), @@ -448,7 +400,7 @@ ); // The button is not disabled - expect(importBtn).not.toHaveAttribute('disabled'); + expect(importBtn).toHaveProperty('disabled', false); // Click import await user.click(importBtn); @@ -494,34 +446,4 @@ ), ).toBeInTheDocument(); }); - it('We can choose a new fiat currency', async () => { - const mockedChronik = await initializeCashtabStateForTests( - walletWithXecAndTokens, - localforage, - ); - render( - , - ); - - // Wait for wallet to load - await waitFor(() => - expect(screen.queryByTestId('loading-ctn')).not.toBeInTheDocument(), - ); - - // Choose GBP - await user.selectOptions( - screen.getByTestId('configure-fiat-select'), - screen.getByTestId('gbp'), - ); - - // We expect balance header to be updated - expect(screen.getByTestId('ecash-price')).toHaveTextContent( - `1 XEC = 0.00002000 GBP`, - ); - - // We expect localforage to be updated - expect((await localforage.getItem('settings')).fiatCurrency).toEqual( - 'gbp', - ); - }); }); diff --git a/cashtab/src/components/Wallets/index.js b/cashtab/src/components/Wallets/index.js new file mode 100644 --- /dev/null +++ b/cashtab/src/components/Wallets/index.js @@ -0,0 +1,455 @@ +// Copyright (c) 2024 The Bitcoin developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +import React, { useState } from 'react'; +import { WalletContext } from 'wallet/context'; +import CopyToClipboard from 'components/Common/CopyToClipboard'; +import { + ThemedCopySolid, + ThemedTrashcanOutlined, + ThemedEditOutlined, + ThemedContactsOutlined, +} from 'components/Common/CustomIcons'; +import Modal from 'components/Common/Modal'; +import { ModalInput } from 'components/Common/Inputs'; +import { toast } from 'react-toastify'; +import PrimaryButton, { + SecondaryButton, +} from 'components/Common/PrimaryButton'; +import { + WalletsList, + WalletsPanel, + Row, + ActiveWalletName, + WalletName, + ButtonPanel, + WalletBalance, + ActivateButton, +} from 'components/Wallets/styles'; +import { getWalletNameError, validateMnemonic } from 'validation'; +import { + createCashtabWallet, + generateMnemonic, + getWalletsForNewActiveWallet, +} from 'wallet'; +import { getUserLocale } from 'helpers'; +import { Event } from 'components/Common/GoogleAnalytics'; +import { formatXecBalance } from 'utils/formatting'; + +const Wallets = () => { + const ContextValue = React.useContext(WalletContext); + const { cashtabState, updateCashtabState } = ContextValue; + const { wallets, contactList } = cashtabState; + + const userLocale = getUserLocale(navigator); + + const emptyFormData = { + renamedWalletName: '', + walletToBeDeletedName: '', + newWalletName: '', + mnemonic: '', + }; + const emptyFormDataErrors = { + renamedWalletName: false, + walletToBeDeletedName: false, + newWalletName: false, + mnemonic: false, + }; + + // State variables + const [formData, setFormData] = useState(emptyFormData); + const [formDataErrors, setFormDataErrors] = useState(emptyFormDataErrors); + const [walletToBeRenamed, setWalletToBeRenamed] = useState(null); + const [walletToBeDeleted, setWalletToBeDeleted] = useState(null); + const [showImportWalletModal, setShowImportWalletModal] = useState(false); + + /** + * Update formData with user input + * @param {Event} e js input event + * e.target.value will be input value + * e.target.name will be name of originating input field + */ + const handleInput = e => { + const { name, value } = e.target; + + if (name === 'renamedWalletName') { + setFormDataErrors(previous => ({ + ...previous, + [name]: getWalletNameError(value, wallets), + })); + } + if (name === 'walletToBeDeletedName') { + const walletToBeDeletedNameError = + value === 'delete ' + walletToBeDeleted.name + ? false + : `Input must exactly match "delete ${walletToBeDeleted.name}"`; + setFormDataErrors(previous => ({ + ...previous, + [name]: walletToBeDeletedNameError, + })); + } + if (name === 'mnemonic') { + setFormDataErrors(previous => ({ + ...previous, + [name]: + validateMnemonic(value) === true + ? false + : 'Invalid mnemonic', + })); + } + setFormData(previous => ({ + ...previous, + [name]: value, + })); + }; + + const renameWallet = async () => { + // Find the wallet you want to rename + let walletToUpdate = wallets.find( + wallet => wallet.mnemonic === walletToBeRenamed.mnemonic, + ); + const oldName = walletToUpdate.name; + + // if a match was found + if (typeof walletToUpdate !== 'undefined') { + // update the walllet name + walletToUpdate.name = formData.renamedWalletName; + + // Update localforage and state + await updateCashtabState('wallets', wallets); + toast.success( + `"${oldName}" renamed to "${formData.renamedWalletName}"`, + ); + } else { + toast.error(`Unable to find wallet ${walletToBeRenamed.name}`); + } + // Clear walletToBeRenamed field to hide the modal + setWalletToBeRenamed(null); + + // Clear wallet rename input + setFormData(previous => ({ + ...previous, + renamedWalletName: '', + })); + }; + + const deleteWallet = async () => { + // filter wallet from wallets + const updatedWallets = wallets.filter( + wallet => wallet.mnemonic !== walletToBeDeleted.mnemonic, + ); + + // Update localforage and state + await updateCashtabState('wallets', updatedWallets); + toast.success(`"${walletToBeDeleted.name}" deleted`); + + // Reset walletToBeDeleted to hide the modal + setWalletToBeDeleted(null); + + // Clear wallet to delete input + setFormData(previous => ({ + ...previous, + walletToBeDeletedName: '', + })); + }; + + const addNewWallet = async () => { + // Generate a new wallet with a new mnemonic + const mnemonic = generateMnemonic(); + const newAddedWallet = await createCashtabWallet(mnemonic); + + // Note: technically possible though highly unlikley that a wallet already exists with this name + // Also technically possible though ... er, almost impossibly improbable for wallet with same mnemonic to exist + // In both cases, the odds are tremendously low. + // Let's cover the edge case anyway though. It's easy enough for the user to just create + // a wallet again if we some crazy how get here + const walletAlreadyInWalletsSomehow = wallets.find( + wallet => + wallet.name === newAddedWallet.name || + wallet.mnemonic === newAddedWallet.mnemonic, + ); + if (typeof walletAlreadyInWalletsSomehow !== 'undefined') { + toast.error( + `By a vanishingly small chance, "${newAddedWallet.name}" already existed in saved wallets. Please try again.`, + ); + // Do not add this wallet + return; + } + + // Event("Category", "Action", "Label") + // Track number of times a different wallet is activated + Event('Configure.js', 'Create Wallet', 'New'); + // Add it to the end of the wallets object + updateCashtabState('wallets', [...wallets, newAddedWallet]); + + toast.success(`New wallet "${newAddedWallet.name}" added to wallets`); + }; + + /** + * Add a new imported wallet to cashtabState wallets object + * @param {mnemonic} string + */ + async function importNewWallet() { + // Make sure no existing wallets have this mnemonic + const walletInWallets = wallets.find( + wallet => wallet.mnemonic === formData.mnemonic, + ); + + if (typeof walletInWallets !== 'undefined') { + // Import error modal + console.error( + `Cannot import: wallet already exists (name: "${walletInWallets.name}")`, + ); + toast.error( + `Cannot import: wallet already exists (name: "${walletInWallets.name}")`, + ); + // Do not clear form data in this case + return; + } + + // Create a new wallet from mnemonic + const newImportedWallet = await createCashtabWallet(formData.mnemonic); + + // Handle edge case of another wallet having the same name + const existingWalletHasSameName = wallets.find( + wallet => wallet.name === newImportedWallet, + ); + if (typeof existingWalletHasSameName !== 'undefined') { + // Import error modal for wallet existing with the same name + console.error( + `Cannot import: wallet with same name already exists (name: "${existingWalletHasSameName.name}")`, + ); + toast.error( + `Cannot import: wallet with same name already exists (name: "${existingWalletHasSameName.name}")`, + ); + // Do not clear form data in this case + return; + } + + // Event("Category", "Action", "Label") + // Track number of times a different wallet is activated + Event('Configure.js', 'Create Wallet', 'Imported'); + + // Add it to the end of the wallets object + updateCashtabState('wallets', [...wallets, newImportedWallet]); + + // Import success modal + toast.success( + `New imported wallet "${newImportedWallet.name}" added to your saved wallets`, + ); + + // Clear formdata + setFormData({ ...formData, mnemonic: '' }); + } + + /** + * Add a wallet to contacts + * @param {{name: string; paths: new Map([[1899, string;]])}} wallet + */ + const addWalletToContacts = async wallet => { + const addressToAdd = wallet.paths.get(1899).address; + + // Check to see if the contact exists + const contactExists = contactList.find( + contact => contact.address === addressToAdd, + ); + + if (typeof contactExists !== 'undefined') { + // Contact exists + // Not expected to ever happen from Tx.js as user should not see option to + // add an existing contact + toast.error(`${addressToAdd} already exists in Contacts`); + } else { + contactList.push({ + name: wallet.name, + address: addressToAdd, + }); + // update localforage and state + await updateCashtabState('contactList', contactList); + toast.success( + `${wallet.name} (${addressToAdd}) added to Contact List`, + ); + } + }; + + const activateWallet = (walletToActivate, wallets) => { + // Get desired wallets array after activating walletToActivate + const walletsAfterActivation = getWalletsForNewActiveWallet( + walletToActivate, + wallets, + ); + + // Event("Category", "Action", "Label") + // Track number of times a different wallet is activated + Event('Configure.js', 'Activate', ''); + + // Update wallets to activate this wallet + updateCashtabState('wallets', walletsAfterActivation); + }; + + return ( + <> + {walletToBeRenamed !== null && ( + setWalletToBeRenamed(null)} + showCancelButton + disabled={ + formDataErrors.renamedWalletName !== false || + formData.renamedWalletName === '' + } + > + + + )} + {walletToBeDeleted !== null && ( + setWalletToBeDeleted(null)} + showCancelButton + disabled={ + formDataErrors.walletToBeDeletedName !== false || + formData.walletToBeDeletedName === '' + } + > + + + )} + {showImportWalletModal && ( + setShowImportWalletModal(false)} + showCancelButton + disabled={ + formDataErrors.mnemonic !== false || + formData.mnemonic === '' + } + > + + + )} + + + {wallets.map((wallet, index) => + index === 0 ? ( + + + {wallet.name} + +

(active)

+ + + + + + setWalletToBeRenamed(wallet) + } + /> + + addWalletToContacts(wallet) + } + /> + +
+ ) : ( + + +

+ {wallet.name} +

+
+ + {wallet?.state?.balanceSats !== 0 + ? formatXecBalance( + wallet.state.balanceSats, + userLocale, + ) + : '-'} + + + + + + + setWalletToBeRenamed(wallet) + } + /> + + addWalletToContacts(wallet) + } + /> + + setWalletToBeDeleted(wallet) + } + /> + + activateWallet(wallet, wallets) + } + > + Activate + + +
+ ), + )} +
+ + addNewWallet()}> + New Wallet + + + + setShowImportWalletModal(true)} + > + Import Wallet + + +
+ + ); +}; + +export default Wallets; diff --git a/cashtab/src/components/Wallets/styles.js b/cashtab/src/components/Wallets/styles.js new file mode 100644 --- /dev/null +++ b/cashtab/src/components/Wallets/styles.js @@ -0,0 +1,82 @@ +// Copyright (c) 2024 The Bitcoin developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +import styled from 'styled-components'; + +export const WalletsList = styled.div` + margin-top: 24px; + padding: 12px; + display: flex; + flex-direction: column; + width: 100%; + align-items: center; + gap: 12px; + color: ${props => props.theme.contrast}; + svg { + fill: ${props => props.theme.eCashBlue}; + } + box-sizing: border-box; + *, + *:before, + *:after { + box-sizing: inherit; + } +`; + +export const WalletsPanel = styled.div` + display: flex; + flex-direction: column; + padding: 12px; + width: 100%; + background-color: ${props => props.theme.panel}; + border-radius: 9px; + margin-bottom: 12px; +`; + +export const Row = styled.div` + display: flex; + width: 100%; + align-items: center; + justify-content: center; + gap: 12px; +`; + +export const WalletName = styled.div` + display: flex; + text-align: left; + word-break: break-word; + width: 30%; +`; + +export const ActiveWalletName = styled(WalletName)` + font-weight: bold; + color: ${props => props.theme.eCashBlue}; +`; + +export const ButtonPanel = styled.div` + display: flex; + gap: 9px; + align-items: center; +`; + +export const WalletBalance = styled.div` + width: 30%; + display: flex; + align-items: center; + justify-content: center; + word-wrap: break-word; + hyphens: auto; +`; + +export const ActivateButton = styled.button` + cursor: pointer; + color: ${props => props.theme.eCashBlue}; + border-radius: 9px; + border: 2px solid ${props => props.theme.eCashBlue}; + background: transparent; + :hover { + background-color: ${props => props.theme.eCashBlue}; + color: ${props => props.theme.contrast}; + } +`; diff --git a/cashtab/src/utils/__tests__/formatting.test.js b/cashtab/src/utils/__tests__/formatting.test.js --- a/cashtab/src/utils/__tests__/formatting.test.js +++ b/cashtab/src/utils/__tests__/formatting.test.js @@ -7,6 +7,7 @@ formatFiatBalance, formatBalance, decimalizedTokenQtyToLocaleFormat, + formatXecBalance, } from 'utils/formatting'; import vectors from 'utils/fixtures/vectors'; @@ -122,4 +123,15 @@ }); }); }); + describe('We can format an XEC balance', () => { + const { expectedReturns } = vectors.formatXecBalance; + expectedReturns.forEach(vector => { + const { description, balanceSats, userLocale, returned } = vector; + it(`formatXecBalance: ${description}`, () => { + expect(formatXecBalance(balanceSats, userLocale)).toBe( + returned, + ); + }); + }); + }); }); diff --git a/cashtab/src/utils/fixtures/vectors.js b/cashtab/src/utils/fixtures/vectors.js --- a/cashtab/src/utils/fixtures/vectors.js +++ b/cashtab/src/utils/fixtures/vectors.js @@ -78,4 +78,74 @@ }, ], }, + formatXecBalance: { + expectedReturns: [ + { + description: 'Balance over 1 trillion XEC (10 trillion)', + balanceSats: 1000000000000000, + userLocale: 'en-US', + returned: '10T', + }, + { + description: 'Balance of exactly 1 trillion XEC', + balanceSats: 100000000000000, + userLocale: 'en-US', + returned: '1T', + }, + { + description: 'Balance exceeding 1 billion XEC (10 billion)', + balanceSats: 1000000000000, + userLocale: 'en-US', + returned: '10B', + }, + { + description: 'Balance exactly 1 billion XEC', + balanceSats: 100000000000, + userLocale: 'en-US', + returned: '1B', + }, + { + description: 'Balance exceeding 1 million XEC (10 million)', + balanceSats: 1000000000, + userLocale: 'en-US', + returned: '10M', + }, + { + description: 'Balance of exactly 1 million XEC', + balanceSats: 100000000, + userLocale: 'en-US', + returned: '1M', + }, + { + description: 'Balance exceeding 1 thousand XEC (10 thousand)', + balanceSats: 1000000, + userLocale: 'en-US', + returned: '10k', + }, + { + description: 'Balance of exactly 1 thousand XEC', + balanceSats: 100000, + userLocale: 'en-US', + returned: '1k', + }, + { + description: 'Balance less than 1 thousand XEC', + balanceSats: 99999, + userLocale: 'en-US', + returned: '999.99', + }, + { + description: 'Balance less than 1 thousand XEC, but french', + balanceSats: 99999, + userLocale: 'fr-FR', + returned: '999,99', + }, + { + description: 'Balance less than 1 thousand XEC, but arabic', + balanceSats: 99999, + userLocale: 'ar', + returned: '٩٩٩٫٩٩', + }, + ], + }, }; diff --git a/cashtab/src/utils/formatting.js b/cashtab/src/utils/formatting.js --- a/cashtab/src/utils/formatting.js +++ b/cashtab/src/utils/formatting.js @@ -3,6 +3,8 @@ // file COPYING or http://www.opensource.org/licenses/mit-license.php. import appConfig from 'config/app'; +import { toXec } from 'wallet'; + export const formatDate = (dateString, userLocale = 'en') => { const options = { month: 'short', day: 'numeric', year: 'numeric' }; const dateFormattingError = 'Unable to format date.'; @@ -101,3 +103,31 @@ return localeTokenString; }; + +export const formatXecBalance = (balanceSats, userLocale) => { + // Get XEC balance + let balanceXec = toXec(balanceSats); + // Format up to max supply + const trillion = 1e12; + const billion = 1e9; + const million = 1e6; + const thousand = 1e3; + let units = 'T'; + if (balanceXec >= trillion) { + balanceXec = balanceXec / trillion; + } else if (balanceXec >= billion) { + balanceXec = balanceXec / billion; + units = 'B'; + } else if (balanceXec >= million) { + balanceXec = balanceXec / million; + units = 'M'; + } else if (balanceXec >= thousand) { + balanceXec = balanceXec / thousand; + units = 'k'; + } else { + units = ''; + } + return `${balanceXec.toLocaleString(userLocale, { + maximumFractionDigits: 2, + })}${units}`; +}; diff --git a/cashtab/src/validation/__tests__/index.test.js b/cashtab/src/validation/__tests__/index.test.js --- a/cashtab/src/validation/__tests__/index.test.js +++ b/cashtab/src/validation/__tests__/index.test.js @@ -8,7 +8,6 @@ isValidTokenDecimals, isValidTokenDocumentUrl, isValidCashtabSettings, - isValidNewWalletNameLength, isValidXecSendAmount, isValidTokenId, isValidXecAirdrop, @@ -31,6 +30,7 @@ nodeWillAcceptOpReturnRaw, getContactNameError, getContactAddressError, + getWalletNameError, } from 'validation'; import { validXecAirdropExclusionList, @@ -244,22 +244,6 @@ it(`isValidAirdropExclusionArray rejects a null airdrop exclusion list`, () => { expect(isValidAirdropExclusionArray(null)).toBe(false); }); - it(`accepts a valid wallet name`, () => { - expect(isValidNewWalletNameLength('Apollo')).toBe(true); - }); - it(`rejects wallet name that is too long`, () => { - expect( - isValidNewWalletNameLength( - 'this string is far too long to be used as a wallet name...', - ), - ).toBe(false); - }); - it(`rejects blank string as new wallet name`, () => { - expect(isValidNewWalletNameLength('')).toBe(false); - }); - it(`rejects wallet name of the wrong type`, () => { - expect(isValidNewWalletNameLength(['newWalletName'])).toBe(false); - }); it(`isProbablyNotAScam recognizes "bitcoin" is probably a scam token name`, () => { expect(isProbablyNotAScam('bitcoin')).toBe(false); }); @@ -536,4 +520,13 @@ }); }); }); + describe('Gets error or false for wallet name input', () => { + const { expectedReturns } = vectors.getWalletNameError; + expectedReturns.forEach(expectedReturn => { + const { description, name, wallets, returned } = expectedReturn; + it(`getWalletNameError: ${description}`, () => { + expect(getWalletNameError(name, wallets)).toBe(returned); + }); + }); + }); }); diff --git a/cashtab/src/validation/fixtures/vectors.js b/cashtab/src/validation/fixtures/vectors.js --- a/cashtab/src/validation/fixtures/vectors.js +++ b/cashtab/src/validation/fixtures/vectors.js @@ -2080,4 +2080,42 @@ }, ], }, + getWalletNameError: { + expectedReturns: [ + { + description: + 'Accepts a string of max length if no wallets exist with the same name', + name: 'thisnameistwentyfourchar', + wallets: [], + returned: false, + }, + { + description: + 'Returns expected error for a string of max length if wallet exists with the same name', + name: 'thisnameistwentyfourchar', + wallets: [{ name: 'thisnameistwentyfourchar' }], + returned: + 'Wallet name "thisnameistwentyfourchar" already exists', + }, + { + description: 'Returns expected error for an empty string', + name: '', + wallets: [], + returned: 'Wallet name cannot be a blank string', + }, + { + description: 'Returns expected error for blank spaces', + name: ' ', + wallets: [], + returned: 'Wallet name cannot be only blank spaces', + }, + { + description: + 'Returns expected error for wallet 1 char over the max name length', + name: 'thisnameistwentyfivechars', + wallets: [], + returned: `Wallet name cannot exceed ${appConfig.localStorageMaxCharacters} characters`, + }, + ], + }, }; diff --git a/cashtab/src/validation/index.js b/cashtab/src/validation/index.js --- a/cashtab/src/validation/index.js +++ b/cashtab/src/validation/index.js @@ -379,13 +379,28 @@ ); }; -export const isValidNewWalletNameLength = newWalletName => { - return ( - typeof newWalletName === 'string' && - newWalletName.length > 0 && - newWalletName.length <= appConfig.localStorageMaxCharacters && - newWalletName.length !== '' - ); +/** + * Get false if no error, or a string error for why a wallet name is invalid + * @param {string} name + * @param {{name: string;}[]} wallets + * @returns {false | string} + */ +export const getWalletNameError = (name, wallets) => { + if (name === '') { + return 'Wallet name cannot be a blank string'; + } + if (name.trim() === '') { + return 'Wallet name cannot be only blank spaces'; + } + if (name.length > appConfig.localStorageMaxCharacters) { + return `Wallet name cannot exceed ${appConfig.localStorageMaxCharacters} characters`; + } + for (const wallet of wallets) { + if (wallet.name === name) { + return `Wallet name "${name}" already exists`; + } + } + return false; }; export const isValidXecAirdrop = xecAirdrop => {