Page Menu
Home
Phabricator
Search
Configure Global Search
Log In
Files
F12428775
D15917.id46917.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Award Token
Flag For Later
Size
105 KB
Subscribers
None
D15917.id46917.diff
View Options
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
Details
Attached
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)
Attached To
D15917: [Cashtab] Move wallet mgmt to its own screen
Event Timeline
Log In to Comment