Page MenuHomePhabricator

D15917.id46917.diff
No OneTemporary

D15917.id46917.diff

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
@@ -28,6 +28,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';
@@ -675,6 +676,10 @@
<BackupWallet />
}
/>
+ <Route
+ path="/wallets"
+ element={<Wallets />}
+ />
<Route
path="/contacts"
element={<Contacts />}
@@ -790,6 +795,15 @@
<p>Wallet Backup</p>
<WalletIcon />
</NavItem>
+ <NavItem
+ data-testid="nav-btn-wallets"
+ active={selectedKey === 'wallets'}
+ onClick={() => navigate('/wallets')}
+ >
+ {' '}
+ <p>Wallets</p>
+ <WalletIcon />
+ </NavItem>
<NavItem
data-testid="nav-btn-contacts"
active={selectedKey === 'contacts'}
diff --git a/cashtab/src/components/App/__tests__/App.test.js b/cashtab/src/components/App/__tests__/App.test.js
--- a/cashtab/src/components/App/__tests__/App.test.js
+++ b/cashtab/src/components/App/__tests__/App.test.js
@@ -15,7 +15,6 @@
freshWalletWithOneIncomingCashtabMsg,
requiredUtxoThisToken,
easterEggTokenChronikTokenDetails,
- vipTokenChronikTokenMocks,
validSavedWallets_pre_2_1_0,
validSavedWallets_pre_2_9_0,
validSavedWallets,
@@ -32,7 +31,6 @@
prepareMockedChronikCallsForWallet,
} from 'components/App/fixtures/helpers';
import CashtabTestWrapper from 'components/App/fixtures/CashtabTestWrapper';
-import { explorer } from 'config/explorer';
import { legacyMockTokenInfoById } from 'chronik/fixtures/chronikUtxos';
import {
cashtabCacheToJSON,
@@ -275,6 +273,12 @@
// Now we see the Contacts screen
expect(screen.getByTestId('contacts')).toBeInTheDocument();
+
+ // Navigate to Wallets screen
+ await user.click(screen.queryByTestId('nav-btn-wallets'));
+
+ // Now we see the Wallets screen
+ expect(screen.getByTestId('wallets')).toBeInTheDocument();
});
it('Adding a contact to to a new contactList by clicking on tx history adds it to localforage and wallet context', async () => {
const mockedChronik = await initializeCashtabStateForTests(
@@ -474,125 +478,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(
- <CashtabTestWrapper chronik={mockedChronik} route="/configure" />,
- );
-
- // 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(
- <CashtabTestWrapper chronik={mockedChronik} route="/configure" />,
- );
-
- // 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(<CashtabTestWrapper chronik={mockedChronik} />);
-
- // 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(
@@ -625,140 +510,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(<CashtabTestWrapper chronik={mockedChronik} />);
-
- // 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/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,27 +2,18 @@
// 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,
CurrencySelectDropdown,
} from 'components/Common/EnhancedInputs';
-import PrimaryButton, {
- SecondaryButton,
-} from 'components/Common/PrimaryButton';
import {
- ThemedWalletOutlined,
ThemedDollarOutlined,
ThemedSettingOutlined,
- ThemedContactsOutlined,
- ThemedTrashcanOutlined,
- ThemedEditOutlined,
ThemedXIcon,
ThemedFacebookIcon,
ThemedGithubIcon,
@@ -30,182 +21,17 @@
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 } from 'components/Common/Inputs';
+import { isMobile } from 'helpers';
+import { hasEnoughToken } from 'wallet';
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 {
@@ -268,8 +94,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;
@@ -278,307 +104,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,
@@ -606,101 +131,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 (
<StyledConfigure data-testid="configure-ctn">
- {savedWalletContactModal && (
- <CustomModal
- title={`Add ${manualContactName} to contacts?`}
- description={manualContactAddress}
- handleOk={() => handleAddSavedWalletAsContactOk()}
- handleCancel={() => handleAddSavedWalletAsContactCancel()}
- showCancelButton
- />
- )}
- {walletToBeRenamed !== null && showRenameWalletModal && (
- <CustomModal
- height={290}
- title={`Rename Wallet?`}
- description={`Editing name for wallet "${walletToBeRenamed.name}"`}
- handleOk={() =>
- renameWallet(walletToBeRenamed.name, newWalletName)
- }
- handleCancel={() => cancelRenameWallet()}
- showCancelButton
- >
- <ModalInput
- placeholder="Enter new wallet name"
- name="newName"
- value={newWalletName}
- error={
- newWalletNameIsValid
- ? false
- : 'Wallet name must be a string between 1 and 24 characters long'
- }
- handleInput={handleWalletNameInput}
- />
- </CustomModal>
- )}
- {walletToBeDeleted !== null && showDeleteWalletModal && (
- <CustomModal
- height={340}
- title={`Delete Wallet?`}
- description={`Delete wallet "${walletToBeDeleted.name}"?. This cannot be undone. Make sure you have backed up your wallet.`}
- handleOk={() => deleteWallet(walletToBeDeleted)}
- handleCancel={() => cancelDeleteWallet()}
- showCancelButton
- >
- <ModalInput
- placeholder={`Type "delete ${walletToBeDeleted.name}" to confirm`}
- name="walletToBeDeletedInput"
- value={confirmationOfWalletToBeDeleted}
- handleInput={handleWalletToDeleteInput}
- error={walletDeleteConfirmationError}
- />
- </CustomModal>
- )}
<NoticeHolder>
<Info>
ℹ️ Backup wallet has moved
@@ -715,158 +147,6 @@
<Link to="/contacts">Contacts</Link> screen
</Info>
</NoticeHolder>
- <h2>
- <ThemedWalletOutlined /> Manage Wallets
- </h2>
- {apiError ? (
- <ApiError />
- ) : (
- <>
- <PrimaryButton onClick={() => addNewWallet()}>
- New Wallet
- </PrimaryButton>
- <SecondaryButton onClick={() => openSeedInput(!seedInput)}>
- Import Wallet
- </SecondaryButton>
- {seedInput && (
- <InputFlex>
- <p style={{ color: '#fff' }}>
- Copy and paste your mnemonic seed phrase below
- to import an existing wallet
- </p>
-
- <Input
- type="email"
- placeholder="mnemonic (seed phrase)"
- name="mnemonic"
- error={
- isValidMnemonic
- ? false
- : 'Valid mnemonic seed phrase required'
- }
- value={formData.mnemonic}
- autoComplete="off"
- handleInput={handleImportMnemonicInput}
- />
- <SecondaryButton
- disabled={isValidMnemonic !== true}
- onClick={() =>
- importNewWallet(formData.mnemonic)
- }
- >
- Import
- </SecondaryButton>
- </InputFlex>
- )}
- </>
- )}
- {wallet !== false && wallets.length > 0 && (
- <>
- <StyledCollapse defaultActiveKey={['1']}>
- <Panel header="Saved wallets" key="1">
- <div>
- {wallets.map((wallet, index) =>
- index === 0 ? (
- <AWRow key={`${wallet.name}_${index}`}>
- <Tooltip title={wallet.name}>
- <h3 className="notranslate">
- {wallet.name}
- </h3>
- </Tooltip>
- <h4>Currently active</h4>
- <SWButtonCtn>
- <ThemedEditOutlined
- data-testid="rename-active-wallet"
- onClick={() =>
- showPopulatedRenameWalletModal(
- wallet,
- )
- }
- />
- <ThemedContactsOutlined
- onClick={() =>
- addSavedWalletToContact(
- wallet,
- )
- }
- />
- </SWButtonCtn>
- </AWRow>
- ) : (
- <SWRow key={`${wallet.name}_${index}`}>
- <Tooltip
- title={wallet.name}
- autoAdjustOverflow={true}
- >
- <SWName>
- <h3 className="overflow notranslate">
- {wallet.name}
- </h3>
- </SWName>
- </Tooltip>
- <SWBalance>
- <div className="overflow">
- [
- {wallet?.state
- ?.balanceSats !== 0
- ? toXec(
- wallet.state
- .balanceSats,
- ).toLocaleString(
- userLocale,
- {
- maximumFractionDigits:
- appConfig.cashDecimals,
- },
- )
- : 'N/A'}{' '}
- XEC]
- </div>
- </SWBalance>
- <SWButtonCtn>
- <ThemedEditOutlined
- data-testid="rename-saved-wallet"
- onClick={() =>
- showPopulatedRenameWalletModal(
- wallet,
- )
- }
- />
- <ThemedContactsOutlined
- data-testid="add-saved-wallet-to-contact-btn"
- onClick={() =>
- addSavedWalletToContact(
- wallet,
- )
- }
- />
- <ThemedTrashcanOutlined
- data-testid="delete-saved-wallet"
- onClick={() =>
- showPopulatedDeleteWalletModal(
- wallet,
- )
- }
- />
- <button
- onClick={() =>
- activateWallet(
- wallet,
- wallets,
- )
- }
- >
- Activate
- </button>
- </SWButtonCtn>
- </SWRow>
- ),
- )}
- </div>
- </Panel>
- </StyledCollapse>
- </>
- )}
<StyledSpacer />
<h2>
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('<Configure />', () => {
let user;
beforeEach(() => {
@@ -92,391 +83,257 @@
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(
<CashtabTestWrapper chronik={mockedChronik} route="/configure" />,
);
- // 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'));
-
- // Confirm new wallet added to contacts
- await waitFor(async () =>
- expect(await localforage.getItem('contactList')).toEqual(
- populatedContactList.concat(addedSavedWalletContact),
- ),
- );
+ // We are on the settings screen
+ await screen.findByTestId('configure-ctn');
- // 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(
<CashtabTestWrapper chronik={mockedChronik} route="/configure" />,
);
- // Wait for the app to load
- await waitFor(() =>
- expect(screen.queryByTestId('loading-ctn')).not.toBeInTheDocument(),
- );
+ // We are on the settings screen
+ await screen.findByTestId('configure-ctn');
- // 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();
-
- // 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(<CashtabTestWrapper chronik={mockedChronik} />);
- // 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(<CashtabTestWrapper chronik={mockedChronik} />);
- // 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"
});
});
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
@@ -4,7 +4,7 @@
import React from 'react';
import { walletWithXecAndTokens } from 'components/App/fixtures/mocks';
-import { populatedContactList } from 'components/Configure/fixtures/mocks';
+import { populatedContactList } from 'components/Wallets/fixtures/mocks';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent, {
PointerEventsCheckLevel,
@@ -61,7 +61,7 @@
),
}));
-describe('<Configure />', () => {
+describe('<Wallets />', () => {
let user;
beforeEach(() => {
// Set up userEvent to skip pointerEvents check, which returns false positives with antd
@@ -114,22 +114,15 @@
savedWallet,
]);
- render(
- <CashtabTestWrapper chronik={mockedChronik} route="/configure" />,
- );
+ render(<CashtabTestWrapper chronik={mockedChronik} route="/wallets" />);
// 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();
@@ -138,9 +131,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(
@@ -148,14 +138,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(
@@ -185,9 +172,7 @@
walletToBeActivatedLaterInTest,
);
- render(
- <CashtabTestWrapper chronik={mockedChronik} route="/configure" />,
- );
+ render(<CashtabTestWrapper chronik={mockedChronik} route="/wallets" />);
// Wait for the app to load
await waitFor(() =>
@@ -208,36 +193,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'),
@@ -249,7 +222,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
@@ -271,7 +244,7 @@
// We get a confirmation modal
expect(
await screen.findByText(
- 'Wallet "Transaction Fixtures" renamed to "ACTIVE WALLET"',
+ '"Transaction Fixtures" renamed to "ACTIVE WALLET"',
),
).toBeInTheDocument();
@@ -288,9 +261,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
@@ -304,9 +275,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
@@ -338,11 +307,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
@@ -361,18 +328,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(
@@ -380,9 +342,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(
@@ -391,12 +351,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);
@@ -426,6 +384,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)'),
@@ -433,7 +398,7 @@
);
// The button is not disabled
- expect(importBtn).not.toHaveAttribute('disabled');
+ expect(importBtn).toHaveProperty('disabled', false);
// Click import
await user.click(importBtn);
diff --git a/cashtab/src/components/Configure/fixtures/mocks.js b/cashtab/src/components/Wallets/fixtures/mocks.js
rename from cashtab/src/components/Configure/fixtures/mocks.js
rename to cashtab/src/components/Wallets/fixtures/mocks.js
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,460 @@
+// 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,
+ WalletName,
+ ButtonPanel,
+ WalletBalance,
+} from 'components/Wallets/styles';
+import { getWalletNameError, validateMnemonic } from 'validation';
+import {
+ createCashtabWallet,
+ generateMnemonic,
+ getWalletsForNewActiveWallet,
+ toXec,
+} from 'wallet';
+import { getUserLocale } from 'helpers';
+import appConfig from 'config/app';
+import { Event } from 'components/Common/GoogleAnalytics';
+
+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 && (
+ <Modal
+ height={180}
+ title={`Rename "${walletToBeRenamed.name}"?`}
+ handleOk={renameWallet}
+ handleCancel={() => setWalletToBeRenamed(null)}
+ showCancelButton
+ disabled={
+ formDataErrors.renamedWalletName !== false ||
+ formData.renamedWalletName === ''
+ }
+ >
+ <ModalInput
+ placeholder="Enter new wallet name"
+ name="renamedWalletName"
+ value={formData.renamedWalletName}
+ error={formDataErrors.renamedWalletName}
+ handleInput={handleInput}
+ />
+ </Modal>
+ )}
+ {walletToBeDeleted !== null && (
+ <Modal
+ height={210}
+ title={`Delete "${walletToBeDeleted.name}"?`}
+ handleOk={deleteWallet}
+ handleCancel={() => setWalletToBeDeleted(null)}
+ showCancelButton
+ disabled={
+ formDataErrors.walletToBeDeletedName !== false ||
+ formData.walletToBeDeletedName === ''
+ }
+ >
+ <ModalInput
+ placeholder={`Type "delete ${walletToBeDeleted.name}" to confirm`}
+ name="walletToBeDeletedName"
+ value={formData.walletToBeDeletedName}
+ handleInput={handleInput}
+ error={formDataErrors.walletToBeDeletedName}
+ />
+ </Modal>
+ )}
+ {showImportWalletModal && (
+ <Modal
+ height={180}
+ title={`Import wallet`}
+ handleOk={importNewWallet}
+ handleCancel={() => setShowImportWalletModal(false)}
+ showCancelButton
+ disabled={
+ formDataErrors.mnemonic !== false ||
+ formData.mnemonic === ''
+ }
+ >
+ <ModalInput
+ type="email"
+ placeholder="mnemonic (seed phrase)"
+ name="mnemonic"
+ value={formData.mnemonic}
+ error={formDataErrors.mnemonic}
+ handleInput={handleInput}
+ />
+ </Modal>
+ )}
+ <WalletsList data-testid="wallets">
+ <WalletsPanel>
+ {wallets.map((wallet, index) =>
+ index === 0 ? (
+ <Row key={`${wallet.name}_${index}`}>
+ <WalletName className="notranslate">
+ {wallet.name}
+ </WalletName>
+ <h4>Currently active</h4>
+ <ButtonPanel>
+ <CopyToClipboard
+ data={wallet.paths.get(1899).address}
+ showToast
+ >
+ <ThemedCopySolid />
+ </CopyToClipboard>
+ <ThemedEditOutlined
+ data-testid="rename-active-wallet"
+ onClick={() =>
+ setWalletToBeRenamed(wallet)
+ }
+ />
+ <ThemedContactsOutlined
+ onClick={() =>
+ addWalletToContacts(wallet)
+ }
+ />
+ </ButtonPanel>
+ </Row>
+ ) : (
+ <Row key={`${wallet.name}_${index}`}>
+ <WalletName>
+ <h3 className="overflow notranslate">
+ {wallet.name}
+ </h3>
+ </WalletName>
+ <WalletBalance>
+ <div className="overflow">
+ [
+ {wallet?.state?.balanceSats !== 0
+ ? toXec(
+ wallet.state.balanceSats,
+ ).toLocaleString(userLocale, {
+ maximumFractionDigits:
+ appConfig.cashDecimals,
+ })
+ : 'N/A'}{' '}
+ XEC]
+ </div>
+ </WalletBalance>
+ <ButtonPanel>
+ <CopyToClipboard
+ data={wallet.paths.get(1899).address}
+ showToast
+ >
+ <ThemedCopySolid />
+ </CopyToClipboard>
+ <ThemedEditOutlined
+ data-testid="rename-saved-wallet"
+ onClick={() =>
+ setWalletToBeRenamed(wallet)
+ }
+ />
+ <ThemedContactsOutlined
+ data-testid="add-saved-wallet-to-contact-btn"
+ onClick={() =>
+ addWalletToContacts(wallet)
+ }
+ />
+ <ThemedTrashcanOutlined
+ data-testid="delete-saved-wallet"
+ onClick={() =>
+ setWalletToBeDeleted(wallet)
+ }
+ />
+ <button
+ onClick={() =>
+ activateWallet(wallet, wallets)
+ }
+ >
+ Activate
+ </button>
+ </ButtonPanel>
+ </Row>
+ ),
+ )}
+ </WalletsPanel>
+ <Row>
+ <PrimaryButton onClick={() => addNewWallet()}>
+ New Wallet
+ </PrimaryButton>
+ </Row>
+ <Row>
+ <SecondaryButton
+ onClick={() => setShowImportWalletModal(true)}
+ >
+ Import Wallet
+ </SecondaryButton>
+ </Row>
+ </WalletsList>
+ </>
+ );
+};
+
+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,84 @@
+// 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: 33%;
+`;
+
+export const ButtonPanel = styled.div`
+ display: flex;
+ gap: 9px;
+ align-items: center;
+`;
+
+export const WalletBalance = 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;
+ }
+`;
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,
@@ -32,6 +31,7 @@
nodeWillAcceptOpReturnRaw,
getContactNameError,
getContactAddressError,
+ getWalletNameError,
} from 'validation';
import {
validXecAirdropExclusionList,
@@ -358,22 +358,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);
});
@@ -650,4 +634,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
@@ -439,13 +439,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 => {

File Metadata

Mime Type
text/plain
Expires
Sat, Dec 28, 19:39 (7 h, 52 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
4844978
Default Alt Text
D15917.id46917.diff (105 KB)

Event Timeline